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.
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 valueBox<dyn Trait>— owned, heap-allocated; most common in collectionsArc<dyn Trait>/Rc<dyn Trait>— reference-counted; for shared ownership
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:
- Data pointer — points to the actual value in memory
- 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.
// 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
}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 |
Object Safety Rules
Not every trait can be used as a trait object. A trait is object-safe only if:
- The trait has no methods that return
Selfby value - The trait has no generic method parameters (no
fn foo<T>(&self, t: T)) - The trait does not require
SizedonSelf
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.
// 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"
}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.
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")))) }
}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.
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.
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();
}
}Performance: Dynamic vs Static Dispatch
The cost of dynamic dispatch is real but modest. Each virtual method call requires:
- Load the vtable pointer from the fat pointer
- Index into the vtable to get the function pointer
- 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.
// 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.
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
Summary: When to Reach for Trait Objects
Heterogeneous collections — a
VecorHashMapholding values of different concrete types that share behaviourFactory 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 handling —
Box<dyn Error>avoids threading a concrete error type through the entire call stack