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) }
}
DispatchCostMultiple types at runtime
<T: Trait>Compile timeNoneNo
impl TraitCompile timeNoneNo (one type per function)
dyn TraitRuntime (vtable)Pointer indirectionYes

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 Trait in 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 Trait if any method returns Self or has generic parameters.
  • Performance difference is rarely the deciding factor. Choose based on whether the type is known at compile time.