Dynamic dispatch: calling code you don't know yet
You're building a game engine. You have a Player, an Enemy, and a FloatingText popup. All three need to update their position every frame. You store them in a single list so the game loop can iterate and call update() on each one. The list doesn't care about the specific type. It just knows every item implements Update.
This is dynamic dispatch. The compiler doesn't decide which update function to run until the program is actually running. At runtime, the program looks at the actual object, finds the right function, and calls it.
The fat pointer and the vtable
Rust implements dynamic dispatch using trait objects. A trait object is created with the dyn keyword. The syntax &dyn Trait or Box<dyn Trait> tells the compiler to treat the value as a trait object.
A trait object is not just a pointer to the data. It is a "fat pointer." A fat pointer contains two pieces of information:
- A pointer to the actual data on the heap or stack.
- A pointer to a virtual method table, or vtable.
The vtable is a static array of function pointers generated by the compiler. It has one entry for every method in the trait. When you coerce a concrete type like Player to &dyn Update, the compiler packs the data pointer and the vtable pointer into the fat pointer. The vtable pointer points to the Player-specific vtable, where the update slot contains the address of Player::update.
Think of a universal remote control. The remote has buttons for "Power" and "Volume". The remote doesn't know if it's controlling a TV or an air conditioner. The remote holds a pointer to the device and a pointer to the device's instruction manual. When you press "Power", the remote consults the manual to find the signal for that specific device. The fat pointer is the remote. The vtable is the manual.
trait Update {
fn update(&mut self, dt: f32);
}
struct Player {
x: f32,
y: f32,
}
impl Update for Player {
fn update(&mut self, dt: f32) {
// Player moves based on input.
self.x += dt * 5.0;
}
}
struct Enemy {
x: f32,
y: f32,
}
impl Update for Enemy {
fn update(&mut self, dt: f32) {
// Enemy chases the player.
self.x += dt * 2.0;
}
}
fn main() {
let mut player = Player { x: 0.0, y: 0.0 };
let mut enemy = Enemy { x: 10.0, y: 10.0 };
// Coerce references to trait objects.
// The compiler creates fat pointers here.
// Each fat pointer holds a data pointer and a vtable pointer.
let entities: Vec<&mut dyn Update> = vec![&mut player, &mut enemy];
for entity in entities {
// Dynamic dispatch occurs here.
// Rust reads the vtable pointer from the fat pointer,
// looks up the `update` function, and calls it.
entity.update(0.016);
}
}
Remember the fat pointer: two pointers, one for the data, one for the map.
What happens under the hood
At compile time, the compiler generates a vtable for the trait. It creates one vtable per concrete type that implements the trait. If Player and Enemy both implement Update, the compiler emits two vtables. The Player vtable has a slot for update pointing to Player::update. The Enemy vtable points to Enemy::update. These tables are static data in the binary. They are generated once and reused for every trait object of that type.
At runtime, calling a method on a trait object involves indirection. The CPU reads the fat pointer to get the vtable pointer. It reads the vtable to get the function pointer. It jumps to that address. This adds a memory access compared to a direct function call.
Dynamic dispatch also prevents inlining. The compiler cannot inline a function call through a trait object because it doesn't know the target function at compile time. Inlining allows the compiler to optimize across function boundaries, eliminate dead code, and specialize constants. When you use dyn, you sacrifice these optimizations. The call remains a call. The CPU must fetch the target address from memory.
The performance cost is usually small for logic-heavy methods. The cost becomes significant in tight loops where the method body is tiny. The overhead of the vtable lookup and the lost inlining can dominate the execution time. Profile before optimizing.
Pay the indirection tax when you need to mix types in one container.
A realistic plugin system
Dynamic dispatch shines when the set of types is open-ended. A plugin system is a classic use case. Plugins are loaded from disk at runtime. The core engine doesn't know the plugin types at compile time. It only knows they implement the Plugin trait.
trait Plugin {
fn name(&self) -> &str;
fn initialize(&mut self);
}
struct LoggerPlugin;
impl Plugin for LoggerPlugin {
fn name(&self) -> &str {
"Logger"
}
fn initialize(&mut self) {
println!("Logger plugin initialized.");
}
}
struct MetricsPlugin;
impl Plugin for MetricsPlugin {
fn name(&self) -> &str {
"Metrics"
}
fn initialize(&mut self) {
println!("Metrics plugin initialized.");
}
}
struct Engine {
// Box<dyn Plugin> owns the data.
// Each Box is a heap allocation.
// The Vec stores pointers to these allocations.
plugins: Vec<Box<dyn Plugin>>,
}
impl Engine {
fn new() -> Self {
Self {
plugins: Vec::new(),
}
}
// Accept a boxed trait object.
// The caller decides which concrete type to box.
fn register_plugin(&mut self, plugin: Box<dyn Plugin>) {
self.plugins.push(plugin);
}
fn start(&mut self) {
for plugin in &mut self.plugins {
// Dispatch to the plugin's initialize method.
// The vtable lookup happens here.
plugin.initialize();
}
}
}
fn main() {
let mut engine = Engine::new();
// Create concrete types and box them.
// The Box allocates memory on the heap.
// The trait object is stored inside the Box.
engine.register_plugin(Box::new(LoggerPlugin));
engine.register_plugin(Box::new(MetricsPlugin));
engine.start();
}
Convention aside: use Box<dyn Trait> when you need to own the data and store it in a collection. Use &dyn Trait when you only need to borrow the data and the lifetime is managed by the caller. Use Arc<dyn Trait> when multiple owners need shared access. The community calls this the "minimum unsafe surface" rule for pointers: keep the trait object behind a smart pointer that manages memory, not a raw pointer.
Pitfalls: size and object safety
Trait objects have constraints. The most common error is E0038. This error occurs when you try to use a trait object where a sized type is required.
struct BadContainer {
// Error: E0038. The trait `Update` has no size known at compile-time.
// You cannot store a `dyn` type directly in a struct field.
item: dyn Update,
}
The compiler rejects this because dyn Update is unsized. The compiler doesn't know how much memory to allocate for item. Different types implementing Update have different sizes. You must use a pointer. Wrap the trait object in Box, &, &mut, or Arc.
struct GoodContainer {
// Box provides a fixed size (pointer + metadata).
// The data lives on the heap.
item: Box<dyn Update>,
}
Not all traits can be made into trait objects. Traits must be "object safe". A trait is object safe if the compiler can generate a vtable for it. The rules are:
- Methods cannot have generic type parameters. The vtable is a static array. It cannot encode type parameters.
- Methods cannot return
Self. The return type must be known at compile time for the vtable slot. - Methods cannot use
Selfin argument positions, except for&self,&mut self, orBox<Self>. - The trait cannot require
Self: Sized.
If a trait violates these rules, the compiler emits object safety errors. You cannot create a dyn object for that trait.
trait BadTrait {
// Generic methods break object safety.
// The vtable cannot store a function pointer for a generic method.
fn process<T>(&self, data: T);
// Returning Self breaks object safety.
// The compiler doesn't know the size of the return value.
fn clone_self(&self) -> Self;
}
// Error: cannot make trait object because of generic method.
// let obj: &dyn BadTrait;
You can work around object safety limits using where Self: Sized. This bound excludes the method from the vtable. The method remains available on concrete types but not on trait objects.
trait Workaround {
fn common(&self);
// This method is not part of the vtable.
// You can call it on `Player`, but not on `&dyn Workaround`.
fn generic_method<T>(&self, data: T) where Self: Sized;
}
If the compiler complains about size, wrap it in a pointer. dyn types are always behind a pointer.
When to use dynamic dispatch
Rust offers multiple ways to abstract over types. Choose the right tool based on your needs.
Use dyn Trait when you need a heterogeneous collection of types that share a behavior, like a list of plugins or UI widgets. Use dyn Trait when the set of types is open-ended and determined at runtime, such as loading modules from disk. Use dyn Trait when you need to break circular dependencies between crates by defining a trait in a shared crate and implementing it in downstream crates.
Use generics when performance matters and the set of types is known at compile time; the compiler generates specialized code for each type, avoiding the vtable lookup overhead. Use generics when you need to access methods that are not object safe, such as generic methods or methods returning Self.
Use an enum when the set of variants is closed and you need to pattern match on the structure; enums are compact and allow exhaustive matching without the indirection of trait objects. Use an enum when you want to avoid heap allocations and keep data contiguous in memory.
Reach for Box<dyn Trait> when you need to own the data behind the trait object and store it in a collection. Reach for &dyn Trait when you only need to borrow the data and the lifetime is managed by the caller. Reach for Arc<dyn Trait> when multiple threads or owners need shared access to the trait object.
Pick generics for speed, dyn for flexibility. You can't have both in the same call.