Static vs. Dynamic Dispatch
When invoking a trait method in Rust, dispatch determines how the program locates the concrete function address to execute: static dispatch (impl Trait / <T: Trait>) resolves the exact function at compile time via monomorphization, whereas dynamic dispatch (&dyn Trait / Box<dyn Trait>) resolves the function at runtime by following a pointer into a virtual method table (vtable).
1. Static Dispatch (`fn run<T: Handler>(h: &T)`):
h.handle() ===[ Resolved at Compile Time ]===> Direct call (or inlined!) to `FastHandler::handle`
2. Dynamic Dispatch (`fn run(h: &dyn Handler)`):
`&dyn Handler` Fat Pointer (16 Bytes)
+------------------+------------------+
| data_ptr (*const)| vtable_ptr |
+------------------+------------------+
|
v [ Static Vtable in .rodata ]
+----------------------------+
| drop_in_place: fn(*mut ()) |
| size: usize |
| align: usize |
| handle: fn(*const ()) | ---> Indirect jump at runtime!
+----------------------------+
Comparing Static Dispatch, Dynamic Dispatch, and Enum Dispatch
In Rust, there is also a third dispatch strategy—enum dispatch—when the set of possible concrete types is closed and known at compile time:
| Dispatch Strategy | Syntax | Inlining & SIMD Across Call? | Heterogeneous Collection (Vec<...>)? |
Code Size Impact |
|---|---|---|---|---|
| Static Dispatch | fn process<T: Stage>(s: &T) |
Yes (Full inlining & auto-vectorization) | No (Vec<T> holds only 1 concrete type T) |
Duplicates generic body per type T |
| Dynamic Dispatch | fn process(s: &dyn Stage) |
No (Indirect call through vtable blocks inlining) | Yes (Vec<Box<dyn Stage>> holds any implementer, including external plugin types!) |
Single compiled function body |
| Enum Dispatch | enum AnyStage { A(StageA), B(StageB) } |
Yes (Direct match branches are inlined by LLVM) |
Yes (Vec<AnyStage> stores values inline without per-item Box heap allocation!) |
Compact single match dispatch |
Why Dynamic Dispatch Mainly Costs Missed Optimizations, Not Just the Jump
Modern CPU branch predictors (Indirect Branch Predictors) can predict a single vtable pointer jump in 1–2 nanoseconds when the same concrete type is called repeatedly. The primary performance cost of &dyn Trait inside a tight inner loop (executed millions of times per second) is secondary optimization blockage:
- Because the compiler does not know which concrete function lies behind the vtable pointer, it cannot inline the method body into the loop.
- Without inlining, the compiler cannot perform constant propagation, Scalar Replacement of Aggregates (SROA), or SIMD loop vectorization across the method call!
pub trait Transform {
fn apply(&self, x: i64) -> i64;
}
pub struct Scale(pub i64);
impl Transform for Scale {
#[inline]
fn apply(&self, x: i64) -> i64 {
x * self.0
}
}
pub struct Offset(pub i64);
impl Transform for Offset {
#[inline]
fn apply(&self, x: i64) -> i64 {
x + self.0
}
}
/// 1. Static Dispatch: LLVM inlines `t.apply(x)` and vectorizes the entire slice loop!
pub fn map_sum_static<T: Transform>(data: &[i64], t: &T) -> i64 {
data.iter().map(|&x| t.apply(x)).sum()
}
/// 2. Dynamic Dispatch: Calls `t.apply(x)` through the `&dyn Transform` vtable on every element.
pub fn map_sum_dynamic(data: &[i64], t: &dyn Transform) -> i64 {
data.iter().map(|&x| t.apply(x)).sum()
}
/// 3. Enum Dispatch: Supports heterogeneous pipelines in a single contiguous `Vec<Op>`
/// without `Box` heap allocations or vtable indirection!
pub enum Op {
Scale(Scale),
Offset(Offset),
}
impl Transform for Op {
#[inline]
fn apply(&self, x: i64) -> i64 {
match self {
Op::Scale(s) => s.apply(x),
Op::Offset(o) => o.apply(x),
}
}
}
fn main() {
let data = [1i64, 2, 3, 4, 5];
let scaler = Scale(10);
let s_res = map_sum_static(&data, &scaler);
let d_res = map_sum_dynamic(&data, &scaler);
assert_eq!(s_res, d_res);
// Heterogeneous pipeline using Enum Dispatch on a contiguous array:
let pipeline = [Op::Scale(Scale(2)), Op::Offset(Offset(5))];
let final_val = pipeline.iter().fold(10i64, |acc, op| op.apply(acc));
println!("Static/Dynamic sum: {s_res}, Enum pipeline(10) = {final_val}");
}
Static/Dynamic sum: 150, Enum pipeline(10) = 25
Choosing the Right Dispatch Strategy
- Use Static Dispatch (
<T: Trait>) for hot data-processing loops, per-pixel/per-sample/per-byte callbacks, and small generic wrappers. - Use Enum Dispatch when you have a fixed, known set of variants that need to live together in a contiguous
Vec<Enum>with maximum cache locality and inlining. - Use Dynamic Dispatch (
&dyn Trait/Box<dyn Trait>) for high-level architectural boundaries (such as I/O backends, plugin registries, HTTP middleware routers, or UI widgets) where the call happens once per request or batch and fast compile times / smaller binary sizes outweigh a few nanoseconds of dispatch.