Generic Functions vs Trait Objects (Trade-offs)

A thorough comparison of static dispatch with generic functions and dynamic dispatch with trait objects, covering performance, binary size, collections, object safety, and when to choose each.

Rust provides two distinct mechanisms for writing code that works with multiple types through a trait interface: generic functions (with trait bounds) and trait objects (dyn Trait). They solve the same high‑level problem — “I want to call methods defined in a trait on values of different concrete types” — but the underlying machinery and the resulting trade‑offs are fundamentally different. Understanding these differences is essential to writing Rust that is fast, maintainable, and correct.

The Two Paths to Polymorphism

The core distinction is when the decision about which specific code to run is made. With generics, the compiler generates a specialized copy of your function for every concrete type you use it with. This is static dispatch: the target code is known at compile time and can be inlined. With trait objects, the decision is deferred to runtime via a virtual table — this is dynamic dispatch.

You can express the same interface in both styles. Consider a trait for anything that can be drawn:

trait Draw {
    fn draw(&self);
}
struct Circle;
struct Square;
impl Draw for Circle {
    fn draw(&self) { println!("drawing circle"); }
}
impl Draw for Square {
    fn draw(&self) { println!("drawing square"); }
}

A generic function accepts any type that implements Draw:

fn render_generic<T: Draw>(item: &T) {
    item.draw();
}

A function that takes a trait object accepts a reference to some type that implements Draw, without knowing which one:

fn render_dyn(item: &dyn Draw) {
    item.draw();
}

Both functions achieve the same visible outcome. The difference lies in what the compiler does with them.

How Generics Work (Static Dispatch)

When you write render_generic::<Circle>(&circle), the compiler duplicates the function body and substitutes the concrete type Circle for the placeholder T. The call to item.draw() becomes a direct jump to <Circle as Draw>::draw. This process is called monomorphization — it produces a separate machine-code version of the function for every combination of type arguments you actually use.

Monomorphization in the Compiler:

If you never call render_generic with a particular type, no code for that instantiation is generated. Only the concrete types that appear at call sites get a copy.

The advantage: no runtime overhead. The draw call is just a normal function call that the optimizer can inline, constant-propagate, and rearrange freely. The cost: larger binary size because each monomorphized version occupies its own space, and longer compile times because the compiler must produce and optimize each variant.

How Trait Objects Work (Dynamic Dispatch)

A trait object, written as &dyn Draw or Box<dyn Draw>, is a fat pointer: it contains a pointer to the actual data (the concrete value) and a second pointer to a vtable. The vtable is a static table of function pointers, one for each method of the trait as implemented for that concrete type. When you call item.draw() through a trait object, the compiler emits code that follows the vtable pointer, looks up the address of draw, and calls it indirectly.

┌──────────────┐      ┌─────────────┐
│ data pointer │─────>│ concrete    │
│              │      │ value       │
├──────────────┤      └─────────────┘
│ vtable ptr   │─────>┌─────────────┐
│              │      │ drop        │
└──────────────┘      │ draw        │
                      │ (fn ptr)    │
                      └─────────────┘

There is only one copy of the function render_dyn in the final binary, regardless of how many different types implement Draw. The function itself is generic over all possible Draw implementors — it just calls through the vtable.

Heterogeneous Code From a Single Function:

If you need to call draw on many different types within the same compiled artifact, the trait-object version produces a single function while the generic version would produce multiple identical copies. This is the primary binary-size advantage.

The cost: each method call goes through an extra pointer dereference, which is slightly slower than a direct call and often prevents inlining. For most application code this overhead is negligible, but it can matter in tight loops.

Performance and Binary Size

Generics typically yield faster execution because the compiler can inline and optimize for the concrete type. Trait objects add an indirection that may defeat optimisations, though the difference is often measured in nanoseconds.

Binary size, however, can swell with heavy use of generics. If a generic function is monomorphized for 50 different types, the linker must keep all 50 copies — even if they are nearly identical. Trait objects avoid this blow-up because the function body exists once.

Compile times mirror this pattern: monomorphizing many types means the compiler performs more work, and the linker has more symbols to handle. Trait objects usually compile faster for codebases with many implementations of a trait.

Avoid Premature Optimization Based on Dispatch:

Do not choose between generics and trait objects purely on performance grounds unless profiling shows a bottleneck. Most real-world Rust programs mix both styles freely, and the algorithmic complexity dominates over dispatch overhead.

Heterogeneous Collections

One of the sharpest differences appears when you need to store values of different concrete types in the same collection. A generic collection must be homogeneous: Vec<Circle> holds only Circle values, and a Vec<T: Draw> in a struct must decide a single T at compile time.

A trait object collection, however, can hold any mix of types that implement the trait:

let components: Vec<Box<dyn Draw>> = vec![
    Box::new(Circle),
    Box::new(Square),
];
for c in &components {
    c.draw(); // dynamic dispatch for each element
}

This pattern is essential for GUI frameworks, plugin systems, or any scenario where new implementations are added after the library is compiled. The Screen struct from the Rust book is a canonical example — a single Vec<Box<dyn Draw>> can hold buttons, text fields, and custom widgets defined by downstream crates.

If you do not need the elements to be different types, the generic approach with a concrete Vec<Circle> or Vec<Square> will be more efficient and still type‑safe.

Conditional Method Availability

Generics can express complex trait requirements. A function can demand that a type implements several traits simultaneously, and you can conditionally provide extra methods based on additional bounds:

use std::fmt::Debug;
// Only available if T implements both Debug and Draw.
fn show<T: Debug + Draw>(item: &T) {
    println!("{:?} has bounds", item);
    item.draw();
}

With a trait object, the vtable only knows about the methods of the single trait used to create it. There is no built‑in way to call Debug methods on a &dyn Draw unless Debug is a supertrait of Draw (i.e., trait Draw: Debug). You can define a new combined trait like trait DebugDraw: Debug + Draw and a blanket implementation, but if you have multiple orthogonal combinations the combinatorics explode. Generics handle these intersections naturally.

Object Safety Limits Trait Selection:

Not every trait can be turned into a trait object. A trait is object‑safe only if none of its methods have generic parameters and none use Self in argument or return position (except through Box<Self> or similar indirections that erase the concrete type). Attempting to use &dyn Clone will fail because Clone::clone returns Self.

Object Safety and Trait Design

A trait must be designed with object safety in mind if you intend to use it as a trait object. The compiler checks that:

  • Every method’s receiver is self, &self, &mut self, or Box<Self> (and no other Self type in argument position).
  • No method has a generic type parameter.
  • The trait does not require Sized (it can be relaxed with ?Sized if needed).

Traits that use associated types in methods that would leak into the vtable are also not object‑safe unless the associated type is fully constrained. For example, the Iterator trait is not object‑safe because its Item is an associated type that would appear in next’s return type.

When you know you will only ever use a trait with generics, these restrictions do not apply. You can define methods that return Self or take other Self‑typed arguments, which is often the most ergonomic design.

Readability, Errors, and Debugging

Generic code tends to produce verbose compile‑time error messages when a type fails to satisfy a bound. The compiler prints the full bound requirement and the concrete type. Trait object errors are usually simpler — you get an “the trait X is not implemented for Y” — but the dynamic nature means that mistakes like passing an invalid downcast only surface at runtime.

In terms of code clarity, impl Trait in argument position reads cleanly, while complex where clauses on generics can become noisy. Trait objects make signatures short but may obscure the fact that you are paying for dynamic dispatch.

When to Use Each Approach

This is not a purity contest; most Rust codebases use both.

Prefer generics (static dispatch) when:

  • The concrete types are known at compile time and you want maximum performance.
  • You need to combine multiple trait bounds on the same value.
  • The trait is not object‑safe (the compiler will prevent you from using trait objects anyway).
  • You are writing generic library code that should be as efficient as possible for downstream users.

Prefer trait objects (dynamic dispatch) when:

  • You need to store a heterogeneous collection of values that share a trait.
  • The set of possible implementors is open — for example, a plugin system or a GUI framework where third‑party code adds new widgets.
  • You want to reduce binary size and compilation time in a project with many implementations of a trait.
  • The trait is object‑safe and the performance cost of an indirect call is acceptable.

Default Mentality:

Start with generics unless you hit a concrete need for trait objects. Generics give you the full power of the type system and static checking. Reach for trait objects when you genuinely need runtime polymorphism that spans unknown types.

Common Misconceptions and Mistakes

  • “Trait objects are always slower.” The overhead is a single pointer indirection per method call. For I/O, GUI events, or any operation that takes microseconds, it is irrelevant. Measure, do not assume.
  • “I can store a &dyn Draw directly on the stack.” Trait objects are dynamically sized; you must always put them behind a pointer (&dyn Draw, Box<dyn Draw>, Rc<dyn Draw>, etc.). The compiler will remind you.
  • “I can convert between different trait objects.” A &dyn Draw cannot become a &dyn Debug unless Debug is a supertrait, and even then Rust currently lacks upcasting support for arbitrary trait objects (as of Rust 1.70, though this is evolving).
  • impl Trait in return position works like dyn Trait.” An -> impl Draw function returns a single concrete type known at compile time but hidden from the caller; a -> Box<dyn Draw> can return different types at runtime. The choice affects type erasure, not just syntax.
  • “If a trait has a default method, it must be object‑safe.” No, object safety is determined by the signatures, not by whether the method bodies are provided. A trait with a default method that returns Self is still not object‑safe.

Summary

Generic functions and trait objects are two ends of a spectrum. Generics trade binary size and compile time for raw speed and compile‑time flexibility. Trait objects trade a bit of runtime performance for smaller binaries, faster compilation, and the ability to work with a mix of unknown types at runtime.

The decision between them is not about which is “better” but about which matches the constraints of your problem: closed vs. open set of types, homogeneous vs. heterogeneous collections, static vs. dynamic dispatch. Most idiomatic Rust libraries use generics at the API boundary for efficiency and then reach for trait objects internally when they need to erase the concrete type.

The interplay between generic code and trait objects is woven throughout Rust’s standard library — in collections, I/O, and iterator adaptors — so internalising these trade‑offs will make reading and writing real‑world Rust far more natural.