Skip to main content
Rust intermediate Lesson 14 of 30

Traits in Rust

Define traits, implement them on types, use trait bounds, default methods, and understand object safety.

Defining a Trait

Traits are Rust’s primary abstraction mechanism. A trait declares a set of method signatures that any conforming type must provide, letting you write code that works across many different types without knowing which specific type you’ll receive. This is how the standard library achieves polymorphism without inheritance — Display, Iterator, Clone, and hundreds of other behaviors are all traits.

trait Greet {
    fn hello(&self) -> String;

    // Default implementation — types can override this, but don't have to
    fn goodbye(&self) -> String {
        String::from("Goodbye!")
    }
}

struct English;
struct Spanish;

impl Greet for English {
    fn hello(&self) -> String {
        String::from("Hello!")
    }
}

impl Greet for Spanish {
    fn hello(&self) -> String {
        String::from("¡Hola!")
    }

    // Override the default goodbye
    fn goodbye(&self) -> String {
        String::from("¡Adiós!")
    }
}

fn main() {
    let e = English;
    let s = Spanish;

    println!("{}", e.hello());   // Hello!
    println!("{}", e.goodbye()); // Goodbye!  (uses default)
    println!("{}", s.hello());   // ¡Hola!
    println!("{}", s.goodbye()); // ¡Adiós!   (overridden)
}

Trait Bounds on Functions

Trait bounds let you write functions that accept any type implementing a given trait, rather than a single concrete type. This is how you get the benefits of generics while still constraining what operations are available. The compiler generates a specialized version of the function for each concrete type used — zero runtime overhead.

use std::fmt::Display;

// Syntax 1: inline bound — T must implement both PartialOrd and Display
fn print_largest<T: PartialOrd + Display>(list: &[T]) {
    let mut largest = &list[0];
    for item in list {
        if item > largest {
            largest = item;
        }
    }
    println!("largest: {}", largest);
}

// Syntax 2: where clause — easier to read when bounds are complex
fn print_pair<T, U>(a: T, b: U)
where
    T: Display + Clone,
    U: Display,
{
    println!("{} and {}", a.clone(), b);
}

fn main() {
    print_largest(&[34, 50, 25, 100, 65]); // largest: 100
    print_largest(&[1.5, 2.3, 0.8, 9.1]);  // largest: 9.1
    print_pair("hello", 42);
}

impl Trait Syntax — Static Dispatch

impl Trait in a parameter or return position is shorthand for a trait bound and uses static dispatch (monomorphization). In return position it is especially useful when you want to return a closure or an iterator without exposing the concrete type — the compiler knows the exact type, the caller only sees the trait.

// Parameter position — equivalent to fn notify<T: Display>(item: &T)
fn notify(item: &impl Display) {
    println!("Alert: {}", item);
}

// Return position — the concrete type is opaque; caller only sees Display
fn make_greeting(name: &str) -> impl Display {
    format!("Hello, {}!", name)
}

fn main() {
    notify(&42);
    notify(&"hello");
    println!("{}", make_greeting("Alice"));
}

dyn Trait — Dynamic Dispatch

dyn Trait is a trait object — the concrete type is erased and method dispatch happens at runtime via a vtable (a table of function pointers). This costs a small indirection per call, but it enables heterogeneous collections where elements can be different types sharing a common interface. Use Box<dyn Trait> when you need to store or return values whose concrete type varies at runtime.

trait Animal {
    fn sound(&self) -> &str;
    fn name(&self) -> &str;
}

struct Dog;
struct Cat;

impl Animal for Dog {
    fn sound(&self) -> &str { "woof" }
    fn name(&self) -> &str { "dog" }
}

impl Animal for Cat {
    fn sound(&self) -> &str { "meow" }
    fn name(&self) -> &str { "cat" }
}

// Accepts a slice of any Animal — the types can differ
fn make_sounds(animals: &[Box<dyn Animal>]) {
    for animal in animals {
        println!("{} says {}", animal.name(), animal.sound());
    }
}

fn main() {
    // Dog and Cat can coexist in the same Vec thanks to dyn Trait
    let animals: Vec<Box<dyn Animal>> = vec![
        Box::new(Dog),
        Box::new(Cat),
        Box::new(Dog),
    ];
    make_sounds(&animals);
}

Implementing Standard Library Traits

Display and Debug

Implementing standard library traits integrates your types into the broader Rust ecosystem. Display controls what println!("{}", ...) shows to users. Debug controls println!("{:?}", ...) for developer output. Implementing them means your type works naturally with format strings, error messages, and logging.

use std::fmt;

struct Temperature {
    celsius: f64,
}

impl fmt::Display for Temperature {
    fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
        // User-facing: show both Celsius and the Fahrenheit conversion
        write!(f, "{:.1}°C ({:.1}°F)", self.celsius, self.celsius * 1.8 + 32.0)
    }
}

impl fmt::Debug for Temperature {
    fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
        // Developer-facing: show the raw struct fields
        write!(f, "Temperature {{ celsius: {} }}", self.celsius)
    }
}

fn main() {
    let t = Temperature { celsius: 100.0 };
    println!("{}", t);   // 100.0°C (212.0°F)
    println!("{:?}", t); // Temperature { celsius: 100 }
}

From / Into

From and Into are conversion traits that express “this type can be constructed from another type.” They matter especially for error handling — the ? operator uses From to automatically convert between error types. Implementing From<T> gives you Into<T> for free through a blanket impl in the standard library.

#[derive(Debug)]
struct Celsius(f64);
#[derive(Debug)]
struct Fahrenheit(f64);

impl From<Celsius> for Fahrenheit {
    fn from(c: Celsius) -> Fahrenheit {
        Fahrenheit(c.0 * 1.8 + 32.0)
    }
}

fn main() {
    let boiling = Celsius(100.0);
    let f: Fahrenheit = boiling.into(); // .into() uses the From impl above
    println!("{:?}", f); // Fahrenheit(212.0)

    let freezing = Fahrenheit::from(Celsius(0.0));
    println!("{:?}", freezing); // Fahrenheit(32.0)
}

Iterator

Implementing Iterator on your type unlocks the entire iterator adapter ecosystem for free — map, filter, take, zip, enumerate, sum, collect, and 70+ more methods all work automatically once you define a single next method. This is the power of trait-based design: you implement the minimal interface and inherit a huge amount of behavior.

struct Counter {
    count: u32,
    max: u32,
}

impl Counter {
    fn new(max: u32) -> Self { Self { count: 0, max } }
}

impl Iterator for Counter {
    type Item = u32;

    // The only method you must implement — everything else comes for free
    fn next(&mut self) -> Option<u32> {
        if self.count < self.max {
            self.count += 1;
            Some(self.count)
        } else {
            None
        }
    }
}

fn main() {
    // sum(), zip(), skip() all work automatically from the Iterator impl
    let sum: u32 = Counter::new(5).sum();
    println!("sum 1..=5: {}", sum); // 15

    let pairs: Vec<_> = Counter::new(3)
        .zip(Counter::new(3).skip(1))
        .collect();
    println!("{:?}", pairs); // [(1, 2), (2, 3)]
}

Operator Overloading via Traits

Rust implements operator overloading through traits in the std::ops module. Overloading + for a custom type means implementing the Add trait. This keeps the language consistent — operators are just syntax sugar for trait method calls, so you can always fall back to the method if needed.

use std::ops::{Add, Mul, Neg};

#[derive(Debug, Clone, Copy, PartialEq)]
struct Vec2 { x: f64, y: f64 }

impl Add for Vec2 {
    type Output = Vec2;
    fn add(self, rhs: Vec2) -> Vec2 {
        Vec2 { x: self.x + rhs.x, y: self.y + rhs.y }
    }
}

impl Mul<f64> for Vec2 {
    type Output = Vec2;
    fn mul(self, scalar: f64) -> Vec2 {
        Vec2 { x: self.x * scalar, y: self.y * scalar }
    }
}

impl Neg for Vec2 {
    type Output = Vec2;
    fn neg(self) -> Vec2 { Vec2 { x: -self.x, y: -self.y } }
}

fn main() {
    let a = Vec2 { x: 1.0, y: 2.0 };
    let b = Vec2 { x: 3.0, y: 4.0 };
    println!("{:?}", a + b);     // Vec2 { x: 4.0, y: 6.0 }
    println!("{:?}", a * 3.0);   // Vec2 { x: 3.0, y: 6.0 }
    println!("{:?}", -b);        // Vec2 { x: -3.0, y: -4.0 }
}

Supertraits

Sometimes a trait only makes sense when another trait is already implemented. Supertraits express this dependency — they say “to implement this trait, you must also implement these other traits.” This is useful for building layered abstractions where a higher-level capability requires lower-level capabilities to already be in place.

use std::fmt;

// Summary requires Display and Debug — any type implementing Summary
// must also implement both formatting traits
trait Summary: fmt::Display + fmt::Debug {
    fn summarize(&self) -> String;

    // Default implementation can use the supertrait methods
    fn print_summary(&self) {
        println!("Summary: {}", self.summarize());
        println!("Debug: {:?}", self);
    }
}

Any type implementing Summary must also implement Display and Debug.

Object Safety

A trait is object-safe (usable as dyn Trait) when the compiler can build a vtable for it. The vtable is a fixed-size table of function pointers, so every method must be dispatchable without knowing the concrete type at compile time. Methods that return Self break this because the return type size would vary; methods with generic parameters break it because there would need to be infinitely many vtable entries.

// NOT object-safe — clone() returns Self, so the return size is unknown
// at the point of the vtable call
// trait MyClone { fn clone(&self) -> Self; }

// Object-safe — all methods dispatch through a fixed vtable
trait Drawable {
    fn draw(&self);
    fn bounding_box(&self) -> (f64, f64, f64, f64);
}

// This works — dyn Drawable is valid
fn render(items: &[Box<dyn Drawable>]) {
    for item in items {
        item.draw();
    }
}

Frequently Asked Questions

What is a trait in Rust?
A trait defines a set of methods that a type must implement. It is similar to an interface in Java/Go or a typeclass in Haskell.
What is the difference between impl Trait and dyn Trait?
impl Trait is static dispatch — the concrete type is known at compile time and monomorphized. dyn Trait is dynamic dispatch — the concrete type is resolved at runtime via a vtable.
What is object safety?
A trait is object-safe if it can be used as dyn Trait. A trait is not object-safe if its methods return Self, use generic type parameters, or have other restrictions that prevent vtable dispatch.