Internals
Internals

Trait objects & vtables

dyn Trait is a fat pointer: a data pointer plus a vtable pointer. Method calls go through the vtable — one indirect call per invocation. Object safety, dispatch costs, and niche optimisations are the full mechanics of dynamic dispatch.

Long read · the layout of trait objects, the object-safety rules, and dispatch costs


1 · The fat pointer

A reference to a sized type — &T — is one machine word. A reference to dyn Trait is two: the data pointer, plus a pointer to the vtable for whichever concrete type is behind it.

rust src/main.rs · prove it
use std::mem;

trait Animal { fn speak(&self); }
struct Dog;
impl Animal for Dog { fn speak(&self) { println!("woof"); } }

fn main() {
    let d = Dog;
    let r:    &Dog       = &d;     // 1 word
    let dyn_r: &dyn Animal = &d;   // 2 words: data + vtable

    println!("&Dog:        {} bytes", mem::size_of_val(&r));
    println!("&dyn Animal: {} bytes", mem::size_of_val(&dyn_r));
}

On a 64-bit machine: 8 bytes vs 16. The second word is the vtable pointer.

2 · What's in a vtable

The vtable for a given (Trait, ConcreteType) pair is a static, read-only struct in the binary. It contains:

  • Destructor pointer. The compiler-generated drop_in_place for the concrete type. When the trait object is dropped, the runtime calls this.
  • Size and alignment of the concrete type. Needed for Box<dyn Trait> deallocation.
  • One function pointer per trait method. The actual implementation for the concrete type.
rust conceptual layout
// For (Animal, Dog), roughly:
struct AnimalVTableForDog {
    drop_in_place:  fn(*mut ()),
    size:           usize,
    align:          usize,
    speak:          fn(*const ()),       // first trait method
    // ... more methods if Animal had them
}

3 · Object safety — the rules

Not every trait can become a trait object. A trait is object-safe iff:

  • Every method takes &self, &mut self, or self: Pin<&mut Self> — never self by value.
  • No generic type parameters on methods (fn foo<T> can't be in a vtable — which monomorphisation?).
  • No Self in the return type or argument position outside of receivers.
  • No associated constants.
rust src/main.rs · what fails
// Not object-safe — method takes self by value
trait IntoString {
    fn into_string(self) -> String;
}
// fn use_trait(_: Box<dyn IntoString>) {}  // ← compile error

// Not object-safe — generic method
trait Convert {
    fn convert<T>(&self) -> T;
}
// fn use_trait(_: Box<dyn Convert>) {}     // ← compile error
Workaround. Split the trait: one object-safe core with the methods you want to dispatch dynamically, one extension with the generic methods. Or use enum dispatch when the set of types is closed.

4 · Dispatch cost

Each method call through dyn Trait costs:

  • One memory load to fetch the vtable pointer (already in the fat pointer's second word).
  • One memory load to fetch the function pointer at the vtable's slot.
  • One indirect call — branch target unknown to the CPU's branch predictor unless the call site is monomorphic in practice.
  • Inlining is blocked — the compiler doesn't know the target, so it can't see across the boundary.

In tight inner loops, this matters. In coarse-grained API boundaries, it's noise. The rule of thumb: prefer generics when the call site is hot and you can afford the code bloat; prefer dyn Trait for heterogeneous collections, plugins, and APIs where the call rate is low.

5 · Box, Arc, and references

rust src/main.rs · all the dyn flavours
trait Animal { fn speak(&self); }

// Reference — borrowed
fn one(a: &dyn Animal) { a.speak(); }

// Box — owned, heap-allocated
fn two(a: Box<dyn Animal>) { a.speak(); }

// Arc — shared ownership, thread-safe
fn three(a: std::sync::Arc<dyn Animal + Send + Sync>) { a.speak(); }

// Trait objects in collections
let zoo: Vec<Box<dyn Animal>> = vec![/* ... */];

+ Send and + Sync in the type are how you require the trait object to be thread-safe — otherwise auto-traits aren't propagated through the erasure.

6 · Static vs dynamic — pick a side

Static (<T: Trait>)Dynamic (dyn Trait)
One specialised function per typeOne function, runtime dispatch
Inlined; zero call overheadIndirect call; no inlining
Larger binary (each monomorph)Smaller binary; one impl
All types known at compile timePlugins, heterogeneous collections
Generic bound errors fire eagerly"Not object-safe" errors trip late

7 · Niche optimisations

The data pointer in a &dyn Trait is nonzero (it's a real reference), so the compiler can use the all-zero pattern as a niche for None in Option<&dyn Trait>. Result: same 16-byte size as the non-Option version. The same trick applies to Box<dyn Trait>.

8 · When the compiler "devirtualises"

Sometimes LLVM can prove that a dyn Trait call always targets one concrete implementation in the surrounding code (it sees the construction site). It then replaces the indirect call with a direct call and inlines. This is best-effort; don't rely on it.

If you want guaranteed devirtualisation, write the generic version — that's what monomorphisation does at compile time.

References

Found this useful?