Trait Objects and dyn

A trait in Rust describes shared behavior, but a plain trait bound like T: Shape only works when the compiler knows the concrete type at compile time. Trait objects, written as dyn Trait, let you store or pass around values of different concrete types through one shared interface, with the actual method to call decided at runtime. They are how Rust gets the flexibility of the polymorphism you might know from languages with inheritance and virtual methods, without a garbage collector and without giving up compile-time safety.

Overview: How Trait Objects Work

To see why dyn Trait exists, first look at what Rust does without it. When you write a generic function such as fn describe<T: Shape>(item: &T), the compiler does not generate one function that works for every Shape. Instead, for every concrete type you call it with — Circle, Square, and so on — the compiler generates a separate, specialized copy of the function with that type baked in. This process is called monomorphization, and the result is static dispatch: by the time the program runs, every call to a trait method has already been resolved to one exact function address, so the call can be inlined and is as fast as calling a normal function. The cost is that the compiler must know every concrete type up front, and a single variable, field, or Vec can only ever hold one concrete type at a time.

That last restriction is the problem dyn Trait solves. Picture a universal remote control: pressing the volume-up button doesn’t hard-wire the remote to one specific television model. Instead, at the moment you press the button, the remote looks up which device is currently paired and sends the command that device understands. A trait object works the same way. Instead of baking in one concrete type at compile time, a value of type dyn Shape carries two things at runtime: a pointer to the actual data (a Circle, a Square, whatever it happens to be) and a pointer to a vtable — a small table of function pointers, one per method in the trait, that point at the correct implementation for that specific type. Calling shape.area() on a trait object means: follow the vtable pointer, find the area entry, and call whatever function address is stored there. This is dynamic dispatch — the decision of which code to run happens at runtime, through one extra pointer indirection, instead of being fixed at compile time.

Because a trait object carries two pointers instead of one, it does not have a single, statically known size — the compiler cannot say how big a dyn Shape is, because a Circle and a Square might be different sizes and dyn Shape has to represent either. Rust’s rule is that any type whose size is not known at compile time must always be handled through a pointer, never held directly on the stack as a bare value. That is why you never see a bare dyn Shape as a variable’s type — always &dyn Shape, &mut dyn Shape, Box<dyn Shape>, Rc<dyn Shape>, or Arc<dyn Shape>. The pointer itself is what Rust calls a fat pointer: twice the width of an ordinary reference, because it has to carry both the data address and the vtable address.

Not every trait can become a trait object. A trait is object-safe (sometimes called dyn-compatible) only if the compiler can build a vtable for it — which rules out methods that are generic over a type parameter, methods that return Self by value, and a few other patterns. The Common Mistakes section below shows what happens when you break this rule and how to fix it.

Syntax

Trait objects always appear behind a pointer. The table below shows the forms you’ll use, followed by a bare syntax pattern.

Form Meaning
&dyn Trait A borrowed, read-only trait object; the cheapest form, valid only as long as the borrow lasts.
&mut dyn Trait A borrowed, mutable trait object.
Box<dyn Trait> An owned trait object on the heap; the most common form for storing trait objects in structs or collections.
Rc<dyn Trait> A reference-counted, shared, single-threaded trait object.
Arc<dyn Trait> A reference-counted, shared, thread-safe trait object.
&dyn Trait
&mut dyn Trait
Box<dyn Trait>
Rc<dyn Trait>
Arc<dyn Trait>

fn takes_ref(item: &dyn Trait) { ... }
fn takes_box(item: Box<dyn Trait>) { ... }
fn returns_box() -> Box<dyn Trait> { ... }

The dyn keyword marks the type as a trait object rather than a generic bound. Since the 2018 edition, writing dyn explicitly is required for clarity — it tells the reader at a glance that dynamic dispatch, not monomorphization, is happening at this spot.

Examples

Example 1: A Basic Trait Object Collection

The simplest use of dyn Trait is storing different types that implement the same trait in one Vec. This would not compile with generics alone, because a Vec<T> needs exactly one concrete T.

trait Animal {
    fn speak(&self) -> String;
}

struct Dog;
struct Cat;

impl Animal for Dog {
    fn speak(&self) -> String {
        String::from("Woof!")
    }
}

impl Animal for Cat {
    fn speak(&self) -> String {
        String::from("Meow!")
    }
}

fn main() {
    let animals: Vec<Box<dyn Animal>> = vec![Box::new(Dog), Box::new(Cat)];
    for animal in animals.iter() {
        println!("{}", animal.speak());
    }
}

Output:

Woof!
Meow!

Each element of animals is a Box<dyn Animal> — a heap allocation holding either a Dog or a Cat, plus a vtable pointer for whichever type it is. The loop doesn’t know or care which concrete type each element is; calling .speak() dispatches through the vtable to the right implementation every time.

Example 2: Passing a Trait Object by Reference

You don’t need to own a trait object to use it. A function that only needs to read through the trait’s methods should take a borrowed &dyn Trait instead of an owned Box<dyn Trait>, avoiding an unnecessary heap allocation at the call site.

trait Shape {
    fn area(&self) -> f64;
    fn name(&self) -> &str;
}

struct Circle {
    radius: f64,
}

struct Square {
    side: f64,
}

impl Shape for Circle {
    fn area(&self) -> f64 {
        std::f64::consts::PI * self.radius * self.radius
    }
    fn name(&self) -> &str {
        "Circle"
    }
}

impl Shape for Square {
    fn area(&self) -> f64 {
        self.side * self.side
    }
    fn name(&self) -> &str {
        "Square"
    }
}

fn print_area(shape: &dyn Shape) {
    println!("{} area: {:.2}", shape.name(), shape.area());
}

fn main() {
    let circle = Circle { radius: 2.0 };
    let square = Square { side: 3.0 };

    print_area(&circle);
    print_area(&square);
}

Output:

Circle area: 12.57
Square area: 9.00

print_area takes &dyn Shape, so it can accept a reference to a Circle or a Square without any change in its own signature, and without either shape ever moving onto the heap. This is the pattern to reach for whenever a function just needs to call a few trait methods and hand the value back to its caller unchanged.

Example 3: A Realistic Plugin-Style System

Trait objects earn their keep in situations like a notification system, where you want to register an open-ended list of notification channels and treat them uniformly.

trait Notifier {
    fn send(&self, message: &str);
}

struct EmailNotifier {
    address: String,
}

struct SmsNotifier {
    phone: String,
}

impl Notifier for EmailNotifier {
    fn send(&self, message: &str) {
        println!("Emailing {}: {}", self.address, message);
    }
}

impl Notifier for SmsNotifier {
    fn send(&self, message: &str) {
        println!("Texting {}: {}", self.phone, message);
    }
}

struct NotificationCenter {
    notifiers: Vec<Box<dyn Notifier>>,
}

impl NotificationCenter {
    fn new() -> Self {
        NotificationCenter { notifiers: Vec::new() }
    }

    fn add(&mut self, notifier: Box<dyn Notifier>) {
        self.notifiers.push(notifier);
    }

    fn notify_all(&self, message: &str) {
        for notifier in self.notifiers.iter() {
            notifier.send(message);
        }
    }
}

fn main() {
    let mut center = NotificationCenter::new();
    center.add(Box::new(EmailNotifier { address: String::from("user@example.com") }));
    center.add(Box::new(SmsNotifier { phone: String::from("555-0100") }));

    center.notify_all("Server restarted");
}

Output:

Emailing user@example.com: Server restarted
Texting 555-0100: Server restarted

NotificationCenter doesn’t know at compile time how many notifiers it will hold or what concrete types they are — it just knows every one of them implements Notifier. New notifier types (a SlackNotifier, a PushNotifier) can be added later without touching NotificationCenter at all, as long as they implement the trait. That is the flexibility static dispatch cannot offer, because a generic Vec<T> could only ever hold one of those types at once.

How It Works Step by Step

Walk through what happens when Box::new(Dog) is coerced into Box<dyn Animal> and then .speak() is called on it:

  1. The compiler sees that a Box<Dog> is being placed where a Box<dyn Animal> is expected, and performs an unsizing coercion: it builds a vtable for Dog‘s implementation of Animal — one function pointer per trait method, plus bookkeeping like the size and destructor for Dog.
  2. The resulting Box<dyn Animal> is a fat pointer: one word points at the Dog value on the heap, the other points at that vtable.
  3. When you call animal.speak(), the compiler cannot know at compile time which concrete speak to call, so it emits code that reads the vtable pointer, looks up the speak entry, and calls the function address stored there — passing the data pointer as &self.
  4. When the Box is dropped, the vtable’s stored destructor entry is used to correctly drop the underlying Dog, even though the code doing the dropping only knows the value as dyn Animal.

Every step after the initial coercion costs one extra pointer indirection compared to a direct function call — in practice, negligible for almost all programs, and the reason trait objects are considered a pay-for-what-you-use feature rather than a departure from Rust’s zero-cost philosophy.

Common Mistakes

Mistake 1: Trying to Make a Non-Object-Safe Trait into a Trait Object

A trait with a generic method cannot become a trait object, because the compiler would need a separate vtable entry for every possible type parameter — an unbounded, unknowable number of entries. This code fails to compile:

trait Serializer {
    fn serialize<T>(&self, value: T) -> String;
}

fn use_serializer(s: &dyn Serializer) {
    // error[E0038]: the trait `Serializer` cannot be made into an object
}

The compiler rejects &dyn Serializer with an object-safety error, because serialize is generic over T and the trait has no way to know, at the point the vtable is built, which T a caller might use. The fix is to remove the generic type parameter from the method — either by making the whole trait generic instead (trait Serializer<T>, fixed to one T per trait object), or, as here, by narrowing the method to a single concrete type it actually needs:

trait Serializer {
    fn serialize(&self, value: &str) -> String;
}

struct JsonSerializer;

impl Serializer for JsonSerializer {
    fn serialize(&self, value: &str) -> String {
        format!("\"{}\"", value)
    }
}

fn use_serializer(s: &dyn Serializer, value: &str) {
    println!("{}", s.serialize(value));
}

fn main() {
    let json = JsonSerializer;
    use_serializer(&json, "hello");
}

Output:

"hello"

With no generic parameter left on serialize, the compiler can build one fixed vtable entry for it, and &dyn Serializer compiles.

Mistake 2: Storing a Trait Object Without a Pointer

Because dyn Trait is unsized, trying to use it as a plain, by-value type — such as an element type in a Vec without a Box — fails to compile:

trait Shape {
    fn area(&self) -> f64;
}

fn main() {
    let shapes: Vec<dyn Shape> = Vec::new();
}

The compiler reports that dyn Shape doesn’t have a size known at compile time, because Vec<T> needs to know how many bytes each element takes, and different implementers of Shape can be different sizes. Wrapping the trait object in Box gives it a fixed, known size — one pointer — regardless of what’s behind it:

fn main() {
    trait Shape {
        fn area(&self) -> f64;
    }

    struct Circle {
        radius: f64,
    }

    impl Shape for Circle {
        fn area(&self) -> f64 {
            std::f64::consts::PI * self.radius * self.radius
        }
    }

    let shapes: Vec<Box<dyn Shape>> = vec![Box::new(Circle { radius: 1.0 })];
    println!("Number of shapes: {}", shapes.len());
}

Output:

Number of shapes: 1

Vec<Box<dyn Shape>> works because every element is the same size (one Box, i.e. one pointer), no matter which concrete Shape each box points at.

Best Practices

  • Default to generics with trait bounds when the concrete type is known at compile time; reach for dyn Trait only when you genuinely need to store or pass around different concrete types through the same interface at runtime.
  • Use &dyn Trait for a short-lived borrow passed into a function, and Box<dyn Trait> when the trait object needs to be owned — stored in a struct field, pushed into a collection, or returned from a function.
  • Reach for Rc<dyn Trait> or Arc<dyn Trait> only when the trait object genuinely needs multiple owners; don’t use them as a default way to avoid thinking about ownership.
  • Design traits meant to be used as trait objects to stay object-safe from the start: avoid generic methods, avoid methods that return Self by value, and avoid associated constants.
  • Don’t reach for dyn Trait just to silence a borrow-checker error you don’t understand yet — most such errors are about ownership, not about needing dynamic dispatch, and a Box, a clone(), or restructuring the borrow is usually the real fix.
  • When a function can only ever return one concrete type, prefer impl Trait as the return type over Box<dyn Trait> — it avoids the heap allocation and the vtable indirection entirely.

Practice Exercises

  1. Define a Logger trait with a method log(&self, message: &str). Implement it for a ConsoleLogger that prints the message, and a SilentLogger that does nothing. Write a function that takes a Vec<Box<dyn Logger>> and calls log on every entry with the same message.
  2. Define a Shape trait with an area(&self) -> f64 method. Implement it for a Rectangle and a Triangle. Write a function total_area(shapes: &[Box<dyn Shape>]) -> f64 that sums the area of every shape in the slice. Hint: .iter().map(...).sum() works on an iterator of f64.
  3. Take the broken Serializer trait from the Common Mistakes section, try compiling it yourself, and read the exact error the compiler gives. Then rewrite it as trait Serializer<T> { fn serialize(&self, value: T) -> String; } and figure out why dyn Serializer<String> is now object-safe even though the original serialize<T> method was not.

Summary

  • dyn Trait enables runtime polymorphism: different concrete types are handled through one shared interface, with the method to call chosen at runtime instead of compile time.
  • dyn Trait is unsized and must always be used behind a pointer: &dyn Trait, &mut dyn Trait, Box<dyn Trait>, Rc<dyn Trait>, or Arc<dyn Trait>.
  • Dynamic dispatch works through a vtable: a trait object is a fat pointer holding a data pointer and a vtable pointer, and each method call is an indirect call through that table.
  • Generics give static dispatch (monomorphized per type, fastest, larger binary, one concrete type per use); dyn Trait gives dynamic dispatch (one shared implementation, small indirection cost, smaller binary, supports mixed concrete types in one collection).
  • A trait must be object-safe to become a trait object — no generic methods, no returning Self by value, and a few other restrictions.
  • Choose generics when the type is known at compile time, and dyn Trait when you need runtime flexibility, such as heterogeneous collections or plugin-style systems.