17.7 Performance: Generics vs Trait Objects Trade-offs
Alright, let’s cut through the noise. You’ve got generics, and you’ve got trait objects (dyn Trait). Both let you write flexible code, but they pay for that flexibility in very different currencies. One uses compile-time bucks, the other uses runtime cash. Picking the right one isn’t about which is “better”—it’s about which debt you want to owe.
The Core of the Conflict: Monomorphization vs. Dynamic Dispatch
The entire trade-off boils down to one compiler concept: monomorphization. It’s a ten-dollar word for a simple, brutally effective process. When you write a generic function, the compiler doesn’t just leave it as is. It looks at every concrete type you actually use that generic with and stamps out a bespoke, hardcoded version of the function for each one.
Think of it like a template. You write fn process<T: Processor>(item: T), and then you call process(5u32) and process("hello"). The compiler goes to work and generates two entirely separate functions for you: fn process_u32(item: u32) and fn process_str(item: &str). Every generic type parameter on structs and enums gets the same treatment.
// This is what you write...
fn add_one<T: std::ops::Add<Output = T> + From<i8>>(x: T) -> T {
x + T::from(1)
}
// ...and this is roughly what the compiler creates behind the scenes
fn add_one_i32(x: i32) -> i32 { x + 1 }
fn add_one_f64(x: f64) -> f64 { x + 1.0 }
// ...and so on for every type you call it with.
This is static dispatch. The compiler resolves exactly which function to call at compile time. The result? Blazing-fast, zero-cost abstraction. There’s no runtime overhead. The function call is a direct jump to a known memory address. It can be inlined, unrolled, and optimized into dust. This is generics’ superpower.
Now, enter the trait object. When you use dyn Processor, you’re opting for dynamic dispatch. You’re saying, “I don’t know the exact type at compile time; I just know it implements this interface.” The compiler can’t monomorphize here. Instead, it uses a nifty trick: it passes around a “fat pointer.”
This fat pointer is two pointers in one:
- A pointer to the actual data.
- A pointer to a vtable (a virtual method table)—a static struct full of function pointers that point to the correct implementation for the concrete type.
trait Draw {
fn draw(&self);
}
// Static Dispatch: known at compile time
fn static_draw<T: Draw>(item: T) {
item.draw(); // Direct call. Can be inlined.
}
// Dynamic Dispatch: resolved at runtime
fn dynamic_draw(item: &dyn Draw) {
item.draw(); // This is actually: (item.vtable.draw)(item.data)
// Look up the function pointer in the vtable, then call it.
}
That vtable lookup—jumping to the address stored in the table—is the runtime cost. It’s tiny, but it’s not zero. It prevents inlining and can confuse branch prediction. You’re trading CPU cycles for flexibility.
When to Choose Generics (Monomorphization)
Choose generics when:
- Performance is non-negotiable. This is your default for most code. You want the raw speed.
- You’re calling the function in a tight loop. The cost of a vtable lookup multiplied by a few million iterations adds up.
- You want your code to be inline-friendly. This is a huge win for optimizers.
- The number of concrete types you use is manageable. You’re willing to pay the upfront cost of a larger binary and slightly longer compile time for runtime speed.
The downside? Code bloat. The compiler generates a new copy of every generic function and impl block for every concrete type. Your binary gets bigger. Your compile times get longer, as the compiler has more code to generate and optimize. This is the “compile-time tax.”
When to Choose Trait Objects (Dynamic Dispatch)
Choose trait objects when:
- You need heterogeneous collections. This is the big one. You can have a
Vec<Box<dyn Draw>>containing aCircle, aSquare, and aTriangle. With generics, aVec<T>must contain only one concrete typeT. - You want to reduce binary size and compile time. If you have a massive, complex generic function used with dozens of types, replacing it with a single function that takes
dyn Traitcan seriously help. - Your interface is the primary concern, and the types themselves are an implementation detail you’re willing to abstract away.
- You’re building a plugin system or passing callbacks where the concrete types truly aren’t known until runtime.
The downsides are the performance hit and the trait object safety rules. Not every trait can be made into an object. The compiler needs to guarantee that method calls are always safe, so object-safe traits must not have methods that:
- Return
Self. - Use generic type parameters.
This is why you can’t easily have a
dyn Clone—clone()returnsSelf, and the compiler would have no idea how big thatSelfis.
The Pitfalls and The Real-World Choice
The biggest pitfall is assuming one size fits all. Don’t. Here’s my rule of thumb:
- Start with generics. Write your code to be generic over the traits it needs. It’s fast and expressive.
- Profile. Is compile time becoming unbearable? Is the binary massive? Are you sure the dynamic dispatch is your bottleneck? (Spoiler: you’re probably wrong. Profile first!)
- Only then, consider trait objects. Use them strategically to erase a type in a specific place where you need heterogeneity or to trim down bloat, like inside a collection that holds multiple implementors of a trait.
Remember, you can often mix them. A library can expose a generic API for performance and a convenience function that uses dyn for ease of use. It’s not a religious war; it’s a toolbox. Pick the right tool for the job, and you’ll know which one is right because you now understand the price tag.