Module 2: Dispatch and Shared Ownership

Rust Trait Objects and Dynamic Dispatch

Learning objectives

  • Explain the core mental model behind Rust Trait Objects and Dynamic Dispatch
  • Apply Rust Trait Objects and Dynamic Dispatch within Dispatch and Shared Ownership
  • Identify important boundaries, trade-offs, and failure modes
  • Produce concrete evidence from the practice exercise

Related: Rust Traits | Rust Generics and Monomorphization | Rust Smart Pointers | Rust Stack Heap and Memory Layout | Rust Index


The Problem Generics Cannot Solve

Monomorphization resolves every generic parameter at compile time. That is what makes it free, and it is also what makes it useless for this:

// ✗ every element must be the SAME T
let components: Vec<T> = vec![monitor, driver, scoreboard];

Three different concrete types cannot inhabit one Vec<T>, because T is one type. But a testbench genuinely does need a list of heterogeneous components that all respond to run(). The set is decided at runtime — by a config file, a plugin, a factory override.

dyn Trait is the answer: erase the concrete type, keep the behaviour.

let components: Vec<Box<dyn Component>> = vec![
    Box::new(Monitor::new()),
    Box::new(Driver::new()),
    Box::new(Scoreboard::new()),
];
for c in &components { c.report(); }

Verification analogy that holds precisely: this is a uvm_component handle array. The factory hands you objects whose concrete type is decided at runtime by set_type_override, you store them as base-class handles, and calling run_phase() dispatches virtually to whichever derived implementation was actually built. Rust's Box<dyn Component> is the same mechanism with the same cost — one pointer indirection per call — with the difference that the "base class" carries no fields at all.


The Fat Pointer and the Vtable

This is the whole implementation, and seeing it removes all the mystery.

   &dyn Draw  is a FAT POINTER — two words, not one

   ┌──────────────┬──────────────┐
   │  data ptr    │  vtable ptr  │   16 bytes on a 64-bit target
   └──────┬───────┴───────┬──────┘
          │               │
          ▼               ▼
   ┌────────────┐   ┌────────────────────────────┐
   │  Circle {  │   │ VTABLE for Circle as Draw  │
   │    r: 2.0  │   ├────────────────────────────┤
   │  }         │   │ drop_in_place  → &fn       │
   └────────────┘   │ size           = 8         │
                    │ align          = 8         │
   the value        │ draw()         → &fn       │  ← one entry per trait method
   itself: NO       │ area()         → &fn       │
   header, NO       └────────────────────────────┘
   type tag             ONE vtable per (type, trait) pair,
                        emitted once in .rodata

   c.draw()  compiles to:   call [vtable + offset_of(draw)]
                            with data ptr as the first argument

Two design points fall out, both consequential:

  1. The vtable pointer lives in the reference, not the object. In C++ every polymorphic
  2. object carries a vptr in its first 8 bytes, forever, whether or not anyone uses it polymorphically. In Rust a Circle is 8 bytes; it only becomes fat when you take &dyn Draw to it. This is "you don't pay for what you don't use", implemented literally.

  3. One type can have several vtables. Circle implementing Draw and Serialize gets
  4. two independent vtables. C++ multiple inheritance needs pointer adjustment thunks to achieve the same thing; Rust just picks the right vtable at coercion time.

The trade-offs versus generics:

impl Trait / <T: Trait>dyn Trait
DispatchStatic, inlinableIndirect call, not inlinable
Cost per call0~1-3 ns (load + indirect branch)
Code sizeOne copy per typeOne copy total
Heterogeneous collections
Runtime type selection
Compile timeGrows with instantiationsFlat

The Forms a Trait Object Takes

dyn Trait is unsized (Rust Stack Heap and Memory Layout), so it only exists behind a pointer.

&dyn Draw               // borrowed, cheapest, no allocation
&mut dyn Draw           // borrowed, mutable
Box<dyn Draw>           // owned, heap-allocated — the workhorse
Rc<dyn Draw>            // shared ownership, single-threaded
Arc<dyn Draw>           // shared ownership, thread-safe
Box<dyn Draw + Send>    // with extra auto-trait bounds
Box<dyn Draw + 'a>      // with an explicit lifetime (default is 'static in Box)
fn render_borrowed(items: &[&dyn Draw]) {          // no allocation
    for i in items { i.draw(); }
}

fn render_owned(items: Vec<Box<dyn Draw>>) {       // heap, owned
    for i in &items { i.draw(); }
}

fn make(kind: &str) -> Box<dyn Draw> {             // type chosen at runtime
    match kind {
        "circle" => Box::new(Circle { r: 1.0 }),
        _        => Box::new(Square { s: 1.0 }),
    }
}

That last function is the case generics simply cannot express: the return type depends on a runtime string. impl Draw would require one concrete type for all paths.


Object Safety: Which Traits Can Be dyn

Not every trait can become a trait object. The rules follow mechanically from the fat-pointer representation — a vtable entry is a function pointer taking an opaque *mut (), so anything the caller would need to know the concrete type for is disallowed.

   NOT object-safe if the trait has:

   1. a method taking `self` by value        → the caller doesn't know the size
        fn consume(self) -> String;             to move it off the stack

   2. a method returning `Self`               → the caller doesn't know what
        fn clone_me(&self) -> Self;              to allocate space for

   3. a generic method                        → monomorphization would need
        fn process<T>(&self, x: T);              infinite vtable entries

   4. `Self: Sized` as a supertrait          → explicitly excludes unsized use

   5. an associated constant                  → no place in the vtable
trait Bad {
    fn clone_me(&self) -> Self;
}
let b: Box<dyn Bad> = ...;
error[E0038]: the trait `Bad` cannot be made into an object
  |
  |     fn clone_me(&self) -> Self;
  |                           ---- ...because method `clone_me` references
  |                                the `Self` type in its return type
  = help: consider moving `clone_me` to another trait

Two standard escapes:

// 1. exclude the offending method from the vtable
trait Mixed {
    fn draw(&self);                                  // in the vtable
    fn build() -> Self where Self: Sized;            // excluded — still callable
    fn helper<T>(&self, x: T) where Self: Sized;     //   on concrete types
}
// Mixed IS object-safe; `dyn Mixed` just cannot call build() or helper()

// 2. return a boxed object instead of Self
trait Cloneable {
    fn clone_box(&self) -> Box<dyn Cloneable>;
}
impl<T: Clone + Cloneable + 'static> Cloneable for T {
    fn clone_box(&self) -> Box<dyn Cloneable> { Box::new(self.clone()) }
}

The where Self: Sized trick is used throughout the standard library — Iterator has seventy methods, most of which are generic and excluded, which is exactly why Box<dyn Iterator<Item = u32>> works at all despite map, filter, and friends being non-object-safe.


Downcasting With Any

Sometimes you genuinely need the concrete type back. std::any::Any provides a checked downcast — Rust's $cast.

use std::any::Any;

trait Component: Any {
    fn name(&self) -> &str;
    fn as_any(&self) -> &dyn Any;
}

struct Scoreboard { mismatches: usize }

impl Component for Scoreboard {
    fn name(&self) -> &str { "scoreboard" }
    fn as_any(&self) -> &dyn Any { self }
}

fn inspect(c: &dyn Component) {
    if let Some(sb) = c.as_any().downcast_ref::<Scoreboard>() {
        println!("scoreboard with {} mismatches", sb.mismatches);
    } else {
        println!("{} — not a scoreboard", c.name());
    }
}

This is precisely $cast(sb, comp) from SV polymorphism, with two improvements: it returns an Option rather than a status flag you might forget to check, and Any requires 'static, so you cannot downcast something holding a dangling borrow.

Use it sparingly. Frequent downcasting means the trait is missing a method. In UVM the same smell applies — a scoreboard that $casts every incoming item to check its type usually wants a virtual method instead.


Examples

Example 1: A component registry — the pattern this feature exists for

use std::collections::HashMap;

trait Component {
    fn name(&self) -> &str;
    fn build(&mut self) {}
    fn run(&mut self);
    fn report(&self) -> String { format!("{}: ok", self.name()) }
}

struct Driver { sent: usize }
struct Monitor { seen: usize }
struct Scoreboard { exp: Vec<u32>, mismatches: usize }

impl Component for Driver {
    fn name(&self) -> &str { "driver" }
    fn run(&mut self) { self.sent += 10; }
    fn report(&self) -> String { format!("driver: sent {}", self.sent) }
}

impl Component for Monitor {
    fn name(&self) -> &str { "monitor" }
    fn run(&mut self) { self.seen += 10; }
    fn report(&self) -> String { format!("monitor: seen {}", self.seen) }
}

impl Component for Scoreboard {
    fn name(&self) -> &str { "scoreboard" }
    fn build(&mut self) { self.exp = vec![1, 2, 3]; }
    fn run(&mut self) { self.mismatches = 0; }
    fn report(&self) -> String {
        format!("scoreboard: {} mismatches over {} expected", self.mismatches, self.exp.len())
    }
}

// a factory: the concrete type is chosen by a RUNTIME string
type Ctor = fn() -> Box<dyn Component>;

fn registry() -> HashMap<&'static str, Ctor> {
    let mut m: HashMap<&'static str, Ctor> = HashMap::new();
    m.insert("driver",     || Box::new(Driver { sent: 0 }));
    m.insert("monitor",    || Box::new(Monitor { seen: 0 }));
    m.insert("scoreboard", || Box::new(Scoreboard { exp: vec![], mismatches: 0 }));
    m
}

fn main() {
    let reg = registry();
    let wanted = ["driver", "scoreboard", "monitor"];   // as if from a config file

    let mut env: Vec<Box<dyn Component>> =
        wanted.iter().filter_map(|k| reg.get(k).map(|c| c())).collect();

    for c in env.iter_mut() { c.build(); }
    for c in env.iter_mut() { c.run(); }
    for c in env.iter()     { println!("{}", c.report()); }
}
driver: sent 10
scoreboard: 0 mismatches over 3 expected
monitor: seen 10

This is a UVM factory in forty lines: a string-keyed registry of constructors returning base-typed handles, phase methods invoked uniformly across heterogeneous types. Generics cannot express it, because wanted is data.

Example 2: Measuring the dispatch cost honestly

trait Op { fn apply(&self, x: u64) -> u64; }
struct AddOne;
struct Double;
impl Op for AddOne { fn apply(&self, x: u64) -> u64 { x + 1 } }
impl Op for Double { fn apply(&self, x: u64) -> u64 { x * 2 } }

#[inline(never)]
fn run_static<T: Op>(op: &T, n: u64) -> u64 {
    (0..n).fold(0u64, |a, _| op.apply(a))
}

#[inline(never)]
fn run_dynamic(op: &dyn Op, n: u64) -> u64 {
    (0..n).fold(0u64, |a, _| op.apply(a))
}

Typical result for n = 100 million: the static version is inlined into an add in the loop body and vectorizes; the dynamic version performs 100 million indirect calls and cannot be inlined. The gap is often 5-20x for this microbenchmark, where the work per call is one instruction.

The honest conclusion is not "dyn is slow". It is: the vtable cost is a fixed few nanoseconds, so it matters only when the work per call is comparable to it. A component's run_phase doing real work absorbs the indirection invisibly; a comparator called per array element does not. Choose accordingly, and measure rather than assume — see Rust Performance and Zero Cost Abstractions.

Example 3: Trait objects with lifetimes, avoiding the allocation

trait Sink { fn write_line(&mut self, s: &str); }

struct Stdout;
struct Buffer<'a> { out: &'a mut String }

impl Sink for Stdout {
    fn write_line(&mut self, s: &str) { println!("{s}"); }
}
impl<'a> Sink for Buffer<'a> {
    fn write_line(&mut self, s: &str) { self.out.push_str(s); self.out.push('\n'); }
}

// &mut dyn Sink — no Box, no heap allocation at all
fn emit(sink: &mut dyn Sink, lines: &[&str]) {
    for l in lines { sink.write_line(l); }
}

fn main() {
    emit(&mut Stdout, &["a", "b"]);

    let mut buf = String::new();
    emit(&mut Buffer { out: &mut buf }, &["c", "d"]);
    print!("{buf}");
}

&mut dyn Sink gives runtime polymorphism with zero allocation — the fat pointer lives on the stack. Reach for &dyn / &mut dyn before Box<dyn>; the Box is only needed when the object must be owned and outlive the current frame.

Note the lifetime: Box<dyn Sink> implicitly means Box<dyn Sink + 'static>, which is why Box::new(Buffer { out: &mut buf }) would be rejected. Writing Box<dyn Sink + 'a> allows a borrowing trait object when you need one.


What Trips People Up

  • dyn Trait is unsized. let d: dyn Draw = circle; is rejected. It must be behind
  • &, Box, Rc, or Arc.

  • Object-safety errors that read like nonsense. They almost always come from -> Self,
  • self by value, or a generic method. Add where Self: Sized to exclude that method.

  • Box<dyn Trait> implying 'static. A trait object holding borrowed data needs
  • Box<dyn Trait + 'a> written out.

  • Forgetting + Send for threads. Box<dyn Fn()> cannot cross a thread boundary; you
  • need Box<dyn Fn() + Send + 'static> (Rust Concurrency and Send Sync).

  • Expecting Clone to work. dyn Trait is not Clone because clone returns Self.
  • Use the clone_box pattern or the dyn-clone crate.

  • Downcasting constantly. If you downcast_ref in more than one or two places, add a
  • method to the trait instead.

  • Assuming dyn allocates. &dyn Trait does not. Only the owning forms do.
  • Reaching for dyn in a hot loop. Try an enum instead: a closed set of types in an enum
  • gives you a heterogeneous collection and static dispatch via match, at the cost of the set being fixed at compile time. This "enum dispatch" pattern is frequently the right answer and is often overlooked.


Related

  • Rust Traits — defining the traits being erased
  • Rust Generics and Monomorphization — the static-dispatch alternative
  • Rust Smart Pointers — Box, Rc, Arc as trait-object containers
  • Rust Stack Heap and Memory Layout — the fat-pointer representation
  • Rust Structs and Enums — enum dispatch as a third option
  • Rust Performance and Zero Cost Abstractions — measuring dispatch cost properly
  • Go Interfaces — Go's interfaces are always dynamically dispatched fat pointers
  • Python Protocols and ABCs — duck typing, where everything is dynamic
  • Rust Index — full topic map

Practice lab

Build the smallest runnable Rust program that demonstrates Rust Trait Objects and Dynamic Dispatch. Add a focused test or Clippy check, trigger one relevant compiler or runtime failure deliberately, and explain the evidence used to correct it. Add an operational constraint such as concurrency, recovery, security, latency, or cost, and defend the resulting design trade-off.

Review questions

  1. What problem does Rust Trait Objects and Dynamic Dispatch solve, and what assumptions does it rely on?
  2. Which boundary or failure case is easiest to miss, and how would you expose it?
  3. What alternative design would you consider, and what trade-off would change the decision?
  4. What artifact, trace, test, or metric proves that your implementation is correct?

Completion evidence

  • A working artifact, annotated trace, or reproducible experiment
  • At least one normal case and one deliberately failing or boundary case
  • A concise explanation of the design choice and its trade-offs
  • Saved output showing how correctness was evaluated