Trait Objects and Dynamic Dispatch
Understand how trait objects enable runtime polymorphism in Rust, how dynamic dispatch works through vtables, and when to use dyn Trait instead of generics.
Trait Objects and Dynamic Dispatch
Rust’s generics provide static dispatch: the compiler knows exactly which function to call for every concrete type at build time. Trait objects flip that model. A trait object erases the concrete type behind a trait, leaving only a reference to shared behavior. The exact method implementation is chosen at runtime—this is dynamic dispatch.
You reach for trait objects when you need a single collection to hold values of different concrete types that all implement the same trait, or when the set of possible types is not known until the program runs. The price is a small runtime lookup and the loss of some compile-time optimizations. The gain is flexibility that generics alone cannot provide.
What a Trait Object Is
A trait object is a value that carries two pieces of information: a pointer to the actual data, and a pointer to a virtual method table (vtable) that contains the correct function implementations for that concrete type. The concrete type itself is hidden from the code that uses the trait object—you can only interact with it through the trait’s methods.
Trait objects are always used behind some kind of pointer. You will see them as &dyn Trait, Box<dyn Trait>, Rc<dyn Trait>, or Arc<dyn Trait>. The dyn keyword signals that the compiler should generate dynamic dispatch machinery for calls made through this pointer.
trait Greeter {
fn greet(&self) -> String;
}
fn say_hello(g: &dyn Greeter) {
println!("{}", g.greet());
}
The parameter g could be a reference to a Person, a Robot, or any other type that implements Greeter. The function say_hello never sees the concrete type. At the call site g.greet(), the program follows the vtable pointer, finds the greet entry, and calls the function pointed to by that entry.
Why Trait Objects Exist
Generics produce a separate compiled copy of a function or struct for each concrete type that is substituted. That works beautifully when you know all the types at compile time and want maximum speed. But it fails in two situations that trait objects solve:
- Heterogeneous collections: A
Vec<Box<dyn Draw>>can hold buttons, text fields, and custom widgets all in the same vector. A genericVec<T>can only hold one type per vector. - Open-ended extensibility: If you build a library and want downstream users to supply their own types that plug into your system, you cannot name those types in your generic parameters. A trait object allows the library to accept anything that implements a specific trait, even types that were created after the library was compiled.
This second case is why the classic example of trait objects is a GUI framework. The framework defines a Draw trait and a Screen that holds a collection of Box<dyn Draw>. Users of the library can define new components like Image or SelectBox without touching the library code—they just implement Draw and hand their types to Screen.
Trait Objects Are Not Traditional Objects:
Trait objects abstract over behavior, not data. You cannot add fields to a trait object, and you cannot call methods that are not part of the trait’s interface. If you need shared data plus shared behavior, pair the trait object with a separate data structure, or use an enum if the set of types is known and closed.
Creating and Using Trait Objects
The most common way to create a trait object is to box a concrete value and coerce the Box<T> into a Box<dyn Trait>. Rust performs this coercion automatically when the target type is unambiguous.
trait Draw {
fn draw(&self);
}
struct Button {
label: String,
}
impl Draw for Button {
fn draw(&self) {
println!("Drawing button: {}", self.label);
}
}
struct TextField {
placeholder: String,
}
impl Draw for TextField {
fn draw(&self) {
println!("Drawing text field: {}", self.placeholder);
}
}
struct Screen {
components: Vec<Box<dyn Draw>>,
}
impl Screen {
fn run(&self) {
for component in &self.components {
component.draw(); // dynamic dispatch
}
}
}
fn main() {
let screen = Screen {
components: vec![
Box::new(Button {
label: String::from("Click me"),
}),
Box::new(TextField {
placeholder: String::from("Enter name"),
}),
],
};
screen.run();
}
When component.draw() executes, the program looks at the vtable inside each Box<dyn Draw>, retrieves the function pointer for draw that matches the original concrete type, and calls it. A Button will print a label; a TextField will print its placeholder. The Screen::run method contains no match or if that inspects types—the dispatch is handled by the vtable.
Compile-Time Safety Still Applies:
Attempting to put a type that does not implement Draw into the vector is a compile error. For example, Box::new(String::from("Hi")) will not compile because String: Draw is not satisfied. The compiler checks the trait bound even though the concrete type is erased at runtime.
Trait objects can also be created from references, which avoids heap allocation when you just need to pass a reference to existing stack data:
fn render(widget: &dyn Draw) {
widget.draw();
}
let b = Button { label: String::from("OK") };
render(&b);
Behind the scenes, &b is coerced from &Button to &dyn Draw. This creates a fat pointer on the stack containing the address of b and the vtable for <Button as Draw>.
How Dynamic Dispatch Works Mechanically
A normal reference like &i32 is a thin pointer—one machine word holding the memory address of the i32. A trait object reference like &dyn Draw is a fat pointer—two machine words: one pointing to the data, and one pointing to the vtable.
The vtable is a static data structure generated by the compiler for each (concrete type, trait) combination. It contains one function pointer per method in the trait. For Button implementing Draw, the vtable has a single entry pointing to <Button as Draw>::draw. For TextField implementing Draw, a separate vtable exists with a pointer to <TextField as Draw>::draw.
When you write component.draw(), the compiler generates code equivalent to:
- Read the vtable pointer from the fat pointer.
- Index into the vtable to find the function pointer for
draw. - Call that function pointer, passing the data pointer as
self.
This indirection is the runtime cost of dynamic dispatch. It prevents the compiler from inlining the method call, because the target function is not known until the moment of the call. For methods called in tight loops, this can have a measurable impact. For calls made once per frame in a GUI or once per request in a network handler, the overhead is usually negligible.
&dyn Draw
┌──────────────────┐
│ data pointer │──────▶ Button { label: "OK" }
├──────────────────┤
│ vtable pointer │──┐
└──────────────────┘ │
▼
VTable for Button: Draw
┌──────────────────────┐
│ draw: &Button::draw │
└──────────────────────┘
Every concrete type that implements Draw gets its own vtable, but all of them have the same layout for a given trait. That is why a single Vec<Box<dyn Draw>> can interleave buttons and text fields freely—the layout of the fat pointer and the structure of the vtable are identical regardless of the underlying data.
Object Safety — Which Traits Can Be Used as Trait Objects
Not every trait can be turned into a trait object. A trait must be object-safe (also called dyn-compatible) for dyn Trait to be allowed. The compiler enforces this with a clear rule: every method in the trait must be callable through a vtable without knowing the concrete type.
Concretely, a trait is object-safe if, for every method:
- The method has a receiver (
self,&self,&mut self, orBox<self>). Methods without a receiver (associated functions called withType::function()) cannot be dispatched through a trait object because noselfpointer exists to find the vtable. - The method does not use
Selfin a position that would require knowing the concrete type’s size or identity. Specifically:- The return type cannot be
Self(unless the method has awhere Self: Sizedbound, which makes the method unavailable on trait objects entirely). - The method cannot have generic type parameters. Generic methods would require a separate monomorphization for each type argument, which cannot be represented in a fixed-size vtable.
- The return type cannot be
A Non-Object-Safe Trait Will Not Compile as dyn:
If you define a trait with fn clone(&self) -> Self, you cannot write Box<dyn ThatTrait>. The compiler will reject it. To use dynamic dispatch with a cloning capability, you can split the trait into an object-safe part and a non-object-safe part, or use a helper trait with where Self: Sized to exclude the problematic method from the trait object.
A common fix is to add where Self: Sized to the method that breaks object safety. That method will be available on concrete types but simply not callable through the trait object, because dyn Trait is not Sized. The rest of the trait remains usable as a trait object.
trait Draw {
fn draw(&self);
// Object-safe: &self receiver, no generic parameters, no Self return.
}
trait Cloneable {
fn duplicate(&self) -> Self
where
Self: Sized;
}
Here, Cloneable is still object-safe because the duplicate method with where Self: Sized is excluded from the vtable for dyn Cloneable. You cannot call duplicate on a trait object, but you can store types that implement Cloneable behind a dyn Cloneable and call other object-safe methods.
Trait Objects vs Generics (Static Dispatch)
The same problem—operating on values of different types through a shared interface—can often be solved with generics. A function fn draw_all(items: &[impl Draw]) uses static dispatch. The compiler monomorphizes a copy of draw_all for each concrete type passed in.
The trade-offs:
| Aspect | Generics (static dispatch) | Trait objects (dynamic dispatch) |
|---|---|---|
| When dispatch happens | Compile time | Runtime |
| Heterogeneous collections | No — one type per collection | Yes — mix types freely |
| Binary size | May increase (code duplicated per type) | Smaller (one version of the function) |
| Runtime speed | Faster (inlining possible, no indirection) | Slower (vtable lookup, no inlining) |
| Extensibility | Closed — all types must be known at the call site | Open — new types can be added later |
Don't Pick Trait Objects for Performance Alone:
Most Rust code should default to generics. The performance difference is real but often irrelevant outside hot loops. Benchmark before refactoring to trait objects purely for binary size. If you genuinely need heterogeneous collections or runtime pluggability, trait objects are the correct tool, and the vtable overhead is the price of that flexibility.
You can even write the same function signature both ways, and the meaning changes subtly. Using impl Trait in argument position is syntactic sugar for a generic:
// Static dispatch — monomorphized for each T
fn render_static(widget: &impl Draw) {
widget.draw();
}
// Dynamic dispatch — single function, fat pointer argument
fn render_dynamic(widget: &dyn Draw) {
widget.draw();
}
With render_static, the compiler knows widget's exact type inside the function and can inline draw. With render_dynamic, it does not.
Returning Trait Objects from Functions
A function cannot return a bare dyn Trait because trait objects are unsized—the compiler doesn't know how much stack space to reserve. Instead, you return a pointer type like Box<dyn Trait> or Arc<dyn Trait>.
fn make_greeter(kind: &str) -> Box<dyn Greeter> {
if kind == "person" {
Box::new(Person { name: String::from("Alice") })
} else {
Box::new(Robot { id: 42 })
}
}
The caller receives a boxed trait object and can call greet() on it without knowing which concrete type was constructed. This pattern is common in factory functions and dependency injection.
impl Trait in Return Position:
If you only ever return a single concrete type from a function, use -> impl Greeter instead. The caller still only knows the trait interface, but the compiler keeps the concrete type for static dispatch inside the caller. impl Trait in return position is not a trait object—it's a static opaque type.
Common Mistakes and Misconceptions
Trying to downcast a trait object to the original concrete type — Trait objects erase the concrete type. The standard library does not provide a generic way to recover it from a Box<dyn Trait>. If you need downcasting, the std::any::Any trait enables limited type recovery, but it comes with its own constraints and should be a last resort.
Assuming trait objects carry all the methods of the concrete type — A Box<dyn Draw> only exposes the methods defined in the Draw trait. If Button also has an on_click method that is not part of Draw, you cannot call it through the trait object. This is the fundamental trade-off: you give up access to type-specific capabilities in exchange for abstraction.
Using trait objects when an enum would suffice — If the set of possible types is fixed and known at compile time, an enum with a match often produces simpler code and static dispatch. Reserve trait objects for genuinely open sets of types.
Forgetting object safety — When you first try to box a trait and the compiler complains, check whether any method returns Self, has generic parameters, or lacks a receiver. Splitting the trait or adding where Self: Sized usually resolves it.
When to Use Trait Objects
You should reach for trait objects when:
- You need a single collection to hold values of different types that all implement the same trait.
- You are building a library where downstream code will provide new types that your library must accept without recompilation.
- You want to reduce binary size by avoiding monomorphization for many different type combinations.
- You are returning different concrete types from a function and the caller should not care which one.
You should stick to generics when:
- You have a small, closed set of types (consider an enum instead).
- The code in question is performance-sensitive and you need every last optimization.
- You do not need to store mixed types together; each usage site works with a single concrete type.
Summary
Trait objects are Rust’s mechanism for runtime polymorphism. By erasing the concrete type behind a dyn Trait pointer, they allow code to operate on any value that satisfies a trait, even types that did not exist when the code was written. A fat pointer combines a data address with a vtable, and every method call becomes an indirect jump through that table.
The cost is that the compiler cannot inline those calls, and the set of available methods is restricted to what the trait declares. Not all traits qualify—object safety rules ensure that the vtable layout is always predictable. For closed sets of types, generics and enums give better performance. For open sets and heterogeneous collections, trait objects are the right fit.