Introduction#
In our previous articles: RAII, Box, and Deref and Introduction to Traits, we explored basic traits and heap allocations via Box<T>. By default, Rust strives to make your code as fast as possible: when using generic types, the compiler performs monomorphization. It takes a function and generates a concrete copy for every single type used, producing direct, zero-cost function calls.
However, real-world software engineering often presents tasks where data types are not known upfront at compile time.
Imagine we are designing a notification or background task system. We have different task structs: NetworkTask (HTTP requests), DbTask (database writes), and TimerTask (timer triggers). All of them implement a shared Task trait with an execute() method. We want to store all these tasks in a single list (Vec) and process them inside a loop.
How do we store different types inside a single Vec? A vector in Rust requires every element to have the exact same type and byte size in memory.
This is where trait objects and the dyn keyword come to the rescue. In this article, we will examine how dynamic method dispatch works in Rust, where “fat” pointers come from, and how the compiler keeps everything memory-safe.
1. Static vs. Dynamic Dispatch#
To choose the right tool for the job, let’s compare these two approaches:
| Feature | Static Polymorphism (Generics) | Dynamic Polymorphism (dyn Trait) |
|---|---|---|
| Method Resolution | Compile time | Runtime |
| Binary Size | Slightly larger (code duplicated per type) | Smaller (single function instance for all types) |
| Collections in Memory | Single type per vector (e.g., strictly Vec<i32>) | Heterogeneous types in one vector (Vec<Box<dyn Task>>) |
| Execution Speed | Direct call (maximum performance) | Indirect call via method table (small runtime overhead) |
Heterogeneous Collections Example#
To store different types inside a single vector, we wrap them in a Box pointer and specify the dyn keyword:
trait Task {
fn execute(&self);
}
struct NetworkTask;
struct DbTask;
// Implementing Task for these structs...
// Storing different types in a single vector
let tasks: Vec<Box<dyn Task>> = vec![
Box::new(NetworkTask),
Box::new(DbTask),
];
for task in tasks {
task.execute(); // Which exact function executes is resolved at runtime
}If the exhaustive list of all possible types is known upfront and won’t expand dynamically, a standard enum serves as a great alternative to dyn Trait.
2. Type Sizes and ?Sized#
In Rust, almost every type has a fixed size in bytes known to the compiler at compile time. These are called Sized types. For example, i64 always takes 8 bytes, and a struct of two i32 fields takes 8 bytes. The compiler knows exactly how much stack space to allocate.
However, some types have a size that can only be determined at runtime. These are called DSTs (Dynamically Sized Types):
- String slices
str - Array slices
[T] - Trait objects like
dyn Task
Variables of DST types cannot be stored directly on the stack or passed by value. A variable declaration like let x: dyn Task will fail to compile because Rust does not know how many bytes to allocate on the stack. Dynamic types must always live behind pointers or references: &str, &[T], Box<dyn Task>.
By default, the compiler assumes every generic parameter must have a fixed size (T: Sized). If we want to write a function that works with dynamic types by reference, we must explicitly relax this constraint using the ?Sized bound (“size may be unknown”):
// T can be either fixed-size or dynamically-sized (e.g., &str or &dyn Debug)
fn print_val<T: ?Sized + std::fmt::Debug>(val: &T) {
println!("{:?}", val);
}3. Fat Pointers and the Method Table (vtable)#
A standard reference on a 64-bit architecture (like &i32 or Box<u64>) takes 8 bytes (one machine word) in memory. It is simply a memory address.
However, a reference to a dynamic type like &dyn Task or Box<dyn Task> takes 16 bytes (two machine words). Because of this doubled size, they are called fat pointers.
What do these 16 bytes contain?
- First 8 bytes: Address of the actual data (where the struct lives on the heap or stack).
- Second 8 bytes: Address of the virtual method table (
vtable) for that concrete type.
graph TD
subgraph FatPointer["Fat Pointer (16 bytes)"]
direction LR
DataPtr["1. Data Pointer (8 bytes)"]
VtablePtr["2. vtable Pointer (8 bytes)"]
end
DataMemory["Data in Memory
──────────────────────
NetworkTask
(object payload)"]
VtableMemory["Virtual Method Table (vtable)
──────────────────────
vtable for NetworkTask
• Destructor function (Drop)
• Object size in bytes (size)
• Memory alignment (align)
• Pointer to execute() method"]
DataPtr --> DataMemory
VtablePtr --> VtableMemory
style FatPointer fill:#f4f4f6,stroke:#666,stroke-width:2px,color:#000
style DataPtr fill:#fff,stroke:#333,color:#000
style VtablePtr fill:#fff,stroke:#333,color:#000
style DataMemory fill:#e8f4f8,stroke:#0288d1,stroke-width:2px,color:#000
style VtableMemory fill:#efebe9,stroke:#5d4037,stroke-width:2px,color:#000
Virtual Method Table (vtable) in Practice#
For every type implementing a trait, the compiler constructs a small static vtable during compilation. It contains:
- Address of the cleanup function (
Drop). - Object size and alignment requirements (so
Boxknows how many bytes to return to the OS on deallocation). - Addresses of all methods declared in the trait.
When we invoke a method on a trait object:
let task: Box<dyn Task> = Box::new(NetworkTask);
task.execute();The following steps occur under the hood:
- The program reads the second pointer from the fat pointer to locate the
vtable. - It looks up the function address for
NetworkTask::executefrom the table slot. - It calls the function, passing the data address (from the first half of the fat pointer) as the first
&selfargument.
4. Combining Traits and Why dyn (Fly + Swim) Is Disallowed#
In Rust, you cannot directly construct a trait object from two independent traits:
// Compilation error!
fn handle(obj: &(dyn Fly + Swim)) {}The reason is simple: supporting this would require a “three-word” fat pointer (data address + vtable1 + vtable2), adding memory overhead and complexity to pointer layouts.
How to Achieve This in Code?#
We create a single supertrait that inherits the required traits, and then provide a Blanket Implementation:
trait Fly {
fn fly(&self) -> String;
}
trait Swim {
fn swim(&self) -> String;
}
// Combine constraints into a single trait
trait Duck: Fly + Swim {}
// Blanket implementation for any type implementing both Fly and Swim
impl<T: Fly + Swim> Duck for T {}
// Now dyn Duck is completely valid and takes standard 16 bytes!
fn handle_duck(duck: &dyn Duck) {
println!("Duck: {}, {}", duck.fly(), duck.swim());
}5. Object Safety Rules#
Not every trait can be converted into a dyn Trait object. For a trait to support dynamic polymorphism, it must adhere to Object Safety rules.
These rules become intuitive once you remember how vtable works: a virtual table can only store methods that have a fixed function pointer signature and receive a pointer to data.
Why Do Certain Methods Break Object Safety?#
- Method takes
selfby value (fn run(self)): Passing by value requires placing the object on the stack, but the byte size ofdyn Traitis not known at compile time. - Method uses generics (
fn process<T>(&self, val: T)): The compiler generates a distinct function instance for everyT. Storing infinite function variants in a fixedvtableis impossible. - Method lacks a
selfparameter (fn new() -> Self): Without aselfargument, the program cannot dispatch to a specific vtable instance because there is no object instance to inspect. - Method returns or takes
Self: At runtime, different concrete structs may hide behinddyn Trait, so Rust cannot guarantee type safety when comparing or combining them.
Practical Solution with where Self: Sized#
If you need constructors or generic methods for static dispatch, but still want to use the trait via dyn Trait, add a where Self: Sized bound to problematic methods:
trait SafeTrait {
fn name(&self) -> String;
// This method takes Self by value and would break Object Safety.
// The `where Self: Sized` bound excludes it from the vtable!
fn copy_by_val(self) -> Self where Self: Sized {
self
}
}With where Self: Sized, the compiler knows that this method is only available during static typing, so it simply omits it from the dyn SafeTrait vtable. The trait remains object-safe!
Let’s see how all these mechanisms work in practice in our interactive code slides:
Test Your Knowledge!#
Take a short quiz to test your understanding of trait objects, fat pointers, and dynamic polymorphism in Rust.

