Skip to content
Codeloom
Rust

Trait Objects vs Generics in Rust: A Decision Guide

Understand the trade-offs between dynamic dispatch with trait objects and static dispatch with generics in Rust through real-world examples.

·7 min read · By Codeloom
Intermediate 11 min read

What you'll learn

  • How static dispatch (generics) and dynamic dispatch (trait objects) work
  • The performance and binary size trade-offs of each approach
  • Object safety rules and when they apply
  • Practical patterns for choosing the right abstraction

Prerequisites

None — this post is self-contained.

Rust offers two mechanisms for polymorphism: generics with trait bounds (static dispatch) and trait objects (dynamic dispatch). Both let you write code that works with multiple types, but they compile differently, perform differently, and impose different constraints. This guide walks through the mechanics and helps you decide which to reach for.

Static Dispatch with Generics

When you write a generic function with a trait bound, the compiler generates a specialized version of the function for every concrete type you call it with. This process is called monomorphization.

trait Summary {
    fn summarize(&self) -> String;
}

struct Article {
    title: String,
    content: String,
}

impl Summary for Article {
    fn summarize(&self) -> String {
        format!("{}: {}...", self.title, &self.content[..20])
    }
}

struct Tweet {
    username: String,
    text: String,
}

impl Summary for Tweet {
    fn summarize(&self) -> String {
        format!("@{}: {}", self.username, self.text)
    }
}

// Static dispatch: compiler generates separate versions
// for notify::<Article> and notify::<Tweet>
fn notify(item: &impl Summary) {
    println!("Breaking: {}", item.summarize());
}

fn main() {
    let article = Article {
        title: String::from("Rust 2026"),
        content: String::from("The language continues to evolve with new features"),
    };
    let tweet = Tweet {
        username: String::from("rustlang"),
        text: String::from("Rust 2026 edition is here!"),
    };

    notify(&article);
    notify(&tweet);
}

The impl Trait syntax is shorthand for a generic bound. The full form is equivalent:

fn notify<T: Summary>(item: &T) {
    println!("Breaking: {}", item.summarize());
}

Because the compiler knows the exact type at each call site, it can inline the method call, eliminate virtual dispatch overhead, and apply type-specific optimizations. The cost is binary size: each monomorphized version adds code to the final binary.

Dynamic Dispatch with Trait Objects

Trait objects use dyn Trait to defer the type resolution to runtime. The compiler stores a fat pointer consisting of a pointer to the data and a pointer to a vtable containing the trait’s method implementations.

fn notify_dynamic(item: &dyn Summary) {
    println!("Breaking: {}", item.summarize());
}

fn main() {
    let article = Article {
        title: String::from("Rust 2026"),
        content: String::from("The language continues to evolve with new features"),
    };

    // Both calls go through the same function, dispatched at runtime
    let items: Vec<&dyn Summary> = vec![
        &article,
        &Tweet {
            username: String::from("dev"),
            text: String::from("Trait objects are powerful"),
        },
    ];

    for item in items {
        notify_dynamic(item);
    }
}

The single function notify_dynamic handles any type that implements Summary. There is no monomorphization, so the binary contains only one copy of the function. The trade-off is the vtable lookup on every method call, which prevents inlining.

Performance Comparison

The performance difference between static and dynamic dispatch is often smaller than expected, but it matters in hot loops.

trait Processor {
    fn process(&self, value: f64) -> f64;
}

struct Doubler;
impl Processor for Doubler {
    fn process(&self, value: f64) -> f64 {
        value * 2.0
    }
}

// Static dispatch: the compiler can inline process()
fn process_batch_static<P: Processor>(processor: &P, data: &[f64]) -> Vec<f64> {
    data.iter().map(|v| processor.process(*v)).collect()
}

// Dynamic dispatch: vtable lookup on each iteration
fn process_batch_dynamic(processor: &dyn Processor, data: &[f64]) -> Vec<f64> {
    data.iter().map(|v| processor.process(*v)).collect()
}

In process_batch_static, the compiler knows the concrete type and can inline process(), potentially vectorizing the loop. In process_batch_dynamic, each call goes through the vtable, blocking inlining and auto-vectorization.

For a batch of a million elements, the static version can be 2-5x faster in microbenchmarks. For a function called once per HTTP request, the difference is negligible.

Object Safety Rules

Not every trait can be used as a trait object. A trait is object-safe only if all its methods satisfy these constraints:

  • The return type is not Self
  • There are no generic type parameters on methods
  • The method has a receiver (self, &self, &mut self, or self: Box<Self>)
// Object-safe: can be used as dyn Drawable
trait Drawable {
    fn draw(&self);
    fn bounding_box(&self) -> (f64, f64, f64, f64);
}

// NOT object-safe: returns Self
trait Clonable {
    fn clone_self(&self) -> Self;
}

// NOT object-safe: generic method
trait Converter {
    fn convert<T>(&self, value: T) -> String;
}

If you need both trait objects and methods that violate these rules, you can add a where Self: Sized bound to exclude those methods from the vtable:

trait Shape {
    fn area(&self) -> f64;

    // This method is excluded from the vtable
    fn clone_shape(&self) -> Self
    where
        Self: Sized;
}

// Now Shape is object-safe; clone_shape just cannot be called on dyn Shape
fn print_area(shape: &dyn Shape) {
    println!("Area: {}", shape.area());
}

Heterogeneous Collections

The strongest case for trait objects is when you need a collection of different types. Generics require every element to be the same type.

trait Widget {
    fn render(&self) -> String;
}

struct Button { label: String }
struct TextInput { placeholder: String }
struct Checkbox { checked: bool }

impl Widget for Button {
    fn render(&self) -> String { format!("<button>{}</button>", self.label) }
}
impl Widget for TextInput {
    fn render(&self) -> String { format!("<input placeholder='{}'>", self.placeholder) }
}
impl Widget for Checkbox {
    fn render(&self) -> String {
        let state = if self.checked { "checked" } else { "" };
        format!("<input type='checkbox' {state}>")
    }
}

fn render_ui(widgets: &[Box<dyn Widget>]) -> String {
    widgets.iter().map(|w| w.render()).collect::<Vec<_>>().join("\n")
}

fn main() {
    let ui: Vec<Box<dyn Widget>> = vec![
        Box::new(Button { label: "Submit".into() }),
        Box::new(TextInput { placeholder: "Name".into() }),
        Box::new(Checkbox { checked: true }),
    ];

    println!("{}", render_ui(&ui));
}

You cannot achieve this with generics alone because Vec<T> requires a single T. The enum-based alternative (defining a WidgetKind enum) works but requires modifying the enum every time you add a new widget type, violating the open-closed principle.

The Enum Alternative

Before reaching for trait objects, consider whether an enum covers your use case. Enums give you static dispatch, exhaustive matching, and no heap allocation.

enum Shape {
    Circle(f64),
    Rectangle(f64, f64),
}

impl Shape {
    fn area(&self) -> f64 {
        match self {
            Shape::Circle(r) => std::f64::consts::PI * r * r,
            Shape::Rectangle(w, h) => w * h,
        }
    }
}

Use enums when the set of variants is closed and known at compile time. Use trait objects when the set of types is open and may be extended by downstream code or plugins.

Decision Framework

Use generics when:

  • Performance is critical and the function is called in hot paths
  • You need the compiler to inline trait method calls
  • The concrete type is known at compile time
  • You want zero-cost abstractions

Use trait objects when:

  • You need heterogeneous collections
  • You want to reduce binary size by avoiding monomorphization
  • The set of types is open-ended (plugin systems, UI frameworks)
  • You are building a library and want to minimize generic type parameter proliferation

Use enums when:

  • The set of variants is small and fixed
  • You want exhaustive pattern matching
  • You want to avoid heap allocation

Combining Both Approaches

In practice, you often use both in the same codebase. A common pattern is to use generics at the library boundary for maximum performance and trait objects internally for flexibility.

trait Storage {
    fn get(&self, key: &str) -> Option<String>;
    fn set(&mut self, key: &str, value: &str);
}

// Generic for performance-critical paths
fn cache_lookup<S: Storage>(storage: &S, key: &str) -> Option<String> {
    storage.get(key)
}

// Trait object for configuration and DI
struct App {
    storage: Box<dyn Storage>,
}

impl App {
    fn new(storage: Box<dyn Storage>) -> Self {
        App { storage }
    }
}

Wrapping Up

Generics and trait objects are complementary tools, not competitors. Generics give you zero-cost polymorphism with compile-time type resolution. Trait objects give you runtime flexibility with a small dispatch overhead. Understanding when each approach fits lets you write Rust code that is both performant and maintainable. Start with generics as your default, reach for trait objects when you need heterogeneous collections or open extensibility, and consider enums when the type set is closed.