Chapter 6 - Generics, Dynamic Dispatch and Static Dispatch
Static where you can, dynamic where you must
Rust allows you to handle polymorphic code in two ways: * Generics / Static Dispatch: compile-time, monomorphized per use. * Trait Objects / Dynamic Dispatch: runtime vtable, single implementation.
Understanding the trade-offs lets you write faster, smaller and more flexible code.
6.1 Generics
Every programming language has tools for effectively handling the duplication of concepts. In Rust, one such tool is generics: abstract stand-ins for concrete types or other properties. We can express the behavior of generics or how they relate to other generics without knowing what will be in their place when compiling and running the code.
We use generics to create definitions for items like function signatures or structs, which we can then use with many different concrete data types. Let's first look at how to define functions, structs, enums, and methods using generics. Generics can also be used to implement Type State Pattern and constrain a struct functionality to certain expected types, more on type state on Chapter 7.
Generics Performance
You might be wondering whether there is a runtime cost when using generic type parameters. The good news is that using generic types won't make your program run any slower than it would with concrete types. Rust accomplishes this by performing monomorphization of the code using generics at compile time. Monomorphization is the process of turning generic code into specific code by filling in the concrete types that are used when compiled. The compiler checks for all occurrences of the generic parameter and generates code for the concrete types the generic code is called with.
6.2 Static Dispatch: impl Trait or <T: Trait>
A static dispatch is basically a constrained version of a generics, a trait bounded generic, at compile-time it is able to check if your generic satisfies the declared traits.
โ Best when:
* You want zero runtime cost, by paying the compile time cost. * You need tight loops or performance. * Your types are known at compile time. * Your are working with single-use implementations (monomorphized).
๐๏ธ Example: High-performance function with generic
fn specialized_sum<T: MyTrait, U: Iterator<Item = T>>(iter: U) -> T {
iter.map(|x| x.random_mapping()).sum()
}
// or, equivalent, more modern
fn specialized_sum<T: MyTrait>(iter: impl Iterator<Item = T>) -> T {
iter.map(|x| x.random_mapping()).sum()
}This is compiled into specialized machine code for each usage, fast and inlined.
6.3 Dynamic Dispatch: dyn Trait
Usually dynamic dispatch is used with some kind of pointer or a reference, like Box<dyn Trait>, Arc<dyn Trait> or &dyn trait.
โ Best when:
* You absolutely need runtime polymorphism. * You need to store different implementations in one collection. * You want to abstract internals behind a stable interface. * You are writing a plugin-style architecture.
โ Closer to what you would get in an object oriented language and can have some heavy costs associated to it. Can avoid generic entirely and let you mix types that implement the same traits.
๐ Example: Heterogeneous collection
trait Animal {
fn greet(&self) -> String;
}
struct Dog;
impl Animal for Dog {
fn greet(&self) -> String {
"woof".to_string()
}
}
struct Cat;
impl Animal for Cat {
fn greet(&self) -> String {
"meow".to_string()
}
}
fn all_animals_greeting(animals: Vec<Box<dyn Animal>>) {
for animal in animals {
println!("{}", animal.greet())
}
}6.4 Trade-off summary
| Static Dispatch (impl Trait) | Dynamic Dispatch (dyn Trait) | |
|---|---|---|
| Performance | โ Faster, inlined | โ Slower: vtable indirection |
| Compile time | โ Slower: monomorphization | โ Faster: shared code |
| Binary size | โ Larger: per-type codegen | โ Smaller |
| Flexibility | โ Rigid, one type at a time | โ Can mix types in collections |
| Use in trait fn() | โ Traits must be object-safe | โ Works with trait objects |
| Errors | โ Clearer | โ Erased types can confuse errors |
* Prefer generics/static dispatch when you control the call site and want performance. * Use dynamic dispatch when you need abstraction, plugins or mixed types. ๐จ Runtime cost. * If you are not sure, start with generics, trait bound them - then use Box<dyn Trait> when flexibility outweighs speed.
Favor static dispatch until your trait needs to live behind a pointer.
6.5 Best Practices for Dynamic Dispatch
Dynamic dispatch Ptr<dyn Trait> is a powerful tool, but it also has significant performance trade-offs. You should only reach for it when type erasure or runtime polymorphism are essential. It is important to know when you need Trait Objects:
โ Use Dynamic Dispatch When:
* You need heterogeneous types in a collection:
fn all_animals_greeting(animals: Vec<Box<dyn Animal>>) {
for animal in animals {
println!("{}", animal.greet())
}
}* You want runtime plugins or hot-swappable components. * You want to abstract internals from the caller (library design).
โ Avoid Dynamic Dispatch When:
* You control the concrete types. * You are writing code in performance critical paths. * You can express the same logic in other ways while keeping simplicity, e.g. generics.
6.6 ๐จ Trait Objects Ergonomics
* Prefer &dyn Trait over Box<dyn Trait> when you don't need ownership. * Use Arc<dyn Trait> for shared access across threads. * Don't use dyn Trait if the trait has methods that return Self. * Avoid boxing too early. Don't box inside structs unless you are sure it'll be beneficial or is required (recursive).
// โ
Use generics when possible
struct Renderer<B: Backend> {
backend: B
}
// โ Premature Boxing
struct Renderer {
backend: Box<dyn Backend> // Boxing too early
}* If you must expose a dyn trait in a public API, Box at the boundary, not internally. * Object Safety: You can only create dyn Traits from object-safe traits: * It has no generic methods. * It doesn't require Self: Sized. * All method signatures use &self, &mut self or self.
// โ
Object Safe
trait Runnable {
fn run(&self);
}
// โ Not Object Safe
trait Factory {
fn create<T>() -> T; // generic methods are not allowed
}