Understanding Rust trait Objects: dyn Keyword, Fat Pointers, and Vtables

Introduction

Rust provides two mechanisms for polymorphism: static dispatch via generics and dynamic dispatch via trait objects. This guide explains how trait objects work at the memory layout level, focusing on the dyn keyword, fat pointers, and virtual function tables (vtables).

Static vs Dynamic Dispatch

Static Dispatch: Compile-Time Specialization

Static dispatch uses generics and monomorphization. The compiler generates a separate copy of the function for each concrete type used, eliminating runtime overhead but increasing binary size.

Basic Monomorphization Example

use std::fmt::Display;

fn print_value<T: Display>(val: T) {
    println!("{}", val);
}

fn main() {
    print_value(42);           // i32
    print_value(3.14);         // f64
    print_value("hello");      // &str
}

// Compiler generates approximately:
// fn print_value_i32(val: i32) { println!("{}", val); }
// fn print_value_f64(val: f64) { println!("{}", val); }
// fn print_value_str(val: &str) { println!("{}", val); }

Each call site gets its own specialized function. The compiler knows the exact type at compile time, enabling direct function calls with zero indirection.

Real-World Example: Generic Collections

trait Drawable {
    fn draw(&self);
    fn area(&self) -> f64;
}

struct Circle {
    radius: f64,
}

impl Drawable for Circle {
    fn draw(&self) {
        println!("Drawing circle with radius {}", self.radius);
    }
    
    fn area(&self) -> f64 {
        std::f64::consts::PI * self.radius * self.radius
    }
}

struct Square {
    side: f64,
}

impl Drawable for Square {
    fn draw(&self) {
        println!("Drawing square with side {}", self.side);
    }
    
    fn area(&self) -> f64 {
        self.side * self.side
    }
}

// Static dispatch: generic function
fn render_and_measure<T: Drawable>(shape: &T) {
    shape.draw();
    println!("Area: {}", shape.area());
}

fn main() {
    let circle = Circle { radius: 5.0 };
    let square = Square { side: 10.0 };
    
    render_and_measure(&circle);  // Monomorphized to Circle version
    render_and_measure(&square);  // Monomorphized to Square version
}

// Compiler generates:
// fn render_and_measure_Circle(shape: &Circle) { ... }
// fn render_and_measure_Square(shape: &Square) { ... }

The compiler creates two distinct functions—one for Circle and one for Square.

Performance Characteristics

Advantages of Static Dispatch:

  • Zero runtime cost: Direct function calls with no vtable lookup
  • Inlining opportunities: Compiler can inline method bodies across the call boundary
  • Better optimization: Full type information enables aggressive optimizations
  • No pointer indirection: Methods are called directly on the concrete type

Trade-offs:

  • Binary size increase: Each type generates separate machine code (code bloat)
  • Compilation time: More code to generate means longer compile times
  • No heterogeneous collections: Can’t store multiple types in the same container

Complex Example: Multiple Generic Parameters

use std::fmt::{Debug, Display};

trait Processor {
    fn process(&self) -> String;
}

impl Processor for i32 {
    fn process(&self) -> String {
        format!("Integer: {}", self)
    }
}

impl Processor for f64 {
    fn process(&self) -> String {
        format!("Float: {:.2}", self)
    }
}

impl Processor for String {
    fn process(&self) -> String {
        format!("String: '{}'", self)
    }
}

// Generic function with multiple constraints
fn transform_and_log<T, U>(input: T, context: U) 
where
    T: Processor + Debug,
    U: Display,
{
    println!("Context: {}", context);
    println!("Input: {:?}", input);
    println!("Processed: {}", input.process());
}

fn main() {
    transform_and_log(42, "batch-001");
    transform_and_log(3.14159, "calculation");
    transform_and_log("Rust".to_string(), 2024);
}

// Compiler generates three versions:
// 1. transform_and_log_i32_str(input: i32, context: &str)
// 2. transform_and_log_f64_str(input: f64, context: &str)
// 3. transform_and_log_String_i32(input: String, context: i32)

Each combination of T and U creates a new monomorphized function.

Dynamic Dispatch: Runtime Polymorphism

Dynamic dispatch uses trait objects with the dyn keyword. A single function handles all types implementing the trait, with method calls resolved at runtime through a vtable.

use std::fmt::Display;

fn print_value(val: &dyn Display) {
    println!("{}", val); // One function, runtime vtable lookup
}
// The Display trait doesn't have a display() method. 
// It's used through formatting macros like println!() with the {} placeholder
// The Display trait has fmt(&self, f: &mut Formatter<'_>) -> Result
// which is called by formatting infrastructure like println!()

Comparison: Static vs Dynamic Dispatch

// STATIC DISPATCH: Known types at compile time
fn process_static<T: Drawable>(shapes: Vec<T>) {
    for shape in shapes {
        shape.draw();  // Direct call, can be inlined
    }
}

fn main() {
    // Usage: All elements must be the same type
    let circles = vec![
        Circle { radius: 1.0 },
        Circle { radius: 2.0 },
        Circle { radius: 3.0 },
    ];
    process_static(circles);  // Only works with homogeneous collections
}

// DYNAMIC DISPATCH: Type known at runtime
fn process_dynamic(shapes: Vec<Box<dyn Drawable>>) {
    for shape in shapes {
        shape.draw();  // Vtable lookup, generally not inlined
    }
}

fn main() {
    // Usage: Can mix different types
    let mixed_shapes: Vec<Box<dyn Drawable>> = vec![
        Box::new(Circle { radius: 1.0 }),
        Box::new(Square { side: 2.0 }),
        Box::new(Circle { radius: 3.0 }),
    ];
    process_dynamic(mixed_shapes);  // Heterogeneous collections
}

Static dispatch requires all elements to be the same type but provides maximum performance. Dynamic dispatch allows mixed types but incurs runtime overhead from vtable lookups.

When to Use Static Dispatch

Use static dispatch when:

  • Performance is critical: Game engines, numerical computation, embedded systems
  • Types are known at compile time: Most function calls in typical Rust code
  • Inlining is beneficial: Hot loops, small functions called frequently
  • Homogeneous collections: Processing arrays of the same type

When to Use Dynamic Dispatch

Use dynamic dispatch when:

  • Heterogeneous collections: Need to store multiple types in a single container
  • Plugin architectures: Loading code at runtime
  • Binary size concerns: Avoid code duplication from monomorphization
  • NOT for FFI boundaries: Rust trait objects have no stable ABI and their vtable layout is a compiler implementation detail. Never pass dyn Trait across FFI boundaries. Instead, define explicit #[repr(C)] structs with function pointers for C-compatible polymorphism.(ABI and vtable layout are intentionally unspecified)”

Binary Size Impact Example

// With 10 different types calling this generic function:
fn serialize<T: Serialize>(data: T) -> Vec<u8> {
    // 100 lines of complex serialization code
}

// Results in 10 copies of 100 lines = 1000 lines of machine code

For large generic functions used with many types, static dispatch can significantly increase binary size. In such cases, consider extracting non-generic code or using dynamic dispatch for less performance-critical paths.

The dyn Keyword and Unsized Types

The type dyn Trait represents a trait object—an unsized type whose concrete layout matches the underlying value it wraps. Because dyn Trait is not Sized, it must always appear behind a pointer such as &, Box, Rc, or Arc.

// Valid: trait object behind a reference
let obj: &dyn Read = &file;

// Valid: trait object behind Box
let obj: Box<dyn Read> = Box::new(file);

// Invalid: cannot have dyn Trait by value
// let obj: dyn Read = file;  // ❌ Compile error

Dyn Compatibility Requirements

Note: This concept was formerly called “object safety” until the terminology was updated around Rust 1.84.0 to better reflect that these rules govern compatibility with the dyn keyword, not general memory safety.

Not all traits can be converted into trait objects. A trait must be dyn-compatible to work with the dyn keyword.

A trait is dyn-compatible if all its methods callable on trait objects are dyn-compatible. A method is dyn-compatible if:

  1. No type parameters: The method must not have any type parameters
  2. Supported receiver: The method must use one of the following receiver forms:
    • &self: shared reference
    • &mut self: mutable reference
    • self: Box<Self>, self: Rc<Self>, self: Arc<Self>: smart pointer receivers
    • self: Pin<&Self>, self: Pin<&mut Self>: Pin-wrapped reference receivers
    • self: Pin<Box<Self>>, self: Pin<Rc<Self>>, self: Pin<Arc<Self>>: Pin-wrapped smart pointer receivers
    • BUT NOT self (by-value Self)
    • NOT arbitrary nested pointers like self: Rc<Box<Self>> without Pin
  3. No Self in arguments or return types: Except in the receiver position, unless the method has a where Self: Sized bound

Methods with where Self: Sized bounds are allowed in the trait but cannot be called on trait objects—they are excluded from the vtable.

Important clarification: a dyn-compatible trait “must not have any associated constants.” Associated constants DO make a trait non-dyn-compatible/dyn-incompatible.

// This trait is NOT dyn-compatible
trait NonDynCompatible {
    // https://stackoverflow.com/questions/77433184/why-doesnt-rust-support-trait-objects-with-associated-constants
    const CONST: i32 = 1;  // ❌ Makes trait dyn-incompatible
    fn method(&self);
}
// Cannot create trait objects:
// let obj: Box<dyn NonDynCompatible> = Box::new(S); // Compile error!

Dyn-Compatible Trait Example

trait Drawable {
    fn draw(&self);              // ✓ Dyn-compatible
    fn area(&self) -> f64;       // ✓ Dyn-compatible
}

// Valid: can create trait objects
let shape: &dyn Drawable = &circle;

Non-Dyn-Compatible Trait Example

trait Cloneable {
    fn clone(&self) -> Self;     // ✗ Returns Self without Sized bound
}

trait GenericProcessor {
    fn process<T>(&self, data: T); // ✗ Generic method
}

// Invalid: cannot create trait objects
// let obj: &dyn Cloneable = &value; // Compile error!

Selective Dyn compatibility

You can exclude specific methods from trait object requirements using Self: Sized:

trait Factory {
    fn create() -> Self where Self: Sized;  // Excluded from trait objects
    fn id(&self) -> u32;                     // Available on trait objects
}

// Valid: id() can be called on trait objects
let factory: &dyn Factory = &concrete_factory;
factory.id();  // ✓ Works

// Invalid: create() requires knowing concrete type at compile time
// factory.create();  // ✗ Compile error: method requires `Self: Sized`

Dyn compatibility ensures that vtable-based dispatch is feasible at runtime.

Fat Pointer Structure

A trait object pointer is a fat pointer consisting of two components:

  1. Data pointer: points to the concrete value
  2. Vtable pointer: points to a static vtable for the (Type, Trait) pair
    &dyn Trait (typically 16 bytes on 64-bit systems)
    ┌─────────────────────────┐
    │ data_ptr    (8 bytes)   │ ──► Concrete value
    ├─────────────────────────┤
    │ vtable_ptr  (8 bytes)   │ ──► Vtable (static)
    └─────────────────────────┘
    

The exact size is target-dependent, but on typical 64-bit platforms, a trait object pointer occupies two pointer-sized words (16 bytes total).

Vtable Layout

The vtable is a statically allocated table containing metadata and function pointers. While the precise layout is not part of Rust’s stable ABI, conceptually it contains:

Vtable for (Type = T, Trait = U)
┌────────────────────────────────────┐
│ drop_in_place: unsafe fn(*mut ())  │  Destructor
├────────────────────────────────────┤
│ size: usize                        │  sizeof(T)
├────────────────────────────────────┤
│ align: usize                       │  alignof(T)
├────────────────────────────────────┤
│ method_0: fn(*const (), ...) -> .. │  First trait method
├────────────────────────────────────┤
│ method_1: fn(*const (), ...) -> .. │  Second trait method
└────────────────────────────────────┘

The metadata fields (drop_in_place, size, align) enable operations like drop, size_of_val(), and align_of_val() on trait objects. Vtables are shared globally: all instances of the same (Type, Trait) pair use the same vtable.

Important: The exact order and offsets of vtable entries are implementation details that may change across compiler versions or targets, and the exact entries and their ordering are intentionally unspecified in the language. Code should not rely on specific vtable layouts.

Complete Memory Layout Examples

Example 1: Reference to Trait Object (&dyn Trait)

trait Animal {
    fn speak(&self);
    fn name(&self) -> &str;
}

struct Dog {
    name: String,
}

impl Animal for Dog {
    fn speak(&self) { println!("Woof!"); }
    fn name(&self) -> &str { &self.name }
}

let dog = Dog { name: "Buddy".into() };
let animal_ref: &dyn Animal = &dog;

Memory layout:

STACK
┌────────────────────────────────┐
│ dog: Dog                       │
│   name: String                 │ ◄─────┐
│     ptr: *mut u8 ───► heap     │       │
│     len: 5                     │       │
│     cap: 5                     │       │
└────────────────────────────────┘       │
┌────────────────────────────────┐       │
│ animal_ref: &dyn Animal        │       │
│   data_ptr    ─────────────────┼───────┘
│   vtable_ptr  ─────────────────┼───┐
└────────────────────────────────┘   │
                                     │
STATIC MEMORY                        │
┌────────────────────────────────┐   │
│ Vtable: Dog:Animal             │ ◄─┘
├────────────────────────────────┤
│ drop_in_place: Dog::drop       │
│ size: 24                       │
│ align: 8                       │
│ speak: Dog::speak              │
│ name: Dog::name                │
└────────────────────────────────┘

The dog value lives on the stack. The data_ptr in animal_ref points to dog on the stack. Only the String’s buffer is heap-allocated due to String’s semantics.

Example 2: Owned Trait Object (Box<dyn Trait>)

let animal_box: Box<dyn Animal> = Box::new(Dog { name: "Buddy".into() });

Memory layout:

HEAP
┌────────────────────────────────┐
│ Dog                            │ ◄─────┐
│   name: String                 │       │
│     ptr: *mut u8 ───► heap     │       │
│     len: 5                     │       │
│     cap: 5                     │       │
└────────────────────────────────┘       │
                                         │
STACK                                    │
┌────────────────────────────────┐       │
│ animal_box: Box<dyn Animal>    │       │
│   data_ptr    ─────────────────┼───────┘
│   vtable_ptr  ─────────────────┼───► (same vtable)
└────────────────────────────────┘

With Box<dyn Animal>, the Dog instance itself is heap-allocated. When animal_box is dropped, the heap memory is deallocated.

Method Call Mechanism

When calling a method on a trait object, Rust performs dynamic dispatch:

animal_ref.speak();

Conceptual steps:

 Load fat pointer components
    data_ptr    = 0x7fff...  (address of dog)
    vtable_ptr  = 0x0040...  (address of Dog:Animal vtable)

 Vtable lookup
    speak_fn = load function pointer for speak() from vtable

 Indirect call
    speak_fn(data_ptr)  // calls Dog::speak(&dog)

The indirection through the vtable adds a small runtime cost compared to static dispatch, but enables polymorphism without monomorphization. The method call uses an indirect call through the vtable, which generally prevents inlining but allows runtime polymorphism.

Trait Upcasting

Trait object upcasting is now stable in Rust, enabling coercion from dyn Subtrait to dyn Supertrait when Subtrait declares Supertrait as a supertrait.

trait Animal {
    fn name(&self) -> &str;
}

trait Dog: Animal {
    fn bark(&self);
}

#[derive(Copy, Clone)] // Add this derive macro
struct Beagle;

impl Animal for Beagle {
    fn name(&self) -> &str { "Beagle" }
}

impl Dog for Beagle {
    fn bark(&self) { println!("Woof!"); }
}

let beagle = Beagle;
let dog: &dyn Dog = &beagle;

// Upcast from &dyn Dog to &dyn Animal
let animal: &dyn Animal = dog;  // ✓ Upcasting to supertrait

Trait upcasting works with any pointer type (&, &mut, Box, Rc, Arc, raw pointers):

let dog_box: Box<dyn Dog> = Box::new(beagle);
let animal_box: Box<dyn Animal> = dog_box;  // ✓ Upcasting coercion

Important: This is a coercion, not subtyping. Therefore, container types do not upcast wholesale:

// ❌ This does NOT work:
let dogs: Vec<Box<dyn Dog>> = vec![Box::new(beagle)];
// let animals: Vec<Box<dyn Animal>> = dogs;  // Compile error!

Trait upcasting is particularly useful with the Any trait, as it allows upcasting your trait object to dyn Any to call Any’s downcast methods without adding trait methods.

Important: To upcast to dyn Any, your trait must explicitly declare Any as a supertrait (e.g., trait Dog: Any). Upcasting follows the declared supertrait relationship.

Collections of Heterogeneous Types

Trait objects enable heterogeneous collections:

let mut shapes: Vec<Box<dyn Drawable>> = Vec::new();
shapes.push(Box::new(Circle { radius: 5.0 }));
shapes.push(Box::new(Square { side: 10.0 }));
shapes.push(Box::new(Circle { radius: 3.0 }));

Memory layout:

Vec<Box<dyn Drawable>> on STACK
┌────────────────────────────────────────┐
│ ptr: *mut Box<dyn Drawable>            │
│ len: 3                                 │
│ cap: 4                                 │
└────────────────────────────────────────┘
         │
         ▼
HEAP (Vec buffer)
Each element occupies 16 bytes (fat pointer size on 64-bit)
┌────────────────────────────────────────┐
│[0] : Box<dyn Drawable>                 │
│      data_ptr    ───► Circle (heap)    │
│      vtable_ptr  ───► Circle:Drawable  │
├────────────────────────────────────────┤
│[1] : Box<dyn Drawable>                 │
│      data_ptr    ───► Square (heap)    │
│      vtable_ptr  ───► Square:Drawable  │
├────────────────────────────────────────┤
│[2] : Box<dyn Drawable>                 │
│      data_ptr    ───► Circle (heap)    │
│      vtable_ptr  ───► Circle:Drawable  │
│                       (same vtable)    │
└────────────────────────────────────────┘

Elements [0] and [2] share the same Circle:Drawable vtable because they are the same (Type, Trait) pair. Both vtable_ptr values point to the single shared Circle:Drawable vtable instance in static memory, while their data_ptr values point to distinct Circle instances on the heap.

Alternative approach: When the set of types is known and closed at compile time, consider using enums with static dispatch instead of trait objects for better performance:

enum Shape {
    Circle(Circle),
    Square(Square),
}

impl Shape {
    fn draw(&self) {
        match self {
            Shape::Circle(c) => c.draw(),
            Shape::Square(s) => s.draw(),
        }
    }
}

Trait Object Coercion

Rust performs unsized coercions to automatically convert concrete types to trait objects:

use std::fs::File;
use std::io::Read;

let file: File = ...;
let reader: &dyn Read = &file;  // &File → &dyn Read

The compiler converts the thin pointer &File into a fat pointer &dyn Read by adding the vtable pointer.

ASCII Quick Reference

╔══════════════════════════════════════════════════════╗
║ Trait Object Essentials                              ║
╠══════════════════════════════════════════════════════╣
║                                                      ║
║ &dyn Trait / Box<dyn Trait>                          ║
║   ├─ data_ptr   → concrete value                     ║
║   └─ vtable_ptr → vtable (static, shared)            ║
║                                                      ║
║ Vtable Layout (conceptual):                          ║
║   drop_in_place                                      ║
║   size                                               ║
║   align
║   Entries and ordering: intentionally unspecified     ║
║   method pointers...                                 ║
║                                                      ║
║ Method Call:                                         ║
║   1. Load data_ptr and vtable_ptr                    ║
║   2. Index vtable for method                         ║
║   3. Indirect call with data_ptr                     ║
║                                                      ║
║ Key Points:                                          ║
║   ✓ dyn Trait is unsized (not Sized)                 ║
║   ✓ Must be behind pointer (&, Box, Rc, Arc)         ║
║   ✓ One vtable per (Type, Trait) globally            ║
║   ✓ Small runtime cost for indirection               ║
║   ✓ Enables polymorphism without monomorphization    ║
║   ✓ Trait upcasting is stable        ║
║                                                      ║
╚══════════════════════════════════════════════════════╝

Comparison Table

Aspect Static Dispatch (T: Trait) Dynamic Dispatch (dyn Trait)
Mechanism Monomorphization Vtable lookup
Performance Zero-cost abstraction Small indirection cost
Binary Size Larger (code duplication) Smaller (single code path)
Inlining Methods can inline Generally not inlined
Type Info Known at compile time Erased at compile time
Use Case Known types Heterogeneous collections
Dyn compatibility All traits work Only dyn-compatible traits

Conclusion

Rust’s trait objects provide dynamic polymorphism through a carefully designed system of fat pointers and vtables. Understanding the memory layout—where data lives, how vtables are structured, and how method dispatch works—enables effective use of the dyn keyword for runtime polymorphism while maintaining Rust’s zero-cost abstraction philosophy for static dispatch.

Trait objects are powerful and allow seamless coercion from subtraits to supertraits without manual workarounds.