Trait Objects vs Generics
Traits are Rust's primary abstraction mechanism. They describe what a type can do, not what it is. But there are two ways to use a trait: generics (<T: Trait>) and trait objects (dyn Trait). They compile to fundamentally different machine code with different trade-offs.
Choosing between them is not just a style question. It affects binary size, runtime performance, flexibility, and what the compiler can verify.
The Core Difference
Generics are resolved at compile time. The compiler generates a separate copy of the function for each concrete type used. Rust calls this monomorphization.
Trait objects are resolved at runtime. The compiler generates one version of the function and uses a vtable (a table of function pointers) to dispatch calls at runtime. This is dynamic dispatch.
trait Greet {
fn hello(&self) -> String;
}
struct English;
struct Spanish;
impl Greet for English { fn hello(&self) -> String { "Hello".into() } }
impl Greet for Spanish { fn hello(&self) -> String { "Hola".into() } }
// Generic — monomorphized, two copies of this function at compile time
fn greet_static<T: Greet>(g: &T) {
println!("{}", g.hello());
}
// Trait object — one copy, dispatch at runtime via vtable
fn greet_dynamic(g: &dyn Greet) {
println!("{}", g.hello());
}
Both work. The question is which one fits the situation.
Scenario 1 - When the type is known at the call site: use generics
If every call site knows the concrete type at compile time, generics are the right choice. The compiler inlines and optimizes each instantiation independently, often eliminating the call entirely.
fn serialize<T: serde::Serialize>(value: &T) -> String {
serde_json::to_string(value).unwrap()
}
serialize(&user); // compiles to a version specialized for User
serialize(&product); // compiles to a version specialized for Product
Each instantiation is a distinct function body optimized for that specific type. No indirection, no vtable lookup, no boxing.
Use generics when:
- All callers know the concrete type
- You want the compiler to optimize each instantiation
- You are writing library code that should impose no runtime cost on callers
Scenario 2 - When the type varies at runtime: use trait objects
If the concrete type is not known until runtime (read from a config, chosen by user input, populated from a plugin), generics cannot help. The compiler cannot monomorphize what it cannot see.
fn build_formatter(format: &str) -> Box<dyn Formatter> {
match format {
"json" => Box::new(JsonFormatter),
"csv" => Box::new(CsvFormatter),
_ => Box::new(PlainFormatter),
}
}
The return type is Box<dyn Formatter>. The concrete type is decided at runtime based on format. Generics cannot express "one of these three types, determined at runtime."
// A Vec of mixed types that all implement the same trait
let handlers: Vec<Box<dyn EventHandler>> = load_plugins();
for handler in &handlers {
handler.on_event(&event); // dispatched through the vtable
}
Storing heterogeneous types in a Vec is only possible with dyn Trait. A Vec<T> must hold a single concrete type.
Use trait objects when:
- The type is determined at runtime
- You need a collection of mixed concrete types
- You want to return "one of several types" from a function without the caller knowing which
Scenario 3 - Return position: impl Trait vs Box<dyn Trait>
Rust has a third option in return position: impl Trait. It tells the caller "I return something that implements this trait" without naming the type, while still resolving at compile time.
// impl Trait — zero-cost, but the concrete type is fixed at compile time
fn make_adder(n: i32) -> impl Fn(i32) -> i32 {
move |x| x + n
}
The compiler knows the exact return type. Callers just do not see it. This is useful for returning closures or iterators where the concrete type is complex or unnameable.
The key limitation: a function can only return one concrete type when using impl Trait. This does not compile:
// ERROR: if and else return different types
fn make_formatter(json: bool) -> impl Formatter {
if json { JsonFormatter } else { PlainFormatter }
}
When you need to return one of several types, reach for Box<dyn Trait>:
fn make_formatter(json: bool) -> Box<dyn Formatter> {
if json { Box::new(JsonFormatter) } else { Box::new(PlainFormatter) }
}
| Dispatch | Cost | Multiple types at runtime | |
|---|---|---|---|
<T: Trait> | Compile time | None | No |
impl Trait | Compile time | None | No (one type per function) |
dyn Trait | Runtime (vtable) | Pointer indirection | Yes |
Scenario 4 - Object safety
Not every trait can be used as dyn Trait. A trait must be object-safe to be used as a trait object. The most common rules:
- No methods that return
Self - No generic methods
trait Clone {
fn clone(&self) -> Self; // returns Self — NOT object-safe
}
trait Stringify {
fn to_string(&self) -> String; // returns String, not Self — object-safe
}
If you try to use a non-object-safe trait as dyn Trait, the compiler tells you which method breaks the rule. The fix is usually to redesign the trait or split it.
A common workaround when you need Clone-like behavior through a trait object:
trait CloneBox {
fn clone_box(&self) -> Box<dyn CloneBox>;
}
Each implementor returns a Box<dyn CloneBox> instead of Self, which is object-safe.
Scenario 5 - Multiple trait bounds
Generics handle multiple bounds cleanly:
fn process<T: Read + Write + Send>(stream: &mut T) { /* ... */ }
With trait objects, multiple bounds use +:
fn process(stream: &mut (dyn Read + Write + Send)) { /* ... */ }
Or more commonly, define a combined trait:
trait ReadWrite: Read + Write {}
impl<T: Read + Write> ReadWrite for T {}
fn process(stream: &mut dyn ReadWrite) { /* ... */ }
The combined trait approach makes function signatures cleaner and is easier to reuse.
Scenario 6 - Performance considerations
The performance difference between generics and trait objects is usually not the deciding factor, but it matters in hot paths.
Generics:
- Compiler can inline methods
- No heap allocation required
- Binary size grows with the number of instantiations (code bloat in extreme cases)
Trait objects:
- Virtual dispatch: one pointer indirection per method call
- Values must be heap-allocated when stored (in
Box,Arc, etc.) - Single copy of the function in the binary regardless of how many types use it
In practice, prefer generics for library APIs and performance-sensitive code. Reach for trait objects when building plugin systems, heterogeneous collections, or anything where flexibility matters more than raw throughput.
Decision Guide
Do you know the concrete type at compile time?
├── Yes → use generics (<T: Trait>) or impl Trait in return position
└── No → use trait objects (dyn Trait)
├── Stored in a collection or returned conditionally? → Box<dyn Trait>
└── Passed by reference only? → &dyn Trait
Key Takeaways
- Generics (
<T: Trait>) are resolved at compile time via monomorphization. Zero runtime cost, but the type must be known at each call site. - Trait objects (
dyn Trait) are resolved at runtime via a vtable. One pointer indirection per call, but the concrete type can vary. impl Traitin return position resolves at compile time but hides the concrete type. Useful for closures and iterators, but limited to one return type per function.- Use trait objects when you need heterogeneous collections, runtime-selected types, or plugin-style architectures.
- Not all traits are object-safe. A trait cannot be used as
dyn Traitif any method returnsSelfor has generic parameters. - Performance difference is rarely the deciding factor. Choose based on whether the type is known at compile time.