Skip to content
academia.sh

Lesson 04 / 15

Object Safety and Dynamic Dispatch

A call written with a trait bound resolves at compile time; a call written through a trait object (dyn) resolves at run time — in the measurement kernel both run twice; the difference is not in the count, it is in how the call is found, and this is only possible for traits that satisfy the object safety condition.

Published Updated

In this lesson

  • Distinguishes the syntax of static and dynamic dispatch (a trait bound vs. a dyn trait object)
  • Compares the two dispatch forms' run counts to show the count does not change
  • Describes the object safety condition in prose and demonstrates an accepted design that satisfies it by running it
Contents

The Associated Types lesson showed that both ways of using a contract (associated type, generic parameter) are resolved at compile time. There is a third way to use a trait, and it is resolved not at compile time but at run time: the trait object. The kernel that ran in the course’s first lesson already had this distinction — static_dispatch<T: Label> and dynamic_dispatch(x: &dyn Label) did the same job in two different call forms. This lesson opens up that difference: whenever the call is resolved, how many times does the body run?

Two Call Forms

The static_dispatch<T: Label>(x: &T) syntax is familiar from the previous two lessons: T is a type parameter, bound to Label. The compiler generates this function separately for every concrete type it is called with — as the Trait Bounds lesson showed. The dynamic_dispatch(x: &dyn Label) syntax is different: x’s type is no longer a type parameter, it is dyn Label — “a reference to any value implementing Label, whose type will be determined at run time.” The dyn keyword is how you tell the compiler “I do not yet know the concrete type behind this reference, but I know I can call Label’s methods on it.”

These two forms exist because they answer different questions. Static dispatch generates a separate body for every concrete type called — this generation happens at compile time, as the Trait Bounds lesson showed, and never enters the run ledger. But this generation carries a cost too: when a function writes <T: Label>, every place that calls that function has to know, at compile time, which concrete T is used. If a list, a field, or a return value wants to say “could be either Plain or Custom,” static dispatch cannot express this — a type parameter has to resolve to a single concrete type. Dynamic dispatch fills exactly this gap: dyn Label, by saying “it does not matter which concrete type it is, as long as it implements Label,” makes it possible to hold different types in the same place. This lesson shows that the two do not replace each other, they complete each other.

These two notations also differ at the binary level. When static_dispatch is called, the compiler reads which method will run directly from the source and embeds it into the call. When dynamic_dispatch is called, x has to carry along a method table — a trait object is actually made of two parts: a reference to the value itself, and a table holding the addresses of methods belonging to that value’s concrete type. Which body runs is found from this table at call time; the single line written in the source (x.name()) can open onto two different bodies depending on the type behind x.

Box<dyn Label> is built from two words, and both were already introduced in a separate course. Box is a container that establishes single ownership on the heap: it places the value it holds on the heap and keeps a single owner. dyn Label does not name which concrete type sits inside this container, it only says which contract it satisfies. Combined, the resulting Box<dyn Label> means “a value sitting on the heap, implementing Label, but whose exact concrete type has been forgotten.” This forgetting is deliberate: Box<Plain> and Box<Custom> are two different types to the compiler and cannot go in the same array; Box<dyn Label> can go in the same array because it is the common denominator of both.

The Run Count Does Not Change

// times.rs — slice: Label, Plain, Custom for static and dynamic dispatch.

use std::cell::RefCell;
use std::collections::BTreeMap;
use std::rc::Rc;

pub type Ledger = Rc<RefCell<BTreeMap<String, u32>>>;

pub fn record(ledger: &Ledger, name: &str) {
    *ledger.borrow_mut().entry(name.to_string()).or_insert(0) += 1;
}

pub fn times(ledger: &Ledger, name: &str) -> u32 {
    *ledger.borrow().get(name).unwrap_or(&0)
}

pub trait Label {
    fn ledger(&self) -> &Ledger;
    fn name(&self) -> String {
        String::from("default")
    }
}

pub struct Plain {
    pub ledger: Ledger,
}

impl Label for Plain {
    fn ledger(&self) -> &Ledger {
        &self.ledger
    }
}

pub struct Custom {
    pub ledger: Ledger,
}

impl Label for Custom {
    fn ledger(&self) -> &Ledger {
        &self.ledger
    }
    fn name(&self) -> String {
        String::from("custom")
    }
}

pub fn static_dispatch<T: Label>(x: &T) -> String {
    record(x.ledger(), "static-dispatch");
    x.name()
}

pub fn dynamic_dispatch(x: &dyn Label) -> String {
    record(x.ledger(), "dynamic-dispatch");
    x.name()
}
// dispatch.rs — compares the run count of static and dynamic dispatch.
mod times;
use times::{times, record, Ledger, Label, Custom, Plain, dynamic_dispatch, static_dispatch};
use std::cell::RefCell;
use std::collections::BTreeMap;
use std::rc::Rc;

fn main() {
    let ledger: Ledger = Rc::new(RefCell::new(BTreeMap::new()));
    let plain = Plain { ledger: Rc::clone(&ledger) };
    let custom = Custom { ledger: Rc::clone(&ledger) };

    let _ = static_dispatch(&plain);
    let _ = static_dispatch(&plain);

    let _ = dynamic_dispatch(&plain as &dyn Label);
    let _ = dynamic_dispatch(&custom as &dyn Label);

    // The same trait object can hold different concrete types in one list - static
    // dispatch could not do this, because a Vec<T> is locked to one concrete T.
    let list: Vec<Box<dyn Label>> = vec![
        Box::new(Plain { ledger: Rc::clone(&ledger) }),
        Box::new(Custom { ledger: Rc::clone(&ledger) }),
        Box::new(Custom { ledger: Rc::clone(&ledger) }),
    ];
    // The loop itself knows how many elements there are, but not which body will
    // run on each call - that is resolved inside name() based on the element's real type.
    let names: Vec<String> = list
        .iter()
        .map(|e| {
            record(e.ledger(), "list-body");
            e.name()
        })
        .collect();

    let mut bodies: BTreeMap<&str, u32> = BTreeMap::new();
    bodies.insert("static-dispatch", times(&ledger, "static-dispatch"));
    bodies.insert("dynamic-dispatch", times(&ledger, "dynamic-dispatch"));
    bodies.insert("list-body", times(&ledger, "list-body"));

    println!("{:<18}{}", "body", "how many times");
    for (name, n) in &bodies {
        println!("{:<18}{}", name, n);
    }
    println!("list-names={:?}", names);
}
body              how many times
dynamic-dispatch  2
list-body         3
static-dispatch   2
list-names=["default", "custom", "custom"]

TG9. static-dispatch ran twice, dynamic-dispatch also twice — the exact same number. The difference is not in the count: both call forms trigger the body unavoidably, both fall into the every time class. The Object-Oriented Java course measured which type made the decision — the declared type or the runtime type. What’s measured here is not that question; what’s measured is whether the two dispatch forms change the body count — they do not. No matter how many times Plain’s and Custom’s name methods are called, whether the call form is static or dynamic never increases or decreases this number.

The list-body row shows the practical consequence of this: Vec<Box<dyn Label>> can hold different concrete types (Plain and two separate Customs) in a single collection — the Box built in the Ownership and the Memory Model course’s Heap-Allocated Single Ownership lesson, here holds a trait object inside it. static_dispatch<T: Label> could not do this: a Vec<T> is locked to a single concrete T, it cannot hold Plain and Custom together in the same array. The three-element list’s body ran three times too — this also falls into the every time class, because the conversion is unavoidable for every element of the array. What’s worth noting is that the closure inside map never knows e‘s real type at all: the e.name() call does not say which body will run until reaching the list’s third element — only that element’s own method table knows. This shows that even though the two call forms’ run count is equal, they do not do the same job — a trait object can carry diversity inside a collection, static dispatch cannot.

Object Safety

Whether dyn Label compiles depends on Label being object safe. If a trait is not object safe, a method table cannot be built for it, and the dyn syntax is rejected by the compiler. There are two common obstacles: if a method returns Self by value (something like fn copy(&self) -> Self), the table cannot know that method’s return size in advance — because the concrete type behind dyn Label can change at run time, and every type’s size can differ. If a method carries its own type parameter (something like fn convert<U>(&self, u: U)), the table cannot hold a separate entry for infinitely many Us. Both of these forms, described in prose here, are rejected — since they do not compile, they are not put in a code block.

Both obstacles share the same root cause: every entry in a method table has to be a fixed, known-size set of pointers at call time. If a method that returns Self by value were put in the table, the caller could not answer, at compile time, the question “how much space will this call’s return value take up” — because behind dyn Label there could be Plain, or there could be a type of another size entirely. A method carrying its own type parameter does not fit into the table as a single entry either, because a separate body is needed for every different U, and the table cannot enumerate infinitely many Us in advance. Both obstacles come down to the same principle: a trait object is a reference that has forgotten the identity of the implementing type; if a method depends on that forgotten identity (on Self’s exact size, or on a U the caller chooses), the table cannot hold that method.

Label carries neither obstacle: ledger and name both take &self (not Self by value), neither carries its own type parameter. This is why it is object safe, and the dynamic_dispatch function above, with its &dyn Label parameter, compiles without a problem — the accepted design’s counterpart has already been run. Deciding which methods you want to be usable as a trait object when writing a trait means shaping those methods’ signatures around these two rules from the start.

This condition sits in a place similar to the associated type constraint from the previous lesson: both show that a contract might not be compatible with every way of using it. The associated type constraint limited how many times a type could implement a contract; object safety limits which call forms a contract can be used with. Label itself is not affected by either, because it was kept simple from the start: it does not need a single associated type, and it stays limited to object-safe methods. A trait writing a broader contract could run into one or both of these constraints; which one it runs into can be read from the trait’s signature, before ever running it.

For example, a method that compares two values and returns the larger one by value — fn larger_of(&self, other: Self) -> Self — would not be object safe: Self appears here as both a parameter and a return type, and since the real type behind dyn Label can change at run time, the table cannot fix this method’s input and output size in advance. This method was never added to Label; if it had been, &dyn Label would not compile. The same comparison request could stay object safe by returning not a value but a reference, or an ordering result (something fixed-size, like less, equal, greater) — and this ties into this course’s general observation too: a contract’s boundaries are drawn not by the caller of that contract, but by the table that is forced to work with the implementer’s forgotten identity.

The Measurement’s Limit

The same count does not mean the same cost. static-dispatch and dynamic-dispatch both running twice does not mean the two call forms are identical at run time — the count only measures how many times the body was triggered, not how the call was found. In a static call, which body will run is determined by reading the source at compile time; in a dynamic call, the same information is looked up from a method table at run time. Whether this lookup itself is a step or not is a question the run counter cannot measure, because the counter only counts how many times the record call passed, not how many steps were taken to reach that call. This question was already addressed in the Object-Oriented Java course; this lesson does not ask it again, it only shows that the two forms leave the body count equal.

This distinction has a concrete consequence: you cannot answer “does this source use dynamic dispatch or static dispatch” just by looking at the run ledger. When the static-dispatch and dynamic-dispatch rows sit side by side, both being two could lead a reader to a wrong conclusion — “if they are doing the same thing, it does not matter which I choose.” But it does matter: the list-body row could only exist because the dyn Label form satisfies the object safety condition; the same list could never be built with the bound static uses. Even though the run count does not distinguish the two forms, which notations are possible still diverges along a separate axis — this lesson shows that axis is seen in the code itself, not in the count.

The course’s first four lessons each asked, in turn, whether everything placed around a body — a default body, a bound, an associated type, a call form — touches the run count. The answer came out the same all four times: it does not. However the body is found (from a type parameter or from a method table), it runs unavoidably everywhere it is called, and this is independent of whichever syntax decision is layered on top of it.

Summary

  • Static dispatch (<T: Label>) resolves at compile time, dynamic dispatch (&dyn Label) resolves at run time from a method table.
  • Both forms fall into the every-time class and give the exact same run count: two and two — the call form does not change how many times the body is triggered.
  • A trait object (Box<dyn Label>) can hold different concrete types in a single collection; static dispatch cannot, because a type parameter locks to a single concrete type.
  • Object safety is the condition required for a trait to be usable with dyn: methods that return Self by value or carry their own type parameter break this condition.
  • The same run count does not mean the same cost; whether the call is found at compile time or at run time is a separate question that cannot be measured by the run counter.

Next Step

In this lesson, the body itself was always hand-written — the name method’s body came either from the trait’s default or from Custom’s own writing. A body can also be produced without ever being hand-written. The next lesson shows how a body whose lines never appear in the source runs at run time, and why this running cannot be counted by the run counter.

To keep your progress and take notes, Log in

My notes

Log in to take notes.

Start typing to search.

↑↓ ↵ Esc navigate · open · close