RustTrait Objects

Trait Objects and Dynamic Dispatch in Rust

In Rust, generics let you write code that works for many types — and the compiler generates a separate, optimised copy for each concrete type. But sometimes you genuinely do not know the types at compile time: you want a list of values that share some behaviour, where the exact type of each element is determined at runtime.

That is what trait objects are for. A trait object lets you treat any value that implements a given trait as that trait, deferring the exact type resolution to runtime via a mechanism called dynamic dispatch.

The Problem: A Heterogeneous Collection

Imagine you are building a simple UI framework. You have circles, rectangles, and text labels — all different types — but you want to store them in one Vec and call draw() on each. Generics alone cannot express this, because a Vec<T> must hold values of a single concrete type. Trait objects solve this.

RUST
trait Draw {
    fn draw(&self);
}

struct Circle    { radius: f64 }
struct Rectangle { width: f64, height: f64 }
struct Label     { text: String }

impl Draw for Circle {
    fn draw(&self) { println!("Drawing circle r={}", self.radius); }
}
impl Draw for Rectangle {
    fn draw(&self) { println!("Drawing rect {}x{}", self.width, self.height); }
}
impl Draw for Label {
    fn draw(&self) { println!("Drawing label {:?}", self.text); }
}

// Vec<Box<dyn Draw>> holds any type that implements Draw
fn render(components: &[Box<dyn Draw>]) {
    for c in components {
        c.draw(); // dynamic dispatch — resolved at runtime
    }
}

fn main() {
    let ui: Vec<Box<dyn Draw>> = vec![
        Box::new(Circle { radius: 3.0 }),
        Box::new(Rectangle { width: 10.0, height: 5.0 }),
        Box::new(Label { text: String::from("Submit") }),
    ];
    render(&ui);
}
Drawing circle r=3
Drawing rect 10x5
Drawing label "Submit"
Syntax: &dyn Trait and Box<dyn Trait>

A trait object is written as dyn Trait — the dyn keyword signals dynamic dispatch. Because trait objects are unsized (the compiler does not know their size at compile time), you always use them behind a pointer:

  • &dyn Trait — borrowed reference; no allocation, borrows an existing value
  • Box<dyn Trait> — owned, heap-allocated; most common in collections
  • Arc<dyn Trait> / Rc<dyn Trait> — reference-counted; for shared ownership

RUST
trait Greet {
    fn greet(&self) -> String;
}

struct English;
struct Spanish;

impl Greet for English { fn greet(&self) -> String { String::from("Hello!") } }
impl Greet for Spanish { fn greet(&self) -> String { String::from("Hola!") } }

// Borrowed trait object — no heap allocation needed
fn print_greeting(g: &dyn Greet) {
    println!("{}", g.greet());
}

// Factory returning an owned trait object — type decided at runtime
fn make_greeter(lang: &str) -> Box<dyn Greet> {
    match lang {
        "es" => Box::new(Spanish),
        _    => Box::new(English),
    }
}

fn main() {
    let e = English;
    print_greeting(&e); // Hello!

    let g = make_greeter("es");
    print_greeting(g.as_ref()); // Hola!
}
How It Works: Fat Pointers and Vtables

Under the hood, a trait object is a fat pointer — two machine-word pointers stored side by side:

  1. Data pointer — points to the actual value in memory
  2. Vtable pointer — points to a table of function pointers for that specific type

The vtable (virtual dispatch table) is generated once per (Type, Trait) pair and lives in the binary's read-only segment — it is not heap-allocated per object. When you call a method through a trait object, the runtime loads the vtable pointer, looks up the right function pointer, and calls it. This one extra indirection is the only cost of dynamic dispatch.

RUST
// Conceptually, a &dyn Draw for Circle looks like:
//
// FatPointer {
//     data:   *const Circle,       // points to the Circle value
//     vtable: *const DrawVtable,   // points to Circle's Draw vtable
// }
//
// DrawVtable {
//     draw: fn(*const ()),         // Circle::draw
//     size: usize,                 // size of Circle
//     align: usize,                // alignment of Circle
//     drop: fn(*mut ()),           // destructor
// }

use std::mem;

fn main() {
    // A regular reference is one pointer (8 bytes on 64-bit)
    let n: i32 = 42;
    let thin: &i32 = &n;
    println!("thin ref size: {} bytes", mem::size_of_val(&thin)); // 8

    // A trait object reference is two pointers (16 bytes on 64-bit)
    let fat: &dyn std::fmt::Debug = &n;
    println!("fat  ptr size: {} bytes", mem::size_of_val(&fat)); // 16
}
Note
The vtable is created once per (Type, Trait) pair and stored in the binary — it is not heap-allocated per object. The runtime cost is a single pointer load and indirect call per virtual method invocation.
Trait Objects vs Generics: Which to Use

Both trait objects and generics enable polymorphism, but they work at different times and suit different situations.

Aspect

Generics (static dispatch)

Trait objects (dynamic dispatch)

Dispatch time

Compile time — monomorphized

Runtime — vtable lookup

Performance

Zero-cost — can be fully inlined

One extra indirection per call

Binary size

Larger — one copy per type

Smaller — one shared code path

Type must be known at

Compile time

Runtime

Heterogeneous collections

Not directly possible

Yes — Vec<Box<dyn Trait>>

Return mixed types

No (without enum)

Yes — Box<dyn Trait>

Object safety required

No

Yes

Best for

Performance-critical, types known upfront

Plugin systems, heterogeneous data

Tip
Prefer generics (static dispatch) when possible — the compiler can inline calls and produce zero-overhead code. Reach for trait objects when you need a collection of mixed types or when the concrete type is genuinely unknown until runtime.
Object Safety Rules

Not every trait can be used as a trait object. A trait is object-safe only if:

  1. The trait has no methods that return Self by value
  2. The trait has no generic method parameters (no fn foo<T>(&self, t: T))
  3. The trait does not require Sized on Self

The reason is that a vtable has fixed-size entries. Generic methods would require infinitely many entries (one per type argument), and returning Self requires knowing the concrete size — both are impossible with a vtable.

RUST
// Object-safe: no Self in return, no generic params
trait Describe {
    fn describe(&self) -> String;
}

// NOT object-safe — returns Self:
// trait Duplicate {
//     fn duplicate(&self) -> Self;
// }
// error[E0038]: the trait Duplicate cannot be made into an object

// NOT object-safe — generic method param:
// trait Process {
//     fn process<T: std::fmt::Debug>(&self, item: T);
// }

// Clone is NOT object-safe (fn clone(&self) -> Self).
// Workaround: a helper supertrait that returns Box<dyn CloneBox>:
trait CloneBox: std::fmt::Debug {
    fn clone_box(&self) -> Box<dyn CloneBox>;
}

impl<T: Clone + std::fmt::Debug + 'static> CloneBox for T {
    fn clone_box(&self) -> Box<dyn CloneBox> {
        Box::new(self.clone())
    }
}

fn main() {
    let original: Box<dyn CloneBox> = Box::new(String::from("hello"));
    let cloned = original.clone_box();
    println!("{:?}", cloned); // "hello"
}
Warning
If you try to use a non-object-safe trait as `dyn Trait`, the compiler gives error E0038 and names the exact method that violated the rules. Read the message carefully — it pinpoints the problem and usually suggests a fix.
Box<dyn Error>: The Classic Use Case

The most common trait object in real Rust code is Box<dyn Error>. It lets a function return any error type without the caller needing to know which concrete error occurred — essential for functions that call multiple fallible operations.

RUST
use std::error::Error;
use std::fs;

// Returns any error type — caller does not need to know which
fn read_number(path: &str) -> Result<i32, Box<dyn Error>> {
    let content = fs::read_to_string(path)?; // io::Error if file missing
    let n: i32  = content.trim().parse()?;   // ParseIntError if not a number
    Ok(n)
}

fn main() {
    match read_number("number.txt") {
        Ok(n)  => println!("Got: {}", n),
        Err(e) => println!("Error: {}", e),
    }
}

// You can also define your own error types:
#[derive(Debug)]
struct AppError(String);

impl std::fmt::Display for AppError {
    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
        write!(f, "App error: {}", self.0)
    }
}
impl Error for AppError {}

fn might_fail(ok: bool) -> Result<(), Box<dyn Error>> {
    if ok { Ok(()) } else { Err(Box::new(AppError(String::from("something went wrong")))) }
}
Note
In larger projects, the `anyhow` crate builds on `Box<dyn Error>` to provide ergonomic error handling. `anyhow::Error` is essentially a well-designed `Box<dyn Error + Send + Sync + 'static>` with extra context and backtracing support.
A Plugin System with Box<dyn Plugin>

Trait objects are the natural building block for plugin and handler architectures. Each plugin is registered at startup, wrapped in a Box<dyn Plugin>, and stored in a shared registry. The host code never needs to know the concrete types.

RUST
trait Plugin {
    fn name(&self) -> &str;
    fn run(&self, input: &str) -> String;
}

struct UppercasePlugin;
struct ReversePlugin;
struct WordCountPlugin;

impl Plugin for UppercasePlugin {
    fn name(&self) -> &str { "uppercase" }
    fn run(&self, input: &str) -> String { input.to_uppercase() }
}
impl Plugin for ReversePlugin {
    fn name(&self) -> &str { "reverse" }
    fn run(&self, input: &str) -> String { input.chars().rev().collect() }
}
impl Plugin for WordCountPlugin {
    fn name(&self) -> &str { "word-count" }
    fn run(&self, input: &str) -> String {
        format!("{} words", input.split_whitespace().count())
    }
}

struct PluginRegistry {
    plugins: Vec<Box<dyn Plugin>>,
}

impl PluginRegistry {
    fn new() -> Self { PluginRegistry { plugins: Vec::new() } }

    fn register(&mut self, plugin: Box<dyn Plugin>) {
        self.plugins.push(plugin);
    }

    fn run_all(&self, input: &str) {
        for plugin in &self.plugins {
            println!("[{}]: {}", plugin.name(), plugin.run(input));
        }
    }
}

fn main() {
    let mut registry = PluginRegistry::new();
    registry.register(Box::new(UppercasePlugin));
    registry.register(Box::new(ReversePlugin));
    registry.register(Box::new(WordCountPlugin));

    registry.run_all("hello world");
}
[uppercase]: HELLO WORLD
[reverse]: dlrow olleh
[word-count]: 2 words
Thread-Safe Trait Objects: dyn Trait + Send + Sync

When you want to share a trait object across threads, you must add the Send and Sync marker traits as bounds. Send means the value can be moved to another thread; Sync means a shared reference to it can be accessed from multiple threads simultaneously.

RUST
use std::sync::Arc;
use std::thread;

// Trait requires Send + Sync so it is usable in multi-threaded contexts
trait Handler: Send + Sync {
    fn handle(&self, request: &str) -> String;
}

struct EchoHandler;
impl Handler for EchoHandler {
    fn handle(&self, request: &str) -> String {
        format!("Echo: {}", request)
    }
}

fn main() {
    // Arc lets multiple threads share ownership of the same handler
    let handler: Arc<dyn Handler> = Arc::new(EchoHandler);

    let mut handles = vec![];
    for i in 0..3 {
        let h = Arc::clone(&handler);
        handles.push(thread::spawn(move || {
            println!("{}", h.handle(&format!("request-{}", i)));
        }));
    }
    for handle in handles {
        handle.join().unwrap();
    }
}
Tip
The bound `dyn Trait + Send + Sync + 'static` is common in library APIs and async code. `'static` means the trait object contains no borrowed references with non-static lifetimes — required for most thread and async contexts.
Performance: Dynamic vs Static Dispatch

The cost of dynamic dispatch is real but modest. Each virtual method call requires:

  1. Load the vtable pointer from the fat pointer
  2. Index into the vtable to get the function pointer
  3. Call through the function pointer — this prevents inlining by the callee

In tight loops processing millions of items, this can be measurable. In most application code — UI rendering, network handlers, plugin systems — the overhead is negligible. Profile before optimising away trait objects.

RUST
// Static dispatch — monomorphized, can be fully inlined
fn sum_static<I: Iterator<Item = i32>>(iter: I) -> i32 {
    iter.sum()
}

// Dynamic dispatch — vtable lookup, callee cannot be inlined at call site
fn sum_dynamic(iter: &mut dyn Iterator<Item = i32>) -> i32 {
    iter.sum()
}

fn main() {
    let v = vec![1, 2, 3, 4, 5];

    // Compiler knows the concrete type: std::slice::Iter<i32>
    let s1 = sum_static(v.iter().copied());

    // Compiler only knows: "some Iterator<Item = i32>"
    let s2 = sum_dynamic(&mut v.iter().copied());

    println!("static={} dynamic={}", s1, s2); // both: 15
}
std::any::Any: Type Erasure and Downcasting

std::any::Any is a special trait that allows runtime type inspection and downcasting. Every 'static type implements Any automatically. You can store values as Box<dyn Any> and later recover the original concrete type with downcast_ref or downcast.

RUST
use std::any::Any;

fn inspect(value: &dyn Any) {
    if let Some(n) = value.downcast_ref::<i32>() {
        println!("i32: {}", n);
    } else if let Some(s) = value.downcast_ref::<String>() {
        println!("String: {:?}", s);
    } else if let Some(b) = value.downcast_ref::<bool>() {
        println!("bool: {}", b);
    } else {
        println!("unknown type");
    }
}

fn main() {
    let values: Vec<Box<dyn Any>> = vec![
        Box::new(42i32),
        Box::new(String::from("hello")),
        Box::new(true),
        Box::new(3.14f64),
    ];

    for v in &values {
        inspect(v.as_ref());
    }
}
i32: 42
String: "hello"
bool: true
unknown type
Warning
Using `Any` for general application logic is usually a design smell — it defeats the type system. Prefer proper enums or well-defined trait interfaces. `Any` is valuable in testing frameworks, plugin systems, and generic containers where the concrete type truly cannot be known at compile time.
Summary: When to Reach for Trait Objects
  • Heterogeneous collections — a Vec or HashMap holding values of different concrete types that share behaviour

  • Factory functions — returning different types depending on runtime conditions (Box<dyn Trait> return type)

  • Plugin or handler systems — new implementations registered at startup, host code stays stable

  • Type erasure in APIs — hide an implementation detail type from public callers

  • Error handlingBox<dyn Error> avoids threading a concrete error type through the entire call stack

Success
Trait objects (`Box<dyn Trait>`, `&dyn Trait`) give Rust runtime polymorphism without a garbage collector. They work via fat pointers — a data pointer plus a vtable pointer — and carry a small, predictable cost of one extra indirection per call. Use them when you need heterogeneous collections, factory functions, or plugin systems. Use generics everywhere else, and the compiler will generate zero-cost, inlined code for each concrete type.