Part 3 Β· The compile-time machine II β types & traits
Dispatch and monomorphisation
Where constraints become machine code. This lesson explains Rust build times, binary sizes, and `dyn` β which are all the same fact.
What this lesson is trying to break (4)
- Generics are erased, like Java or TypeScript.
- `dyn Trait` is what generics compile to.
- `impl Trait` means the same thing wherever it appears.
- Dynamic dispatch is the slow one, so avoid it.
Lesson 3.2 said a trait is a constraint. This lesson is what the compiler does with the constraint, and it is where Rustβs most-discussed engineering trade-offs come from.
Monomorphisation
fn largest<T: PartialOrd>(items: &[T]) -> &T {
let mut best = &items[0];
for x in items { if x > best { best = x; } }
best
}
fn main() {
println!("{}", largest(&[1i32, 5, 3]));
println!("{}", largest(&[1.5f64, 0.2]));
println!("{}", largest(&["a", "z"]));
println!("{}", largest(&[10i32, 20]));
}Three. One for i32, one for f64, one for &str. The two i32 calls share
a copy.
The compiler generates them by substitution, as though you had written:
fn largest_i32(items: &[i32]) -> &i32 { /* ... */ }
fn largest_f64(items: &[f64]) -> &f64 { /* ... */ }
fn largest_str(items: &[&str]) -> &&str { /* ... */ }This is monomorphisation β turning one polymorphic function into many monomorphic ones.
If you said βone, generics are erasedβ, that is Java and TypeScript. Java erases
to Object and inserts casts; TypeScript erases entirely. Rust does neither: it
duplicates.
Everything downstream follows from this.
It is fast. Each copy knows its concrete type, so x > best is a direct
comparison with no indirection, and the whole function can be inlined into the
caller. Generic Rust runs exactly as fast as the hand-written version, which is
the actual meaning of βzero-cost abstractionβ: you could not have done better by
hand.
It is expensive to compile. The compiler does real work per instantiation, and generic code in a heavily-used library multiplies fast. When someone complains that Rust builds slowly, monomorphisation is a large part of the answer β along with LLVM optimising every one of those copies separately.
It makes binaries large. A generic function used with forty types produces
forty bodies. cargo bloat will show you exactly this, and running it once on a
real project is worth more than reading about it.
It cannot be stored heterogeneously. Vec<T> holds one T. If you need a
vector of different types that share a trait, monomorphisation cannot help β
each would be a different type and a different Vec.
That last constraint is what forces the other mechanism to exist.
Dynamic dispatch
let shapes: Vec<Box<dyn Draw>> = vec![
Box::new(Circle { r: 1.0 }),
Box::new(Square { s: 2.0 }),
];
dyn Draw is a type β the one built from the trait. It is dynamically sized
(1.2), so it lives behind a pointer, and
that pointer is fat:
Box<dyn Draw>
ββββββββββββββββ¬βββββββββββββββ
β data pointer β vtable ptr β
ββββββββ¬ββββββββ΄βββββββ¬ββββββββ
β β
βΌ βΌ
the Circle ββββββββββββββββββββ
(heap) β drop_in_place β
β size, align β
β draw() β β the trait's methods
ββββββββββββββββββββ
Calling .draw() loads the function pointer from the vtable and calls through
it. One extra indirection, and β usually more important β the call cannot be
inlined, which also blocks the optimisations inlining would have enabled.
Varies: only how the trait is used β the trait and the types are identical
Static (impl Trait / generics) | Dynamic (dyn Trait) | |
|---|---|---|
| Chosen at | Compile time | Run time |
| Call cost | Direct, inlinable | Indirect, not inlinable |
| Pointer | Thin | Fat (data + vtable) |
| Binary size | One copy per type | One copy total |
| Compile time | Grows with instantiations | Flat |
| Heterogeneous collections | Impossible | The reason it exists |
| Trait requirements | Any trait | Must be dyn-compatible |
Note that dynamic dispatch wins two rows outright. βStatic is the fast oneβ is too crude β static is faster per call and worse for build times and binary size.
The honest summary for a design discussion: the per-call cost of dyn is
usually irrelevant, and the compile-time and binary-size cost of generics is
usually not. An indirect call is a couple of nanoseconds. Teams reach for dyn
in application code β error types, plugin registries, trait-object collections β
and reserve generics for hot paths and library APIs. If you take one
tech-lead-usable opinion from this lesson, that is it.
impl Trait means two different things
This trips people up because the syntax is identical and the meaning is nearly opposite.
fn print_all(items: impl IntoIterator<Item = String>) { } // argument position
fn make() -> impl Iterator<Item = u32> { 0..10 } // return position
In argument position, impl Trait is sugar for an anonymous generic
parameter. It is the same as fn print_all<I: IntoIterator<Item = String>>(items: I).
The caller chooses the type.
In return position, it is an opaque type. The function returns one specific concrete type, but the caller is not told which β only that it satisfies the bound. The function chooses the type.
The return-position form exists because iterator and closure types are
unnameable. 0..10 mapped by a closure has a type like
Map<Range<u32>, {closure@src/main.rs:3:14}>, which you cannot write down. impl Iterator lets you return it without naming it, with no boxing and no dynamic
dispatch.
Two consequences worth knowing when reading:
- You cannot return two different concrete types from a function returning
impl Trait, even in different branches β there is one hidden type, not a choice. If you need branches, you needBox<dyn Trait>. - It is an API commitment. The caller cannot name the type, so you can change it freely β but they can rely on the traits you named, so adding is safe and removing is breaking.
-> impl Future<Output = T> + Send + 'static
An async function whose future must be spawnable. The Send means it can
move between threads; the 'static means it borrows nothing from the callerβs
frame (2.4).
You will see this constantly in async trait definitions and in anything built on
tokio::spawn. When a bound like this fails to hold, the error is usually about
something non-Send held across an .await β a Rc, or a RefCell borrow, or
a MutexGuard from std rather than tokio.
Dyn compatibility
Not every trait can become a dyn Trait. The trait must be dyn-compatible,
which until Rust 1.84 was called object safety β you will hear both, and
knowing the rename is current is a small credibility marker.
The rules exist for one reason: a vtable must have a fixed layout, decided once, without knowing the concrete type. Anything preventing that is disqualifying.
// A
trait Draw { fn draw(&self); }
// B
trait Container { fn add<T>(&mut self, item: T); }
// C
trait Cloneable { fn duplicate(&self) -> Self; }
// D
trait Named { fn name(&self) -> String; fn id(&self) -> u64 { 0 } }A and D are dyn-compatible. B and C are not.
- B has a generic method. Monomorphisation would need one vtable entry
per
T, and the set ofTs is not known when the vtable is built. Infinite slots required; impossible. - C returns
Self. Different implementors return different sizes, so the caller β who only knowsdyn Cloneableβ cannot allocate space for the result. - D is fine. A default method still gets a vtable slot; there is nothing type-dependent about it.
The standard workaround for both is where Self: Sized:
trait Container {
fn add<T>(&mut self, item: T) where Self: Sized;
fn len(&self) -> usize;
}That marks add as not dispatchable β it is excluded from the vtable and
callable only on concrete types. The rest of the trait becomes usable as dyn.
This is exactly how Iterator manages to be dyn-compatible despite having
methods like map and collect that are generic: they all carry
where Self: Sized.
Your team is choosing between these two designs for a plugin system:
fn run<P: Plugin>(p: P) // A
fn run(p: &dyn Plugin) // BThere will be roughly 30 plugin types, each invoked a handful of times per request. Write the recommendation you would give in a design review β two or three sentences, with the reason, and name the cost you are accepting.
What you should now be able to say
- Monomorphisation duplicates generic code per concrete type. That single fact explains Rustβs speed, its build times, and its binary sizes.
dyn Traitis a type, dynamically sized, behind a fat pointer carrying a vtable.- Dynamic dispatch costs an indirect, non-inlinable call β and saves compile time and binary size.
impl Traitin argument position: caller picks. In return position: function picks, and the type is opaque.- Dyn compatibility (formerly object safety) is about whether a fixed
vtable layout is possible.
where Self: Sizedexcludes a method from it.