Skip to content
advanced Phase 6 · Structs, Traits, and Trait Bounds

Trait Objects and Dynamic Dispatch

Store heterogeneous values behind dyn Trait and pay for runtime dispatch.

50m
0 problems
Topic Progress 0%

One Vec, Many Shapes: Storing Heterogeneous Values Behind `Box<dyn Trait>`

You are drawing figures in a renderer, and the set of figures is not closed: Circle and Rect today, Bezier and Text added by a plugin next month. The first instinct is an enum:

enum Shape { Circle(f64), Rect(f64, f64) }   // every addition edits this enum
// and every `match` over it, in every crate that sees it

An enum is the right tool when you own the closed set of variants. It is the wrong tool here: each new figure is a change to a central type plus every exhaustive match against it — the opposite of open extension. Traits already solved the behavior side; the missing piece is storage: a Vec that holds different implementors at once. That is what a trait object is — a value whose concrete type is chosen at run time:

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

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

impl Shape for Circle {
    fn area(&self) -> f64 { std::f64::consts::PI * self.r * self.r }
    fn name(&self) -> &'static str { "circle" }
}
impl Shape for Rect {
    fn area(&self) -> f64 { self.w * self.h }
    fn name(&self) -> &'static str { "rect" }
}

fn main() {
    let shapes: Vec<Box<dyn Shape>> = vec![
        Box::new(Circle { r: 1.0 }),      // Box<Circle> coerces to Box<dyn Shape>
        Box::new(Rect { w: 2.0, h: 3.0 }),
    ];
    let total: f64 = shapes.iter().map(|s| s.area()).sum();   // one code path
    println!("{} shapes, {} total area", shapes.len(), total);
}

Box<Circle> becomes Box<dyn Shape> by unsized coercion at the point where the type annotation demands it — no .into(), no wrapper enum, and the heap allocation still holds the real Circle. Note what does not work: Vec<dyn Shape> is illegal, because a dynamically sized value cannot be stored inline in a vector's buffer. The element type must be sized, so the collection holds pointers instead: Vec<Box<dyn Shape>> for owned values, Vec<&dyn Shape> for a borrowed view over data that lives elsewhere.

Layout is the part interviewers ask about. A dyn Shape reference is a fat pointer — two pointer-width words:

&dyn Shape / Box<dyn Shape>      16 bytes on a 64-bit target
┌──────────────┬──────────────┐
│  data ptr    │  vtable ptr  │   → concrete Circle on the heap
└──────────────┴──────────────┘      → read-only table emitted by the compiler
                                      [size, align, drop glue, area(), name(), …]

The data pointer targets the value itself; the vtable pointer targets a table the compiler generates once per (trait, type) pair — every Circle in the program shares one Shape vtable, so memory cost is not per instance. A call like s.area() loads the vtable pointer from the fat pointer, indexes the slot for area, and calls through it with the data pointer as self. Everything you can infer from that picture — why the concrete type can change between elements, why the compiler cannot inline the call, why Box<dyn Shape> itself is perfectly sized — follows from those two words.

One syntax rule is enforced since the 2021 edition: the dyn keyword is mandatory. Writing Box<Shape> is rejected with error[E0782]: trait objects must include the dyn keyword, which exists to keep a plain generic type name from silently meaning "possible trait object" in old code.

The previous topic built the static half — Generic Trait Bounds fixes types at compile time. This is the dynamic half: the type is data, not a compile-time fact. That freedom has a price list, and the compiler will not let you pay it until the trait qualifies.

Why `Clone` Cannot Become a Trait Object: Object Safety and E0038

Not every trait can be turned into a vtable. Try to store a clonable value behind dyn:

trait Clonable {
    fn clone_value(&self) -> Self;      // returns the implementor's own type
}

fn duplicate(item: &dyn Clonable) { /* item.clone_value() — clone into what? */ }
error[E0038]: the trait `Clonable` cannot be made into an object

The reason is mechanical: a vtable entry must be callable with nothing but the data pointer and the table — no knowledge of the concrete type, no generics, no surprises about size. A method that fails that test disqualifies the whole trait, and there are only a handful of ways to fail:

Signature feature Why it breaks dyn Real trait with the problem
Returns Self the return type differs per implementor and cannot be named through a fat pointer Clone (fn clone(&self) -> Self)
Takes &Self / Self caller and callee would have to agree on one concrete type PartialEq (fn eq(&self, other: &Self))
Its own generic parameters each instantiation would need its own vtable entry fn encode<W: Write>(&self, w: &W)
By-value self moving out of an unsized value is impossible fn into_parts(self)
Requires Self: Sized the method is defined only for sized types any where Self: Sized method

Note the nuance in that last row: a method may opt itself out of object safety instead of killing the trait:

trait Shape {
    fn area(&self) -> f64;                                  // in the vtable
    fn translate(self, dx: f64, dy: f64) -> Box<dyn Shape>
        where Self: Sized;                                  // only on concrete types
}

dyn Shape compiles and exposes area; translate simply does not exist on a trait object, because it was gated behind Self: Sized. This is exactly how std::iter::Iterator gets away with dozens of methods that take self by value or generic parameters (count, last, chain, map) while dyn Iterator still works: every one of them carries where Self: Sized. The trait stays object-safe; the heavy machinery stays on concrete iterators.

When you meet E0038 in review, there are three standard repairs, in the order you should try them:

// 1. Return a boxed value instead of Self — the classic clone_box pattern
fn duplicate(&self) -> Box<dyn Shape>;

// 2. Replace a generic parameter with a trait object parameter
fn encode(&self, w: &mut dyn Write);        // was: fn encode<W: Write>(&self, w: &W)

// 3. Gate the method: keep it for concrete types, drop it from dyn
fn with_area(&self, extra: f64) -> Self where Self: Sized;

All three preserve the object-safe subset while moving the impossible parts to call sites where the concrete type is known. One repair people miss: keep by-value semantics by reboxing the receiver — fn into_parts(self: Box<Self>) is object-safe, because the compiler can move out of a sized box using only the data pointer, while plain self cannot move out of an unsized value. Also remember the cascade: a trait with supertraits needs every supertrait to be object-safe too, because dyn Sub must expose the inherited methods from the same table.

The proof that these rules are workable rather than restrictive is std itself: Display, Iterator, Error, and the Fn traits are all object-safe, which is why Box<dyn Error> and Box<dyn Fn(&str) -> bool> are everyday types — every closure-capturing callback you have ever boxed rode through one of these vtables.

Object safety is the admission ticket; the price of admission is paid at every call site.

Dynamic vs Static Dispatch: Paying for Flexibility at the Call Site

Take the same s.area() call written two ways and follow what the machine does:

fn total_generic<T: Shape>(items: &[T]) -> f64 {       // static dispatch
    items.iter().map(|s| s.area()).sum()
}

fn total_dyn(items: &[&dyn Shape]) -> f64 {            // dynamic dispatch
    items.iter().map(|s| s.area()).sum()
}

The generic version is monomorphized: the compiler knows the concrete type of T, resolves area to a fixed address, and can inline it straight into the loop — after which the call disappears entirely. The dyn version loads the vtable pointer out of the fat pointer, indexes the area slot, and performs an indirect branch the CPU cannot predict from the opcode alone. The body of area is identical; only how the target is found differs.

Dimension T: Shape (generics) dyn Shape (trait objects)
Dispatch compile time, direct call run time, vtable lookup
Inlining yes, often fully blocked across the call
Code size one copy per type instantiated one copy for all types
Type set fixed when the crate compiles decided while running
Zero-sized/inline types fine must live behind a pointer
Typical home hot loops, numeric kernels, inner APIs collections, plugins, callbacks, errors

Choosing well is about where the decision happens, not about which is "faster" in the abstract. Reach for dyn when the type set is genuinely open — a Vec<Box<dyn Shape>>, a widget tree, a registry of command handlers — or when you want the concrete type out of the public signature so adding an implementation does not recompile callers. Reach for generics when the set is closed and the call sits in a loop that runs millions of times; there, an uninlinable indirect call per iteration is a real cost you can measure. And remember the third option from Enumerations with Data: a plain enum plus match gives you one closed set with no vtable and fully inlined dispatch — often the best answer for two or three variants, and it forces the exhaustiveness check every time you add a fourth.

Two facts round out daily usage. First, trait objects accept marker bounds and lifetimes like any other bound: Box<dyn Fn(&str) -> bool + Send + 'static> is a thread-safe callback, and Box<dyn Error + Send + Sync + 'static> is the shape anyhow::Error wraps. Second, the object lifetime default decides how long the referent must live: &'a dyn Trait defaults to 'a, while Box<dyn Trait> defaults to 'static — which is why Box<dyn Error> inside a main that returns Result refuses borrowed errors, and why a callback that captures borrowed data must spell its lifetime out:

fn log_box<'a>(msg: &'a str) -> Box<dyn Fn() + 'a> {   // bare Box<dyn Fn()> would demand 'static
    Box::new(move || println!("{msg}"))
}

Write the bound explicitly (+ 'a) and the borrowed data is legal; omit it and the closure is rejected for not living forever. The rule is small, and knowing it saves an afternoon of fighting a 'static requirement that was never really a requirement.

Dispatch is now a dial, not a fork: bounds choose types at compile time, dyn chooses at run time, enums sidestep both. The next topic moves from designing failure-prone signatures to handling what happens when they fail anyway: unwrap, expect, and When Panics Are Acceptable.

Quiz

1. What does a `Box<dyn Shape>` value physically contain, and where does the concrete `Circle` live?

Question 1 options

2. `trait Clonable { fn clone_value(&self) -> Self; }` cannot be used as `&dyn Clonable`. Which error and which repair are correct?

Question 2 options

3. Why does `fn run(e: Box<dyn Error>)` refuse an error that borrows data from the local scope?

Question 3 options

4. A hot loop processes 100k elements and the element type is known at compile time. Which design, and why?

Question 4 options

Flashcards

Question

What are the two halves of a trait-object pointer, and what lives in the vtable?

Answer

A fat pointer: a data pointer to the value and a vtable pointer to a compiler-emitted table of size, alignment, drop glue, and one function pointer per method — two words, 16 bytes on 64-bit. The table is emitted once per (trait, type) pair and shared by every instance, so vtable cost is per type, not per value.

Question

Which signatures make a trait not object safe, and what error results?

Answer

Returning `Self` (as `Clone` does), taking `&Self`/`Self` as a non-receiver parameter (as `PartialEq` does), methods with their own generic parameters, by-value `self`, and any `where Self: Sized` requirement — all rejected with E0038, "the trait cannot be made into an object". Non-object-safe supertraits block the subtrait too.

Question

Give the three standard repairs for E0038.

Answer

Return a boxed value (`fn duplicate(&self) -> Box<dyn Trait>`), replace a method generic with a trait-object parameter (`w: &mut dyn Write`), or gate the method with `where Self: Sized` so it exists only for concrete types — which is exactly how std keeps `Iterator::count`, `chain`, and `map` usable on concrete iterators while `dyn Iterator` still works.

Question

Static vs dynamic dispatch: the costs and the decision rule.

Answer

Generics: compile-time resolution, direct and inlinable calls, one code copy per type, type set frozen at compile time. `dyn`: vtable lookup, one indirect branch per call, blocks inlining, one code copy, types chosen at runtime. For a closed set of two or three variants an enum plus `match` often beats both — no vtable and exhaustive checking.

Question

What are the default object lifetime bounds, and how do marker bounds attach?

Answer

`&'a dyn T` defaults to `'a`; `Box<dyn T>` defaults to `'static` (that is why `Box<dyn Error>` refuses borrowed errors — write `+ 'a`). Bounds combine with `+`: `dyn Fn(&str) -> bool + Send + 'static`, `dyn Error + Send + Sync + 'static`.

Revision Notes

Key Takeaways

  • 1. A trait object is a fat pointer — data pointer plus vtable pointer — with the vtable emitted once per (trait, type) pair and shared by every instance
  • 2. You never store `dyn Trait` inline: `Vec<Box<dyn T>>` or `Vec<&dyn T>`; the `dyn` keyword is mandatory in edition 2021 (E0782)
  • 3. Object safety is the requirement that every method be callable through a pointer alone: `Self` in returns/arguments, method generics, and by-value `self` all fail it with E0038 — repaired via `Box` returns, `&dyn` parameters, or `where Self: Sized`
  • 4. Dynamic dispatch costs an indirect branch and blocks inlining; static dispatch costs one code copy per type — pick by where the decision happens, and consider enum-plus-match for closed sets
  • 5. Trait objects carry bounds and defaulted lifetimes: `dyn Error + Send + 'static`, and `Box<dyn T>` silently means `Box<dyn T + 'static>`

Interview Tips

  • • "\"What is a trait object physically?\" — draw the two-word fat pointer, say 'vtable once per type, not per instance', and note the call is an indirect branch; that is the whole mechanism plus its performance story in one answer"
  • • "\"Why can't `Clone` be a trait object?\" — E0038: `fn clone(&self) -> Self` returns a type the fat pointer cannot name; give the repair (`fn clone_box(&self) -> Box<dyn Trait>`) and mention `where Self: Sized` as the gating alternative"
  • • "\"When do you choose `dyn` over generics?\" — open type sets, stable public signatures, collections of mixed types; generics for hot paths where inlining matters. Naming enum-plus-match as the third option shows range"
  • • "\"Why does `Box<dyn Error>` reject a borrowed error?\" — the object lifetime default: `Box<dyn T>` means `+ 'static`; mention `Box<dyn Error + 'a>` as the fix and `&'a dyn T` defaulting to `'a` as the other half of the rule"

Cheat Sheet

Trait Objects and Dynamic Dispatch

Storage and layout:

let shapes: Vec<Box<dyn Shape>> = vec![Box::new(Circle { r: 1.0 })];
// unsized coercion: Box<Circle> -> Box<dyn Shape> at the annotation

&dyn Shape = [ data ptr | vtable ptr ]   // 16 bytes on 64-bit; NOT a single pointer
// vtable emitted once per (trait, type): size, align, drop, method fns — shared by all instances
Rule Detail
Vec<dyn Shape> illegal — unsized values can't be inline; use Vec<Box<dyn …>> or Vec<&dyn …>
dyn keyword mandatory in edition 2021 — E0782 without it
Call load vtable ptr → indirect call with data ptr as self
Lifetime default &'a dyn T → 'a · Box<dyn T> → 'static (write + 'a to borrow)
Markers Box<dyn Fn(&str) -> bool + Send + 'static>, Box<dyn Error + Send + Sync + 'static>

Object safety (else error[E0038]: the trait … cannot be made into an object):

Disqualifier Example Fix
returns Self fn clone(&self) -> Self -> Box<dyn Trait> (clone_box)
takes &Self/Self fn eq(&self, other: &Self) accept &dyn Trait / drop the pair
method generics fn encode<W: Write>(…) &mut dyn Write parameter
by-value self fn into_parts(self) self: Box<Self> or where Self: Sized

where Self: Sized exempts one method (std's Iterator::count, chain, map) — trait stays object-safe, method disappears from dyn. Supertraits must be object-safe too.

Dispatch choice: generics = direct + inlinable + N copies, types frozen at compile time · dyn = one indirect branch, one copy, types chosen at runtime · enum + match = closed set, no vtable, exhaustive.

Continue Learning