Trait Objects and Dynamic Dispatch
Static vs. Dynamic Dispatch
Rust supports both static dispatch (generics and impl Trait) and dynamic dispatch (dyn Trait trait objects), allowing you to choose the trade-off between runtime speed, compile time, binary size, and collection heterogeneity on a per-interface basis.
Head-to-Head Comparison
| Dimension | Static Dispatch (<T: Trait> / impl Trait) |
Dynamic Dispatch (&dyn Trait / Box<dyn Trait>) |
|---|---|---|
| Dispatch resolution | Compile time (monomorphization) | Runtime (vtable lookup) |
| Method inlining & SIMD | Enabled across trait calls | Blocked across indirect vtable calls |
| Heterogeneous collections | No (Vec<T> holds one concrete type T) |
Yes (Vec<Box<dyn Trait>>, &[&dyn Trait]) |
| Trait compatibility | Works with all traits | Requires a dyn-compatible trait |
| Pointer width | 8 bytes (&T, Box<T>) |
16 bytes (&dyn Trait, Box<dyn Trait>) |
| Binary code size | Larger (one copy per instantiated type T) |
Smaller (one shared machine-code body) |
| Compile time | Slower when heavily instantiated | Faster (type-erased boundary) |
Combining Static and Dynamic Dispatch: The Thin-Wrapper Pattern
Standard library functions such as std::fs::read_to_string(path: impl AsRef<Path>) use a hybrid pattern to get the ergonomics of generics without binary code bloat: a tiny generic outer function converts the parameter into a concrete reference or trait object and immediately delegates to a non-generic inner function:
trait Formatter {
fn format_kv(&self, key: &str, value: &str) -> String;
}
struct EnvFormatter;
impl Formatter for EnvFormatter {
fn format_kv(&self, key: &str, value: &str) -> String {
format!("{key}={value}")
}
}
// Thin generic entry point (inlined at call site) -> delegates to `&dyn Formatter`:
pub fn render_config(fmt: impl Formatter, entries: &[(&str, &str)]) -> String {
render_config_dyn(&fmt, entries)
}
// Compiled only ONCE in the binary regardless of how many `Formatter` types exist:
fn render_config_dyn(fmt: &dyn Formatter, entries: &[(&str, &str)]) -> String {
entries
.iter()
.map(|(k, v)| fmt.format_kv(k, v))
.collect::<Vec<_>>()
.join("\n")
}
fn main() {
let pairs = [("HOST", "0.0.0.0"), ("PORT", "8080")];
println!("{}", render_config(EnvFormatter, &pairs));
}
HOST=0.0.0.0
PORT=8080
Decision Guide: When to Choose Each
- Default to static dispatch (
<T: Trait>) for small, hot-path operations (iterators, numeric traits, comparators, serialization primitives) where inlining is critical. - Choose dynamic dispatch (
&dyn Trait/Box<dyn Trait>) when you need heterogeneous collections (Vec<Box<dyn Plugin>>), runtime dependency injection, or to shrink compile times and binary size at high-level architectural boundaries (such as HTTP middleware, database drivers, or log sinks). - Choose an
enumwhen you need heterogeneous storage over a fixed, closed set of known types without vtable or heap overhead.