Trait Objects and Dynamic Dispatch

Fat Pointers and Vtables

A pointer or reference to a trait object (&dyn Trait, Box<dyn Trait>, Arc<dyn Trait>) is represented at runtime as a two-word fat pointer (wide pointer): one machine word pointing to the concrete value's data, and a second machine word pointing to a static virtual method table (vtable).

Memory Layout of a Trait Object Fat Pointer

On a 64-bit architecture, a regular reference &Concrete is 8 bytes, whereas a trait object reference &dyn Draw is 16 bytes (2 * usize):

Stack (`&dyn Draw`, 16 bytes)        Heap / Stack Data
+------------------+                 +-----------------------+
| data_ptr (8 B)  -+---------------> | Circle { radius: 5.0 }|
+------------------+                 +-----------------------+
| vtable_ptr (8 B)-+---+
+------------------+   |             Static Read-Only Memory (vtable for Circle as Draw)
                       |             +----------------------------------------+
                       +-----------> | drop_in_place: fn(*mut Circle)         |
                                     | size:          8 bytes                 |
                                     | align:         8 bytes                 |
                                     | Draw::draw:    <Circle as Draw>::draw  |
                                     +----------------------------------------+

What the Compiler Stores Inside a Vtable

For each (ConcreteType, Trait) pair used as a trait object in the program, the compiler emits one immutable vtable in static read-only memory (.rodata) containing:

  1. drop_in_place pointer: A function pointer to the concrete type's destructor so Box<dyn Trait> can properly drop the underlying value when freed.
  2. size and align: The exact byte size (usize) and alignment (usize) of the concrete type so Box<dyn Trait> knows how many bytes to deallocate on drop.
  3. Method pointers: One function pointer for each dyn-compatible method in the trait (and its supertraits).

Verifying Pointer Sizes with std::mem::size_of

use std::mem::size_of;

trait Draw {
    fn draw(&self);
}

struct Button {
    width: u32,
    height: u32,
}

impl Draw for Button {
    fn draw(&self) {}
}

fn main() {
    println!("size_of::<Button>():       {} bytes", size_of::<Button>());
    println!("size_of::<&Button>():      {} bytes (thin pointer)", size_of::<&Button>());
    println!("size_of::<&dyn Draw>():    {} bytes (fat pointer)", size_of::<&dyn Draw>());
    println!("size_of::<Box<dyn Draw>>(): {} bytes (fat pointer)", size_of::<Box<dyn Draw>>());
}
size_of::<Button>():       8 bytes
size_of::<&Button>():      8 bytes (thin pointer)
size_of::<&dyn Draw>():    16 bytes (fat pointer)
size_of::<Box<dyn Draw>>(): 16 bytes (fat pointer)

Even for a zero-sized unit struct (struct Noop;), &Noop is 8 bytes (data_ptr is a non-null aligned dangling address and size in the vtable is 0), while &dyn Draw for Noop is still 16 bytes because the vtable pointer is stored inline inside the fat pointer rather than inside the struct itself.