Trait Objects and Dynamic Dispatch

Generics with trait bounds give you many types, resolved at compile time. But sometimes you need a collection of different types that share a trait — a list of shapes, a set of plugins — decided at runtime. Trait objects provide that, trading a little performance for runtime flexibility. Knowing when to use which is a real Rust design decision.

The last two posts used traits with generics for static dispatch — the compiler specializes code per concrete type (monomorphization). This post covers the other way to use traits: trait objects, which give dynamic dispatch — choosing behavior at runtime. Trait objects let you hold values of different types that share a trait in one place, which generics can’t. This post covers dyn, the static-vs-dynamic dispatch trade-off, and how to choose — a genuine Rust design decision.

The problem generics can’t solve

Generics are monomorphized: Vec<T> holds elements of one concrete type T. But sometimes you want a collection of different types that all implement the same trait — a Vec of assorted shapes (circles, rectangles, triangles), a list of UI widgets, a set of plugins. Each is a different type; they only share a trait. Generics can’t express “a Vec of any-type-implementing-Shape” as a single homogeneous Vec<T>, because T must be one type.

Trait objects solve this. A trait object is a value accessed through a trait, not its concrete type — written dyn Trait (usually behind a pointer like Box<dyn Trait> or &dyn Trait):

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

struct Circle { r: f64 }
struct Rectangle { w: f64, h: f64 }

impl Shape for Circle { fn area(&self) -> f64 { 3.14159 * self.r * self.r } }
impl Shape for Rectangle { fn area(&self) -> f64 { self.w * self.h } }

// a Vec of DIFFERENT types that all implement Shape:
let shapes: Vec<Box<dyn Shape>> = vec![
    Box::new(Circle { r: 2.0 }),
    Box::new(Rectangle { w: 3.0, h: 4.0 }),
];

for shape in &shapes {
    println!("area = {}", shape.area());   // calls the right area() at runtime
}

Vec<Box<dyn Shape>> is “a vector of boxed values, each some type that implements Shape.” The elements are different concrete types (Circle, Rectangle), unified by the Shape trait. This is what trait objects enable and generics don’t: heterogeneous collections of trait-implementing types.

Static vs dynamic dispatch

The core distinction — and the reason to choose one or the other — is when the method to call is decided:

Static dispatch (generic):   compiler picks Circle::area at compile time → direct call
Dynamic dispatch (dyn):      at runtime, look up area() in the value's vtable → indirect call

The trade-off is performance vs. flexibility:

Neither is “better” — they solve different needs, and this is a real design choice in Rust code.

When to use which

The practical decision:

The rule of thumb: default to generics (performance, the common case); reach for trait objects when you specifically need heterogeneous collections or runtime-selected behavior. Recognizing which situation you’re in is the skill — “do I have many types known now (generics), or different types unified only by a trait and possibly chosen at runtime (trait objects)?”

Object safety: the catch

One constraint to know: not every trait can be a trait object — the trait must be object-safe. Roughly, this means its methods must be dispatchable through a vtable — e.g. methods can’t return Self by value or use generic type parameters, because the vtable needs a uniform, concrete signature. Most ordinary traits (like Shape above) are object-safe; you’ll occasionally hit a trait that isn’t (the compiler tells you clearly), which usually means that trait is meant for static dispatch (generics) only. This is a detail rather than a daily concern, but it explains why some traits work as dyn and some don’t — object safety is the requirement for the vtable-based dynamic dispatch to work.

Two ways to use the same traits

The big-picture takeaway: traits (last post) can be used two ways — with generics for static dispatch (compile-time, zero-cost, homogeneous, the default) or as trait objects for dynamic dispatch (runtime, small cost, heterogeneous, flexible). This dual nature is one of Rust’s elegant design points: the same trait definitions serve both, and you choose the dispatch strategy per situation. Reach for generics by default for performance, and trait objects when you genuinely need runtime polymorphism (heterogeneous collections, runtime-chosen types). Understanding this choice — and recognizing which your problem calls for — is a mark of writing idiomatic, well-performing Rust. The next post shifts to a different everyday tool: closures.

Key takeaways

Further reading

Sources & References