#199 Jun 13, 2026

199. Static Dispatch — Generics Beat Box<dyn Trait> When You Can Afford the Code

Box<dyn Trait> is the reflex when a function “takes something that implements a trait.” But every call through it pays for a vtable hop the compiler can’t see past. Swap it for a generic and the optimizer inlines the whole thing.

This is the morning half of a pair with this afternoon’s #[inline] & Copy bite: both are about giving the optimizer a body it can actually fold into the caller.

The cost: Box<dyn Trait> hides the call from the optimizer

When you accept Box<dyn Fn(i32) -> i32> (or any dyn Trait), the concrete type is erased. At each call site the program loads a function pointer from a vtable and jumps through it. The compiler has no idea what’s on the other end, so it can’t inline the body, can’t constant-fold through it, can’t vectorize a loop around it:

1
2
3
fn apply_all(f: Box<dyn Fn(i32) -> i32>, xs: &[i32]) -> Vec<i32> {
    xs.iter().map(|&x| f(x)).collect() // indirect call every iteration
}

There’s also a heap allocation just to hold the closure, and the pointer-chase ruins instruction-cache locality in a hot loop.

The fix: a generic parameter monomorphizes to the real type

Take impl Fn (sugar for a generic) instead. The compiler stamps out a specialized copy of apply_all for each concrete f you pass — monomorphization. Inside that copy the closure’s body is fully visible, so it gets inlined and the map loop optimizes as if you’d written the arithmetic by hand:

1
2
3
fn apply_all<F: Fn(i32) -> i32>(f: F, xs: &[i32]) -> Vec<i32> {
    xs.iter().map(|&x| f(x)).collect() // direct call, inlinable
}

No box, no vtable, no allocation. impl Trait in argument position is the same thing with less typing:

1
2
3
fn apply_all(f: impl Fn(i32) -> i32, xs: &[i32]) -> Vec<i32> {
    xs.iter().map(|&x| f(x)).collect()
}
1
2
let doubled = apply_all(|x| x * 2, &[1, 2, 3]);
assert_eq!(doubled, vec![2, 4, 6]);

Both generic versions compile to a tight loop with the multiply spliced straight in.

The same trick for returns: impl Trait instead of Box<dyn>

Returning a dyn value forces a box too. If the function only ever returns one concrete type, impl Trait keeps it static — the caller gets the real type and can inline through it:

1
2
3
4
5
6
fn adder(n: i32) -> impl Fn(i32) -> i32 {
    move |x| x + n          // one concrete closure type, no Box
}

let add5 = adder(5);
assert_eq!(add5(10), 15);

When dyn is still the right call

Static dispatch trades code size for speed: each instantiation is a fresh copy, so monomorphizing over many types bloats the binary. And generics can’t do heterogeneous collections — Vec<Box<dyn Draw>> holding circles and squares genuinely needs dynamic dispatch, because the element type varies at runtime. Reach for dyn when you need a uniform type for mixed values, want to shrink compile times and binary size, or the call isn’t hot enough to matter. Reach for generics / impl Trait when the call sits in a loop and you want the optimizer to see through it.

1
2
3
4
5
// heterogeneous → dyn is correct
let shapes: Vec<Box<dyn Fn() -> &'static str>> =
    vec![Box::new(|| "circle"), Box::new(|| "square")];
let names: Vec<&str> = shapes.iter().map(|s| s()).collect();
assert_eq!(names, vec!["circle", "square"]);

Rule of thumb: default to a generic, and downgrade to dyn only when you have a reason — mixed types, code-size pressure, or a cold path where the indirection is free.

← Previous 198. into_iter() to Transform — Move Owned Items Instead of cloned()