Skip to main content
  1. Rust/

Trait Objects: How Dynamic Polymorphism Works in Rust

1948 words·10 mins· loading · loading · · ·Rust-middle
About Rust - This article is part of a series.
Articles read 0/4
0%
🔵 Intermediate level (Rust-middle)

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:

FeatureStatic Polymorphism (Generics)Dynamic Polymorphism (dyn Trait)
Method ResolutionCompile timeRuntime
Binary SizeSlightly larger (code duplicated per type)Smaller (single function instance for all types)
Collections in MemorySingle type per vector (e.g., strictly Vec<i32>)Heterogeneous types in one vector (Vec<Box<dyn Task>>)
Execution SpeedDirect 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
}
Note

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
Caution

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?

  1. First 8 bytes: Address of the actual data (where the struct lives on the heap or stack).
  2. 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 Box knows 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:

  1. The program reads the second pointer from the fat pointer to locate the vtable.
  2. It looks up the function address for NetworkTask::execute from the table slot.
  3. It calls the function, passing the data address (from the first half of the fat pointer) as the first &self argument.

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?
#

  1. Method takes self by value (fn run(self)): Passing by value requires placing the object on the stack, but the byte size of dyn Trait is not known at compile time.
  2. Method uses generics (fn process<T>(&self, val: T)): The compiler generates a distinct function instance for every T. Storing infinite function variants in a fixed vtable is impossible.
  3. Method lacks a self parameter (fn new() -> Self): Without a self argument, the program cannot dispatch to a specific vtable instance because there is no object instance to inspect.
  4. Method returns or takes Self: At runtime, different concrete structs may hide behind dyn 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:

Trait Objects and Dynamic Polymorphism
Step 1/4
 1trait Task {
 2    fn execute(&self) -> String;
 3}
 4
 5struct NetworkTask;
 6impl Task for NetworkTask {
 7    fn execute(&self) -> String { "Network".to_string() }
 8}
 9
10struct DbTask;
11impl Task for DbTask {
12    fn execute(&self) -> String { "Database".to_string() }
13}
14
15fn main() {
16    // Vector holds different types under a common trait object
17    let tasks: Vec<Box<dyn Task>> = vec![
18        Box::new(NetworkTask),
19        Box::new(DbTask),
20    ];
21    for t in tasks {
22        println!("Executing task: {}", t.execute());
23    }
24}
25

Dynamic Polymorphism and dyn Trait

  • Trait object dyn Task enables working with heterogeneous collections where types are known only at runtime.
  • For this reason, objects must live behind a pointer (e.g., Box<dyn Task> or &dyn Task).
  • Invoked method resolution occurs at runtime via the virtual method table (vtable).

Test Your Knowledge!
#

Take a short quiz to test your understanding of trait objects, fat pointers, and dynamic polymorphism in Rust.

Article read
Please rate how helpful and clear this article was to you
Article series
About Rust - This article is part of a series.
Articles read 0/4
0%
🔵 Intermediate level (Rust-middle)

Related