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 returnSelfby 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.