How to Use Trait Objects for Dynamic Dispatch

Use the `dyn` keyword with a pointer like `Box` to store different types implementing a common trait in a single collection for dynamic dispatch.

When one type isn't enough

You are building a simple UI framework. You have a Button, a TextField, and an Image. Each one knows how to paint itself on the screen. You want to store a list of widgets and iterate over them to render the whole window. In Python, you would just shove them into a list. In Rust, the compiler rejects you. It refuses to put a Button and a TextField in the same Vec because they have different sizes and different layouts in memory.

You need a way to treat them as the same thing without losing their individual behavior. Trait objects solve this problem. They let you store values of different types in a single collection as long as those types implement a common trait. The compiler generates a dispatch table at compile time, and your code calls the correct method at runtime.

Trait objects bridge the gap between Rust's strict compile-time rules and the flexibility you need for real-world systems.

The concept: dynamic dispatch and vtables

Rust usually uses static dispatch. When you call a method, the compiler knows the exact type of the value. It generates a direct call to the function. This is fast. The compiler can inline the function and optimize the code aggressively.

Trait objects use dynamic dispatch. The compiler does not know the exact type at compile time. It only knows the trait. To make this work, the compiler builds a vtable (virtual method table) for each type that implements the trait. The vtable is a static array of function pointers. It contains the address of every method in the trait for that specific type.

When you store a value as a trait object, the value goes on the heap. You get back a fat pointer. The fat pointer holds two pieces of data: a pointer to the actual value, and a pointer to the vtable. When you call a method on the trait object, the program follows the vtable pointer, finds the offset for the method, and jumps to that address. The decision happens at runtime.

The vtable turns a trait definition into a runtime map. The compiler does the heavy lifting; you just call the method.

Minimal example

Here is the basic pattern. You define a trait, implement it on structs, and store them as Box<dyn Trait>.

trait Draw {
    fn draw(&self);
}

struct Button;

impl Draw for Button {
    fn draw(&self) {
        // Button draws a rectangle with text.
        println!("Drawing Button");
    }
}

struct TextField;

impl Draw for TextField {
    fn draw(&self) {
        // TextField draws a box with a cursor.
        println!("Drawing TextField");
    }
}

fn main() {
    // Box<dyn Draw> stores a pointer to the heap.
    // The pointer size is fixed, so they fit in a Vec.
    let widgets: Vec<Box<dyn Draw>> = vec![
        Box::new(Button),
        Box::new(TextField),
    ];

    for widget in widgets {
        // Dynamic dispatch: the compiler generates code to look up
        // the correct draw function at runtime via the vtable.
        widget.draw();
    }
}

The dyn keyword is mandatory. It marks the type as a trait object. You cannot write Box<Draw>. You must write Box<dyn Draw>. The dyn tells the compiler that this is not a concrete type. It is a pointer to something that implements the trait.

The dyn keyword is your signal to the compiler. It says: "I know this is a pointer, and I accept the indirection."

How the compiler builds the magic

Under the hood, Box<dyn Draw> is a fat pointer. It takes up twice the space of a regular pointer. On a 64-bit system, a regular pointer is 8 bytes. A fat pointer is 16 bytes. The first 8 bytes point to the data on the heap. The second 8 bytes point to the vtable.

The vtable is generated by the compiler. For Button, the vtable contains the address of Button::draw. For TextField, it contains the address of TextField::draw. The vtable also contains metadata. It includes a drop glue pointer that tells the runtime how to clean up the value when the Box is dropped. It includes the size and alignment of the original type. This metadata allows the runtime to handle types of different sizes correctly.

When you call widget.draw(), the CPU loads the vtable pointer from the fat pointer. It adds the offset for the draw method. It loads the function address from that location. It jumps to the address. This indirection costs a tiny bit of performance. It prevents the compiler from inlining the method. But it gives you the ability to mix types freely.

Convention aside: You will see Box<dyn Trait> everywhere. You can also use &dyn Trait for references or Rc<dyn Trait> for shared ownership. The dyn keyword is required in all cases. Before Rust 2018, you could write Box<Trait> and the compiler assumed dyn. This was confusing. Rust 2018 made dyn explicit to force you to acknowledge the indirection.

The fat pointer carries the vtable. Without it, the runtime would not know which function to call.

Realistic example: A command history

Trait objects shine when you need to extend a system without changing the core code. Imagine a command history for a text editor. Commands can be Save, Load, Delete, or Undo. You want to store them in a list and replay them later.

trait Command {
    fn execute(&self);
    fn description(&self) -> &'static str;
}

struct SaveCommand {
    filename: String,
}

impl Command for SaveCommand {
    fn execute(&self) {
        println!("Saving to {}", self.filename);
    }

    fn description(&self) -> &'static str {
        "Saves the current document"
    }
}

struct DeleteCommand {
    path: String,
}

impl Command for DeleteCommand {
    fn execute(&self) {
        println!("Deleting {}", self.path);
    }

    fn description(&self) -> &'static str {
        "Deletes a file"
    }
}

struct History {
    commands: Vec<Box<dyn Command>>,
}

impl History {
    fn new() -> Self {
        Self {
            commands: Vec::new(),
        }
    }

    fn add(&mut self, cmd: Box<dyn Command>) {
        self.commands.push(cmd);
    }

    fn replay(&self) {
        for cmd in &self.commands {
            println!("Replaying: {}", cmd.description());
            cmd.execute();
        }
    }
}

fn main() {
    let mut history = History::new();
    history.add(Box::new(SaveCommand { filename: "notes.txt".to_string() }));
    history.add(Box::new(DeleteCommand { path: "temp.log".to_string() }));
    history.replay();
}

The History struct does not know about SaveCommand or DeleteCommand. It only knows about the Command trait. You can add a PrintCommand or a SearchCommand without touching the History code. You just implement the trait and add it to the list.

Trait objects make it easy to extend systems without changing the core code. Add a new command, implement the trait, and the history just works.

Object safety: the rules of the road

Not every trait can be used as a trait object. The trait must be object safe. The compiler needs to be able to build a vtable for the trait. The vtable must have a fixed layout. This imposes restrictions on the trait definition.

The most common rule is about Self. A trait method cannot return Self if the trait is used as a trait object. The vtable cannot hold a function that returns a variable size. The compiler does not know what type Self refers to at runtime. It could be Button or TextField. The vtable needs a concrete return type.

If you try to use a trait with a Self return type as a trait object, the compiler rejects you with an error about the trait not being object safe. The error message explains that the method returns Self, which prevents object safety.

You can work around this. If a method returns Self, you can add a where Self: Sized bound to that method. This tells the compiler that the method is only available for concrete types. The method will not appear in the vtable. You can still call it on a Button, but you cannot call it on a Box<dyn Trait>.

Generic methods also break object safety. The vtable cannot encode generic parameters. If a trait has a generic method, you cannot use it as a trait object unless you bound the method with Self: Sized.

Object safety is a hard constraint. If a method returns Self, the trait is not object safe. Rewrite the method to return a reference or a concrete type.

Pitfalls and performance

Trait objects come with trade-offs. The first is the Sized bound. Every type in Rust has a known size at compile time by default. This is enforced by the Sized trait. Trait objects do not have a known size. They are !Sized.

When you use a generic function, the compiler adds a Sized bound automatically.

fn process<T: Draw>(item: T) {
    item.draw();
}

If you try to call process with a trait object, the compiler rejects you with E0277 (trait bound not satisfied). It tells you that dyn Draw does not implement Sized. You cannot pass a trait object by value to a generic function. You must pass it by reference or by box.

fn process(item: &dyn Draw) {
    item.draw();
}

The second trade-off is performance. Dynamic dispatch is slower than static dispatch. The CPU has to dereference the vtable pointer. This adds memory latency. It also prevents inlining. The compiler cannot see the body of the function at compile time. It has to generate a call instruction. This hurts branch prediction and optimization.

For UI frameworks, game engines, and plugin systems, the overhead is negligible. The work done by the method dwarfs the cost of the vtable lookup. If you are in a performance-critical inner loop, avoid trait objects. Use generics or enums instead.

Dynamic dispatch trades speed for flexibility. Measure before you optimize. Most applications do not feel the vtable lookup cost.

Decision: trait objects vs alternatives

Choose the right tool for your constraints. Trait objects are powerful, but they are not always the best choice.

Use trait objects when you need to store different types in a single collection and the types are not known until runtime.

Use trait objects when you are building a plugin system or a UI framework where new types can be added without recompiling the core library.

Use generics when the types are known at compile time and you want the compiler to inline methods for maximum performance.

Use an enum when the set of variants is small and fixed, and you want to avoid heap allocation and vtable lookups.

Use &dyn Trait when you need to pass a trait object to a function without taking ownership.

Use Box<dyn Trait> when you need to own the trait object and move it around.

Pick the tool that matches your constraints. Flexibility costs memory and speed. Generics cost code size. Enums cost nothing but imagination.

Where to go next