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.
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_placefor 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.
// 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, orself: Pin<&mut Self>— neverselfby value. - No generic type parameters on methods (
fn foo<T>can't be in a vtable — which monomorphisation?). - No
Selfin the return type or argument position outside of receivers. - No associated constants.
// 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 error4 · 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
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 type | One function, runtime dispatch |
| Inlined; zero call overhead | Indirect call; no inlining |
| Larger binary (each monomorph) | Smaller binary; one impl |
| All types known at compile time | Plugins, 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
- std::keyword::dyn — The official keyword reference.
- RFC 2027 — dyn Trait syntax — The motivation behind the dyn keyword.
- Peeking inside trait objects (Huon Wilson) — The original deep dive on vtable layout.
- Reference: object safety rules — The current normative spec.
- rustc codegen: vtable assembly — How the compiler builds vtables.