Trait Objects and Dynamic Dispatch

Dynamic Dispatch Costs

While dynamic dispatch via trait objects (&dyn Trait and Box<dyn Trait>) provides runtime flexibility and smaller binary code size, it introduces specific runtime and optimization costs compared to static dispatch.

Breakdown of Runtime Costs

Static Call (`T: Trait`)                 Dynamic Call (`&dyn Trait`)
+----------------------------------+     +---------------------------------------+
| 1. Direct call / inlined body    |     | 1. Dereference fat pointer vtable_ptr |
| 2. Registers & loop vectorized   |     | 2. Load fn pointer from vtable slot   |
+----------------------------------+     | 3. Indirect call (blocks inlining)    |
                                         +---------------------------------------+
Cost Source Cause Practical Impact
Indirect function call CPU must load the method address from the vtable before jumping Small per-call latency (~1–3 ns when vtable and branch predictor are warm)
Inlining barrier Compiler does not know which concrete function will run at compile time Prevents constant folding, dead-code elimination, and SIMD vectorization across the call boundary
Pointer & cache locality Vec<Box<dyn Trait>> stores pointers to separate heap allocations Iterating chases pointers across the heap instead of scanning one contiguous buffer
Heap allocation (Box) Constructing Box<dyn Trait> calls the global allocator Avoidable when borrowing via &dyn Trait or &mut dyn Trait
Fat pointer width &dyn Trait and Box<dyn Trait> occupy 16 bytes instead of 8 bytes Doubles pointer storage size inside slices and structs

Why the Inlining Barrier Matters More Than the Vtable Lookup

Modern CPUs predict indirect jumps accurately when the same concrete type is called repeatedly. In tight inner loops (such as per-pixel math or per-byte parsing), the main cost of &dyn Trait is not the vtable load itself, but that the compiler cannot inline a 2-instruction method body into the loop and auto-vectorize it:

trait Operation {
    fn apply(&self, x: i64) -> i64;
}

struct AddOne;

impl Operation for AddOne {
    fn apply(&self, x: i64) -> i64 {
        x + 1
    }
}

// 1. Static dispatch: `op.apply(acc)` is inlined and collapsed by LLVM into `n`:
pub fn run_static<O: Operation>(op: &O, n: i64) -> i64 {
    let mut acc = 0;
    for _ in 0..n {
        acc = op.apply(acc);
    }
    acc
}

// 2. Dynamic dispatch: must perform `n` indirect calls unless devirtualized:
pub fn run_dynamic(op: &dyn Operation, n: i64) -> i64 {
    let mut acc = 0;
    for _ in 0..n {
        acc = op.apply(acc);
    }
    acc
}

fn main() {
    let op = AddOne;
    println!("Static:  {}", run_static(&op, 1_000));
    println!("Dynamic: {}", run_dynamic(&op, 1_000));
}
Static:  1000
Dynamic: 1000

Amortizing Dynamic Dispatch with Batch Methods

If a trait is designed for dynamic dispatch (&dyn Trait), avoid putting a vtable call inside a per-byte or per-element inner loop. Instead, design the trait method to operate on a slice batch (fn process_batch(&mut self, buf: &mut [f32]))—just like std::io::Read::read(&mut self, buf: &mut [u8])—so the vtable lookup happens once per buffer and the inner loop inside the concrete implementation vectorizes at full speed.