| |

Rust 65 🦀 Trait Objects (dyn Trait) and Dynamic Dispatch

⏱️ Estimated read: 16 min | |

Rust is a language that favors static dispatch. When you write a generic function with a trait bound, the compiler generates a specialized copy of that function for every concrete type that uses it. This is monomorphization, and it costs nothing at runtime: the call is direct, inlinable, and optimizable. But there is a class of problems that static dispatch cannot solve. If you need a collection that holds values of different concrete types—all sharing a common behavior—generics alone cannot express it, because a Vec<T> must have a single T. Trait objects solve this by erasing the concrete type at the point of use and substituting a runtime lookup through a virtual method table.

This chapter is about that mechanism: the dyn Trait type, the fat pointer that represents it, and the vtable that makes dynamic dispatch possible. We will cover why trait objects require pointers, what the two-pointer representation actually contains, the rules a trait must follow to be usable as a dyn Trait (the object safety, or “dyn compatibility,” rules), and the performance trade-offs between static and dynamic dispatch. We will also examine when to reach for dyn Trait and when a closed set of types should be modeled with an enum instead. By the end, you will understand not just how to write Box<dyn Draw>, but why it is shaped the way it is and what the compiler is doing underneath.

Key point: A trait object dyn Trait is a dynamically sized type that must live behind a pointer; the pointer is a fat pointer containing both a data pointer and a vtable pointer, and method calls through it are resolved at runtime by consulting the vtable.


Why dynamic dispatch exists

The heterogeneous collection problem. A Vec<T> can hold elements of exactly one type. If you want a list of drawable things—buttons, text fields, images—the types differ but the behavior is common. An enum can model a closed set of variants, but the set is not always closed. A GUI library cannot know every widget type its users will create. Trait objects are the mechanism that lets a single collection hold values of arbitrarily many concrete types, all behind one interface .

Type erasure and what it buys. Coercing a Box<Button> to Box<dyn Draw> erases the compiler’s knowledge of Button. After the coercion, the only thing known is that the value implements Draw. This erasure is what makes heterogeneity possible: the container stores a uniform Box<dyn Draw> regardless of what concrete type is inside . The price is that the concrete type is gone—you can no longer call Button-specific methods without a downcast, and the compiler can no longer inline the calls.

The closed vs open set distinction. When the set of types is known at compile time and is small, an enum is usually the better choice. An enum is a concrete type, stored inline, dispatched by a match, with no heap allocation and no vtable . Trait objects are for the opposite case: plugin architectures, user-extensible frameworks, or any situation where the set of implementors is open and cannot be enumerated at compile time.

Code size vs call speed. Monomorphization duplicates code for every concrete instantiation. When a generic function is used with many types, the binary grows, and instruction cache pressure can actually hurt performance—especially for cold paths . A trait object generates one copy of the dispatching code and shares it across all types. The trade-off is explicit: you give up inlining and direct calls in exchange for smaller code and runtime flexibility .

The design constraint as a signal. The rules that govern which traits can become trait objects—no generic methods, no bare Self returns, no associated constants—are not arbitrary. They exist because a vtable cannot contain an infinite family of monomorphized functions, and it cannot describe a return type that depends on a Self the caller no longer knows . When the compiler rejects dyn Trait with error E0038, it is telling you that the trait’s shape is incompatible with runtime dispatch, and the correct response is usually to reconsider whether dynamic dispatch is the right tool.


a. The representation of a trait object

A trait object is a fat pointer. On a 64-bit system, &dyn Trait or Box<dyn Trait> occupies 16 bytes: one pointer to the data, one pointer to the vtable . The data pointer addresses the concrete value. The vtable pointer addresses a static table of function pointers—one per method the trait declares.

The vtable itself is a compiler-generated struct. Its exact layout is not part of the stable ABI, but conceptually it contains a destructor, the size and alignment of the erased type, and a function pointer for each dispatchable method . When you call trait_object.method(), the compiler generates code that loads the vtable pointer from the fat pointer, indexes into the table at the method’s slot, and performs an indirect call through that function pointer .

The data pointer is not necessarily a heap allocation. A &dyn Trait can point to a stack value; the fat pointer merely records where it is and how to call its methods. A Box<dyn Trait> points to heap memory, because the box owns the allocation. An Arc<dyn Trait> points to an atomically reference-counted allocation. The vtable pointer is the same in all cases for a given concrete type and trait.

The fat pointer’s dual nature is what makes trait objects dynamically sized. dyn Trait itself has no statically known size, which is why it must always appear behind a pointer—&dyn Trait, Box<dyn Trait>, Rc<dyn Trait>, Arc<dyn Trait> . You cannot declare a variable of type dyn Trait or pass one by value. The pointer provides the fixed size; the vtable provides the behavior.


b. Object safety and the rules for dyn Trait

Not every trait can be used as a trait object. The set of rules is called dyn compatibility (formerly object safety), and the compiler enforces them at the coercion site .

A trait is dyn-compatible if it does not require Self: Sized, has no associated constants, and every method that should be callable through dyn Trait is dispatchable. A method is dispatchable if it has no generic type parameters, does not return bare Self, and uses a receiver that is either &self, &mut self, self: Box<Self>, self: Arc<Self>, or similar pointer-based forms .

trait Draw {
    fn draw(&self);
}

trait Factory {
    fn create<T>(&self) -> T; // NOT dispatchable: generic method
}

Draw is dyn-compatible. Factory is not, because create<T> would require a distinct function pointer for every T, and a vtable cannot contain all of them . The fix is usually to mark the offending method where Self: Sized, which makes it available on concrete types but excludes it from the vtable:

trait Factory {
    fn create<T>(&self) -> T where Self: Sized;
    fn name(&self) -> &'static str;
}

Now Factory is dyn-compatible: create is callable on sized implementors, and name is callable through dyn Factory. This pattern appears throughout the standard library—Iterator has many where Self: Sized methods so that dyn Iterator remains usable with just next .

Associated types require special handling. A non-generic associated type can be used with a trait object only if the object type specifies it: &dyn Iterator<Item = u8> is valid, but &dyn Iterator without the Item binding is not, because the compiler cannot know the return type of next .


c. When to use dyn Trait and when to use an enum

The decision between an enum and a trait object is the decision between a closed set and an open set. An enum models a fixed, known-at-compile-time collection of variants. It is stored inline, dispatched by match, has no heap allocation, and preserves the concrete type so variant-specific data is accessible . A trait object models an open set: any type that implements the trait, including types defined in other crates, can be used.

// Closed set: enum is better
enum Shape {
    Circle(f64),
    Rect(f64, f64),
}

// Open set: trait object is necessary
trait Shape {
    fn area(&self) -> f64;
}
fn total_area(shapes: &[Box<dyn Shape>]) -> f64 {
    shapes.iter().map(|s| s.area()).sum()
}

A hybrid pattern is sometimes useful: keep a Vec<Box<dyn Trait>> for the general case, but “promote” the most common variants into an enum for the hot path, using the trait object only as the fallback . This preserves extensibility while recovering static dispatch for the cases that matter.

For parameters, prefer &dyn Trait when you only need to borrow. Prefer Box<dyn Trait> when you must own or store the erased value. Prefer Arc<dyn Trait> when the value is shared across threads . Avoid boxing too early—a struct that holds Box<dyn Backend> when a generic Renderer<B: Backend> would do pays an indirection cost on every call for no flexibility benefit .


Complete Example Session

The following session builds a small drawing system that demonstrates trait objects in practice: heterogeneous collections, coercion, owned vs borrowed trait objects, and the interaction with object safety.

// ============================================
// PART 1: THE TRAIT
// ============================================
trait Draw {
    fn draw(&self) -> String;
    fn name(&self) -> &'static str;
}

// ============================================
// PART 2: TWO CONCRETE IMPLEMENTATIONS
// ============================================

struct Button {
    label: String,
}

impl Draw for Button {
    fn draw(&self) -> String {
        format!("[Button: {}]", self.label)
    }
    fn name(&self) -> &'static str {
        "button"
    }
}

struct TextField {
    placeholder: String,
}

impl Draw for TextField {
    fn draw(&self) -> String {
        format!("[TextField: {}]", self.placeholder)
    }
    fn name(&self) -> &'static str {
        "text_field"
    }
}

// ============================================
// PART 3: HETEROGENEOUS COLLECTION WITH BOX<DYN>
// ============================================

fn build_components() -> Vec<Box<dyn Draw>> {
    vec![
        Box::new(Button { label: "OK".into() }),
        Box::new(TextField { placeholder: "Name".into() }),
        Box::new(Button { label: "Cancel".into() }),
    ]
}

// ============================================
// PART 4: BORROWED TRAIT OBJECT PARAMETER
// ============================================

fn render_one(component: &dyn Draw) -> String {
    component.draw()
}

// ============================================
// PART 5: DISPATCHING OVER THE COLLECTION
// ============================================

fn render_all(components: &[Box<dyn Draw>]) -> Vec<String> {
    components.iter().map(|c| c.draw()).collect()
}

// ============================================
// PART 6: A GENERIC ALTERNATIVE FOR COMPARISON
// ============================================

fn render_generic<T: Draw>(component: &T) -> String {
    component.draw()
}

// ============================================
// PART 7: COERCION FROM CONCRETE TO TRAIT OBJECT
// ============================================

fn coerce_demo() -> String {
    let button = Button { label: "Coerce".into() };
    let as_dyn: &dyn Draw = &button; // implicit coercion
    as_dyn.draw()
}

// ============================================
// PART 8: SHARED OWNERSHIP WITH ARC<DYN>
// ============================================

use std::sync::Arc;

fn shared_component() -> Arc<dyn Draw> {
    Arc::new(TextField { placeholder: "Shared".into() })
}

// ============================================
// PART 9: OBJECT-SAFE TRAIT WITH OPTED-OUT METHOD
// ============================================

trait Service {
    fn handle(&self) -> String;
    // Not dispatchable; opted out of dyn use
    fn into_box(self) -> Box<dyn Service>
    where
        Self: Sized + 'static,
    {
        Box::new(self)
    }
}

struct Echo;

impl Service for Echo {
    fn handle(&self) -> String {
        "echo".into()
    }
}

// ============================================
// PART 10: MAIN
// ============================================

fn main() {
    let components = build_components();
    let rendered = render_all(&components);

    println!("heterogeneous: {:?}", rendered);
    println!("borrowed dyn:  {}", render_one(&Button { label: "B".into() }));
    println!("generic call:  {}", render_generic(&TextField { placeholder: "G".into() }));
    println!("coerced:       {}", coerce_demo());
    println!("arc<dyn>:      {}", shared_component().draw());

    let service: Box<dyn Service> = Echo.into_box();
    println!("service:       {}", service.handle());
}

The ten parts cover the trait definition, two implementations, a heterogeneous Vec<Box<dyn Draw>>, a borrowed &dyn Draw parameter, a generic function for comparison, implicit coercion, Arc<dyn Draw> for shared ownership, and a trait with a where Self: Sized method that remains dyn-compatible for its dispatchable part. What the session demonstrates is that dyn Trait is not a different kind of trait—it is a pointer type that erases the concrete implementor while preserving the behavior.


Quick Reference

Trait Object Forms

FormOwnershipAllocationUse When
&dyn TraitBorrowedNoneParameter; no ownership needed
&mut dyn TraitBorrowed mutableNoneParameter requiring mutation
Box<dyn Trait>OwnedHeapStoring/returning erased value
Arc<dyn Trait>SharedHeap (atomic)Thread-safe shared ownership
Rc<dyn Trait>SharedHeap (non-atomic)Single-thread shared ownership

Dyn Compatibility Rules

RuleViolationFix
No Self: Sized supertraittrait T: SizedRemove the bound
No associated constantsconst N: usizeMove to method or separate trait
No generic methodsfn f<T>(&self)Add where Self: Sized
No bare Self returnfn clone(&self) -> SelfReturn Box<dyn Trait>
Valid receiver&self, &mut self, Box<Self>Use a supported receiver

Static vs Dynamic Dispatch

AspectGenerics (impl Trait)dyn Trait
DispatchCompile-time, directRuntime, vtable
PerformanceInlinable, zero-costIndirect call, no inlining
Binary sizeLarger (per-type copies)Smaller (shared code)
HeterogeneousNoYes
Trait bound requiredDyn-compatibleAny trait

Decision Guide

SituationChoice
Closed set of typesEnum
Type known at call siteGenerics
Open set, runtime polymorphismdyn Trait
Hot path, performance criticalGenerics or enum
Plugin architecturedyn Trait
Binary size constraineddyn Trait

Best Practices

✅ Do This:

fn log(writer: &dyn Write) { ... }                    // ✅ borrow when possible
fn build() -> Box<dyn Handler> { ... }                // ✅ box when returning
let c: &dyn Draw = &button;                           // ✅ implicit coercion
trait T { fn f<T>(&self) -> T where Self: Sized; }    // ✅ opt out to stay dyn-safe
Arc<dyn Service>                                       // ✅ shared across threads
Vec<Box<dyn Draw>>                                     // ✅ heterogeneous collection

❌ Don’t Do This:

struct R { b: Box<dyn Backend> }                      // ❌ premature boxing
trait F { fn create<T>(&self) -> T; }                 // ❌ breaks dyn compatibility
let x: dyn Draw = button;                             // ❌ unsized, must be pointer
Box::new(button) as Box<dyn Draw>                     // ❌ cast not needed; coerce
Vec<dyn Draw>                                         // ❌ unsized element

Common Pitfalls

PitfallWhy It HappensFix
E0038: trait not dyn-compatibleGeneric method or bare SelfAdd where Self: Sized or split trait
Cannot create dyn Trait valueUnsized typeAlways use behind a pointer
Vtable lookup slower than expectedCache misses from indirect callsPrefer enum for hot paths
Associated type not specifieddyn Iterator without ItemWrite dyn Iterator<Item = T>
Self return blocks dynfn clone(&self) -> SelfReturn Box<dyn Trait>
Borrowed trait object outlives data&dyn Trait to a temporaryOwn with Box or extend lifetime
Too much boxingBox<dyn> inside every structUse generics internally, box at boundaries

Real-World Examples

1. GUI component list

let components: Vec<Box<dyn Draw>> = vec![Box::new(button), Box::new(field)];

2. Plugin registry

let plugins: HashMap<String, Box<dyn Plugin>> = HashMap::new();

3. Borrowed writer parameter

fn serialize(out: &mut dyn Write, data: &Data) -> io::Result<()>;

4. Return erased iterator

fn numbers() -> Box<dyn Iterator<Item = u32>> { Box::new(0..10) }

5. Thread-safe service handle

let service: Arc<dyn Service + Send + Sync> = Arc::new(MyService);

6. Object-safe trait with opted-out method

trait Factory { fn create(&self) -> Box<dyn Product>; fn reset(self) where Self: Sized; }

7. Downcast from trait object

if let Some(btn) = (&*component as &dyn Any).downcast_ref::<Button>() { ... }

8. Enum dispatch for closed set

enum Handler { A(AHandler), B(BHandler) }

9. Trait object with lifetime

Box<dyn Draw + 'a>

10. impl Trait for static dispatch

fn make() -> impl Draw { Button { label: "x".into() } }

Visual

Fat Pointer Layout

┌──────────────────────────────────────────────┐
│  &dyn Draw (16 bytes on 64-bit)              │
│                                              │
│  ┌─────────────────┬─────────────────────┐   │
│  │ data pointer    │ vtable pointer      │   │
│  └────────┬────────┴──────────┬──────────┘   │
│           │                   │              │
│           ▼                   ▼              │
│  ┌─────────────────┐  ┌─────────────────┐    │
│  │ Button {label}  │  │ vtable for      │    │
│  │ (concrete data) │  │ Button: Draw    │    │
│  └─────────────────┘  └────────┬────────┘    │
│                                │             │
│                                ▼             │
│                       ┌─────────────────┐    │
│                       │ destructor      │    │
│                       │ size, align     │    │
│                       │ draw()          │    │
│                       │ name()          │    │
│                       └─────────────────┘    │
└──────────────────────────────────────────────┘

Dynamic Dispatch Call Flow

┌──────────────────────────────────────────────┐
│  component.draw()                            │
│         │                                    │
│         ▼                                    │
│  load vtable pointer from fat pointer        │
│         │                                    │
│         ▼                                    │
│  index into vtable at draw slot              │
│         │                                    │
│         ▼                                    │
│  load function pointer                       │
│         │                                    │
│         ▼                                    │
│  indirect call with data pointer as self     │
│         │                                    │
│         ▼                                    │
│  Button::draw(&self) executes                │
└──────────────────────────────────────────────┘

Static vs Dynamic Dispatch

┌──────────────────────────────────────────────┐
│  STATIC (generics)                           │
│                                              │
│  render<Button>(&button) ──▶ Button::draw    │
│  render<TextField>(&field) ──▶ TextField::draw│
│  one specialized copy per type               │
│  direct call, inlinable                      │
│                                              │
│  DYNAMIC (trait object)                      │
│                                              │
│  render(&dyn Draw) ──▶ vtable lookup ──▶ fn  │
│  one shared copy of render                   │
│  indirect call, not inlinable                │
└──────────────────────────────────────────────┘

Enum vs Trait Object

┌──────────────────────────────────────────────┐
│  ENUM (closed set)                           │
│                                              │
│  enum Shape { Circle(f64), Rect(f64, f64) }  │
│  stored inline, no heap, match dispatch      │
│  exhaustive checking                         │
│                                              │
│  TRAIT OBJECT (open set)                     │
│                                              │
│  Box<dyn Shape>                              │
│  heap allocation, vtable dispatch            │
│  extensible by any implementor               │
└──────────────────────────────────────────────┘

Summary

ItemValue
Trait object typedyn Trait
RepresentationFat pointer: data + vtable
Size (64-bit)16 bytes
Must live behind&, &mut, Box, Rc, Arc
Dispatch mechanismVtable lookup, indirect call
Dyn compatibilityRules for usable dyn Trait
Generic methodsNot dispatchable
Bare Self returnNot dispatchable
Fix for offending methodwhere Self: Sized
Closed set alternativeEnum
Static dispatch alternativeGenerics / impl Trait

Key takeaways:

  • A trait object is a fat pointer. It contains a data pointer and a vtable pointer; the concrete type is erased .
  • dyn Trait is unsized. It must appear behind a pointer—&dyn Trait, Box<dyn Trait>, Arc<dyn Trait> .
  • Method calls go through the vtable. The compiler emits an indirect call that cannot be inlined, which is the runtime cost of dynamic dispatch .
  • Dyn compatibility is a set of rules. No generic methods, no bare Self returns, no associated constants, and only supported receivers .
  • where Self: Sized opts a method out. This keeps the trait dyn-compatible for its dispatchable methods while retaining the opted-out method on concrete types .
  • Enums beat trait objects for closed sets. Inline storage, direct match dispatch, no heap allocation .
  • Generics beat trait objects for hot paths. Static dispatch allows inlining and avoids the vtable indirection .
  • Box at boundaries, not everywhere. Use generics inside structs and box only when returning or storing erased types .
  • Box<dyn Trait> generates less code than many generic instantiations. The trade-off is call speed for binary size .

Remember: dyn Trait is Rust’s mechanism for runtime polymorphism. It erases the concrete type and replaces it with a fat pointer containing a data pointer and a vtable pointer. Method calls become indirect, which costs inlining and call speed but buys heterogeneity and smaller code. The trait must be dyn-compatible—no generic methods, no bare Self returns, no associated constants—and methods that violate these rules can be opted out with where Self: Sized. When the set of types is known and closed, reach for an enum; when it is open or performance-critical, stay with generics. Reach for dyn Trait when you genuinely need to mix types in a collection or hand ownership of an erased value across an API boundary.


Was this helpful?


Stop using slow, ad-bloated tool sites! 🤮

🔎 Search “KandZ Tools” on Google to use many professional utilities for free.

KandZ.me is the ultimate minimalist hub for:
✅ Finance (Mortgage, Interest, Inflation)
✅ Tech (Base64, JSON, Dev Suite, IP)
✅ Health (BMI, BMR, TDEE)
✅ Productivity (Timer, Workspace, QR)

⚡️ Fast & Private
🔒 No data leaves your device
💎 100% Free

🔗 Use it now: https://tools.kandz.me
🔖 Bookmark it—you’ll need it later!