Trade-offs Between Generics and Trait Objects

Compare generics with static dispatch and trait objects with dynamic dispatch in Rust to decide which form of polymorphism fits your code's performance needs, flexibility requirements, and long-term maintainability.

When you have a trait and need to write code that works with any type implementing it, Rust gives you two paths: generics (with trait bounds) and trait objects (dyn Trait). Both let you abstract over behaviour, but they affect how your code compiles, runs, and evolves differently. This section helps you choose by examining the real-world consequences of each approach.

The Mechanism Behind the Choice

Under the hood, the two paths use entirely different dispatch strategies.

  • Generics rely on static dispatch: the compiler copies the generic function for every concrete type you use, a process called monomorphization. The function call is a direct jump to the generated code. There is no pointer chasing at runtime.
  • Trait objects rely on dynamic dispatch: a single function is compiled, and each call goes through a vtable (a lookup table of function pointers). The trait object itself is a fat pointer — a pointer to the data plus a pointer to the vtable.

This single mechanical difference cascades into everything: performance, binary size, type flexibility, and how errors surface.

Generics — Compile-Time Polymorphism

trait Draw {
    fn draw(&self);
}
struct Button { label: String }
impl Draw for Button {
    fn draw(&self) { println!("[{}]", self.label); }
}
struct TextField { placeholder: String }
impl Draw for TextField {
    fn draw(&self) { println!("({})", self.placeholder); }
}
// Generic version – compiled once per concrete type
fn render<T: Draw>(item: &T) {
    item.draw();
}

Calling render(&button) and render(&text_field) produces two separate copies of render, each specialised for Button and TextField. The call sites are as fast as a regular function call. The compiler also knows the exact type inside the function body, so it can inline and optimise aggressively.

Zero-Cost Abstraction:

The generated code for generics has no vtable indirection. In many cases, the compiler inlines the body, removing the function call entirely. If you measure and find a hot loop, generics are the default path to speed.

Generics shine when you can work with a single type at a time. They also make it trivial to ask for multiple trait implementations in one spot.

fn debug_and_draw<T: Draw + std::fmt::Debug>(item: &T) {
    println!("{:?}", item);
    item.draw();
}

The constraint T: Draw + Debug is checked at compile time. If a type implements both, the function works. You cannot do this as cleanly with trait objects — a &dyn Draw has no knowledge of Debug unless you create a combined trait.

The main trade-off is that generics treat collections as homogeneous. A Vec<T> can only hold one type. You can’t put a Button and a TextField in the same generic vector.

// This will not compile – Vec<T> must be a single type
// let components: Vec<impl Draw> = vec![Button { .. }, TextField { .. }];

Trait Objects — Runtime Polymorphism

A trait object erases the concrete type and preserves only the behaviour the trait describes.

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

A single render_dyn is compiled, and any type that implements Draw can be passed in. The call goes through the vtable, which adds a tiny runtime cost. In exchange, you can build collections that mix different types.

struct Screen {
    components: Vec<Box<dyn Draw>>,
}
impl Screen {
    fn run(&self) {
        for component in &self.components {
            component.draw();
        }
    }
}

This is the classic GUI example: a Screen can contain a button, a text field, and a custom widget from a downstream crate — all behind the same Box<dyn Draw>. The library author doesn’t need to know every possible widget at compile time.

Not All Traits Work This Way:

A trait must be object-safe to be used as a trait object. Methods with generic parameters, methods that return Self, or associated constants make a trait non-object-safe. If you try Box<dyn Clone> you’ll get a compile error because Clone::clone returns Self.

The erased type also means you lose access to anything beyond what the trait exposes. You cannot call Debug methods on &dyn Draw unless the trait itself inherits from Debug or you create a supertrait.

Performance and Binary Size

The monomorphisation that powers generics creates a specific tension.

  • Runtime speed: Generics are generally faster. There is no vtable lookup, and the compiler can inline across the call. In tight loops, this difference can be measurable.
  • Binary size: Each distinct concrete type used with a generic function produces a new copy of that function. A function used with 20 types yields 20 monomorphised versions. This can bloat the binary, especially in large codebases or when the function body is large.
  • Trait objects produce a single function body regardless of how many types they service. The binary stays lean, but every call pays a double indirection (trait object → vtable → code). For code that isn’t in a critical path, this overhead is usually negligible.
  • Compile time: Generics require more work from the compiler, which must generate and optimise each monomorphised instance. Trait objects keep compilation faster because they avoid this duplication.

None of these differences should drive your decision in isolation. Start with the design that matches the problem, then profile if performance becomes a concern.

Flexibility Trade-Offs

The most visible divide is in what you can express.

ScenarioGenericsTrait Objects
Heterogeneous collection (Vec<Box<dyn T>>)Not possible directly (requires enum)Natural fit
Plugin-like architectureWorks if types known at compile timeAllows runtime loading / dynamic composition
Multiple trait bounds (T: A + B + C)StraightforwardRequires a supertrait or manual combinator traits
Returning a closure or unknown typeimpl Trait in return position worksBoxed trait object works, but heap-allocates
Conditional method availabilityCan use multiple impl blocks with boundsCannot conditionally expose methods at compile time

When you need to accept types you didn't know about at crate-authoring time, trait objects are often the only option. Plugin systems, scripting interfaces, and GUI component trees all rely on this ability.

When you control all the types or the set is closed, generics give you more compile-time guarantees and better ergonomics with the type system.

When to Reach for Generics

  • The set of types is small and known. If you have three concrete renderers and no expectation of external extension, generics keep the code simple and fast.
  • Performance is critical. In a hot loop that calls a trait method millions of times per frame, the vtable indirection of a trait object might matter. Monomorphised code eliminates that indirection.
  • You need multiple trait bounds. A function that requires T: Draw + PartialEq + Clone is trivial with generics. Achieving the same with trait objects means defining a new supertrait, which doesn’t scale combinatorially.
  • You want detailed compiler errors. When a generic bound fails, the compiler tells you exactly which trait is missing. With trait objects, the error is often an opaque "trait bound not satisfied" in the context of a Box<dyn …> cast, and the erased type makes diagnostics harder.

When Trait Objects Are the Right Call

  • You genuinely need a heterogeneous collection. A vector of different widgets, a list of event handlers, or a set of I/O backends that vary at runtime all require dyn Trait.
  • You’re building a library that must accommodate user-defined types. If your Screen struct needs to work with types defined in other crates without recompiling your crate, Vec<Box<dyn Draw>> is the standard solution.
  • You want to avoid generic parameter propagation. A single Logger trait object can be stored in a struct without threading a generic parameter through every function that touches the struct. With generics, every struct and fn that holds a logger must carry a L: Logger parameter, which can ripple through the entire codebase.

The Parameter Threading Problem:

Some teams choose trait objects for architectural simplicity: storing a Box<dyn Logger> in a context struct avoids infecting dozens of function signatures with generic type parameters. If the performance cost is acceptable, this can make the code more approachable.

Common Pitfalls and Misconceptions

Object Safety Violations:

Attempting Box<dyn Clone> or using a trait with a generic method as a trait object produces a hard compiler error. Always check object safety before committing to dyn Trait. The rules: no generic methods, no Self in return position (except Box<Self> in nightly with certain flags), and no associated constants.

  • "Trait bounds are inheritance." A trait bound T: Draw means T implements Draw, not T is a Draw. There is no parent-child relationship; Rust doesn’t have subclassing. Trait objects with multiple bounds don’t let you upcast between traits either (at least not in stable Rust as of this writing).
  • Overusing Box<dyn Trait> prematurely. Boxing every abstraction from the start adds heap allocation and dynamic dispatch overhead that may never be necessary. If you control the call site, start with generics and only switch to trait objects when the heterogeneous collection or plugin requirement materialises.
  • Assuming generics always bloat the binary. For small functions called with many types, the per-type code duplication is real. But the compiler also deduplicates identical monomorphised code and the linker can fold copies. Don’t avoid generics out of a vague fear of bloat; measure first.
  • Ignoring ergonomic costs. Threading generic parameters through a deep struct hierarchy can make the code harder to read and refactor. There’s a genuine maintenance cost, and some teams accept the slight runtime overhead of trait objects to keep signatures clean.

A Decision Framework

Use this order of operations when choosing:

  1. Is the set of types open (unknown at compile time) or do you need heterogeneous storage? → Trait objects.
  2. Is performance in that code path proven to be critical (by measurement)? → Generics.
  3. Are you dealing with multiple trait bounds that would be awkward to combine into a supertrait? → Generics.
  4. Does propagating generic parameters make the public API unreadable? → Consider trait objects at the boundary, but keep generics internally.

When neither constraint forces your hand, default to generics. They are Rust’s primary abstraction tool, they play better with the type system, and they keep your options open — you can always wrap a generic in a thin allocation later.

Summary

Generics and trait objects are two sides of the same coin: static and dynamic dispatch. Generics offer speed, precise type information, and rich compile-time checks at the cost of binary size and homogeneous collections. Trait objects offer runtime flexibility, smaller binary footprint, and simpler signatures at the cost of indirection and erased types. Neither is universally better. The art is recognising which axis matters most for the code you’re writing right now and being willing to change your mind when the constraints shift.