Skip to main content
Navigation
HomeTechnical ReferenceJournalGitHubGitHub
Sidebar — toggle document categories via the logo
Categories

Dynamic Dispatch & Trait Objects

Overview​

Rust provides two dispatch mechanisms: static dispatch (monomorphization via generics) and dynamic dispatch (via dyn Trait and trait objects). Static dispatch is zero-cost at runtime but produces more code (code bloat). Dynamic dispatch adds a vtable lookup per call but allows heterogeneous collections. Understanding when to choose each is fundamental to idiomatic Rust.

Static dispatch (monomorphization)​

When you write a generic function, the compiler generates a separate copy for each concrete type used:

fn print_value<T: std::fmt::Display>(value: &T) {
println!("{}", value);
}

// The compiler generates:
// fn print_value_i32(value: &i32) { ... }
// fn print_value_String(value: &String) { ... }

print_value(&42);
print_value(&"hello".to_string());

The call site knows the exact function address at compile time — the linker can even inline it. This is zero-cost abstraction: you pay nothing at runtime that you wouldn't pay if you wrote the specialized versions by hand.

Monomorphization tradeoffs​

ProCon
Zero runtime overheadBinary size grows linearly with distinct type uses
Compiler can inlineCompile time increases
Exact type known (size, alignment)Cannot store different types together in a collection

Dynamic dispatch (dyn Trait)​

Dynamic dispatch uses trait objects — fat pointers containing both a data pointer and a vtable pointer:

┌────────────────────┐
│ &dyn Display │
├──────────┬─────────┤
│ data ptr │ vtable │ (16 bytes on 64-bit: 2 × usize)
└────┬─────┴────┬────┘
│ │
▼ ▼
┌─────────┐ ┌──────────────────┐
│ data │ │ vtable for Type │
│ (e.g. │ ├──────────────────┤
│ i32) │ │ drop: <fn ptr> │
│ │ │ size: 4 │
│ │ │ align: 4 │
│ │ │ fmt: <fn ptr> │
└─────────┘ └──────────────────┘

A &dyn Display is 16 bytes on 64-bit: 8 bytes for the data pointer, 8 bytes for the vtable pointer.

Using trait objects​

trait Animal {
fn speak(&self) -> &str;
fn legs(&self) -> u32 { 4 } // default impl
}

struct Dog;
impl Animal for Dog {
fn speak(&self) -> &str { "woof" }
}

struct Spider;
impl Animal for Spider {
fn speak(&self) -> &str { "..." }
fn legs(&self) -> u32 { 8 }
}

// Heterogeneous collection — all implement Animal but are different sizes
let animals: Vec<Box<dyn Animal>> = vec![
Box::new(Dog),
Box::new(Spider),
];

for a in &animals {
println!("{} ({} legs)", a.speak(), a.legs());
}

Without dyn, you cannot store Dog and Spider in the same Vec because they have different sizes.

Five forms of trait objects​

FormSizeOwnershipUse case
&dyn Trait16 bytesBorrowedPassing reference, no ownership
&mut dyn Trait16 bytesMutably borrowedMutating through the trait
Box<dyn Trait>16 bytes (heap data)OwnedStoring, returning, collections
Arc<dyn Trait>16 bytes (heap data)Shared ownedMulti-threaded shared ownership
Pin<Box<dyn Trait>>16 bytes (pinned heap)Owned + pinnedAsync trait objects

Object safety​

Not all traits can be made into trait objects. A trait is object-safe if all of its methods satisfy:

  1. No Self: Sized requirement — methods cannot require a known size at the call site.
  2. No generic type parameters on methods — the vtable can't hold a function for every possible type.
  3. Self only in receiver position — self, &self, &mut self, Box<self>, etc. are OK; -> Self is not.
  4. No associated constants (stable Rust limitation).

Examples of non-object-safe traits​

// Non-object-safe: method has a type parameter
trait Parser {
fn parse<T: FromStr>(&self, input: &str) -> Result<T, T::Err>; // ✗
}

// Non-object-safe: returns Self
trait Clone {
fn clone(&self) -> Self; // ✗
}

// Non-object-safe: associated constant
trait Config {
const MAX_SIZE: usize; // ✗ (stabilized in nightly)
}

// Fix: use where Self: Sized to opt-out methods
trait Cloneable {
fn clone_box(&self) -> Box<dyn Cloneable>; // ✓ returns trait object
}

// Fix: explicit Sized bound
trait Parser {
fn parse_str(&self, input: &str) -> Result<String, ParseError>; // ✓ concrete type

// Opt-out non-object-safe methods with Sized
fn parse<T: FromStr>(&self, input: &str) -> Result<T, T::Err>
where Self: Sized; // ✓ only available when Self is known
}

When a method has where Self: Sized, it is not part of the trait object's vtable and cannot be called through dyn Trait.

Checking object safety at compile time​

fn requires_object_safe<T: MyTrait + ?Sized>() {}

// The compiler will error if MyTrait is not object-safe
// This pattern is useful in libraries that need to enforce it

A raw T: MyTrait means T: MyTrait + Sized. You must add ?Sized for the compiler to check object safety.

Sized and ?Sized​

Sized is an auto trait — every concrete type is Sized by default. Types whose size is unknown at compile time are !Sized (unsized). The only unsized types are:

  • [T] — slices
  • dyn Trait — trait objects
  • str — string slices
  • Structs whose last field is unsized (rare)
// Implicitly T: Sized
fn concrete<T: Display>(value: &T) { ... }

// Explicitly opt out: allow unsized types
fn unsized<T: Display + ?Sized>(value: &T) { ... }

?Sized means "this type may or may not be Sized." It does NOT mean "the type is unsized" — it relaxes the default Sized bound.

Why Sized matters for generics​

fn store_on_stack<T>(value: T) {
// The compiler must know T's size to allocate the stack frame.
// T: Sized is implied — this works.
}

fn store_on_heap<T: ?Sized>(value: Box<T>) {
// Box<T> is always Sized (it's a pointer), even when T is not.
// This is fine.
}

Performance comparison​

Static dispatch​

fn static_dispatch<T: Animal>(animal: &T) {
animal.speak(); // direct function call — compiler can inline
}
  • Zero runtime overhead.
  • Compiler knows the exact callee — inlining possible.
  • Each monomorphization duplicates code.

Dynamic dispatch​

fn dynamic_dispatch(animal: &dyn Animal) {
animal.speak(); // vtable[0](data) — indirect call through function pointer
}
  • Cost: ~3-5 CPU cycles for vtable lookup + indirect branch.
  • Prevents inlining (compiler can't know which function is called).
  • Single copy of the caller function.

Benchmark guidance​

ScenarioRecommendation
Hot loop, < 5 implementationsStatic dispatch
Cold code, many implementationsDynamic dispatch
Heterogeneous collection (e.g. plugin system)Dynamic dispatch (only option)
Library exposing a single trait methodDynamic dispatch reduces binary size for consumers
Binary size constrained (embedded, WASM)Dynamic dispatch for large generic functions

When to use which​

Use static dispatch (generics) when:​

  • The number of types is small and known at compile time.
  • Performance is critical in hot paths.
  • You need to return the concrete type by value.
  • The trait methods need generic parameters on methods.

Use dynamic dispatch (trait objects) when:​

  • You need to store heterogeneous types in the same collection.
  • You want to reduce binary size (avoid monomorphization bloat).
  • You're building a plugin/extension system.
  • The set of types is unbounded or determined at runtime.
  • You want to decouple the API from concrete types (e.g., returning Box<dyn Error>).

Hybrid approach​

// Inner function is generic (fast, monomorphized)
fn process_impl<T: Processor + ?Sized>(processor: &T, data: &Data) -> Result {
processor.validate(data)?;
processor.transform(data)
}

// Outer function takes a trait object (flexible, single binary copy)
pub fn process(processor: &dyn Processor, data: &Data) -> Result {
process_impl(processor, data)
}

This pattern — sometimes called "thin wrapper" — gives you the API flexibility of dyn Trait while keeping the hot code monomorphized. The dyn-to-generics conversion has a negligible vtable-lookup overhead at the boundary.

Trait object limitations and workarounds​

Cannot call generic methods​

trait Serializer {
fn serialize<T: Serialize>(&self, value: &T) -> String;
}

fn take_serializer(s: &dyn Serializer) {
// s.serialize(&42); // ✗ error: generic method can't be called on trait object
}

Workaround: erase the generic to a trait object:

trait Serializable {
fn serialize_with(&self, s: &dyn Serializer) -> String;
}

trait Serializer {
fn serialize_serializable(&self, value: &dyn Serializable) -> String;
}

Cannot clone a trait object​

Clone is not object-safe (it returns Self). Workaround: define a helper method:

trait ClonableAnimal: Animal {
fn clone_box(&self) -> Box<dyn ClonableAnimal>;
}

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

let cloned: Box<dyn ClonableAnimal> = original.clone_box();

Downcasting from trait objects​

Use std::any::Any:

use std::any::Any;

trait Animal: Any { ... }

let animals: Vec<Box<dyn Animal>> = ...;

for a in animals {
if let Some(dog) = a.as_any().downcast_ref::<Dog>() {
println!("Found a dog!");
}
}

// Requires adding as_any() to the trait:
trait Animal: Any {
fn as_any(&self) -> &dyn Any;
}

See also​