Reading Rust

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.

45 minPOEcontrasting casesbeacon training
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

Predict firstcommit in writing, then look
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]));
}

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 atCompile timeRun time
Call costDirect, inlinableIndirect, not inlinable
PointerThinFat (data + vtable)
Binary sizeOne copy per typeOne copy total
Compile timeGrows with instantiationsFlat
Heterogeneous collectionsImpossibleThe reason it exists
Trait requirementsAny traitMust 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 need Box<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.
Beaconsee this β†’ think this, without decoding it

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

Predict firstcommit in writing, then look
// 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 } }
Explain it to yourselfwrite it β€” thinking it does not count

Your team is choosing between these two designs for a plugin system:

fn run<P: Plugin>(p: P)              // A
fn run(p: &dyn Plugin)               // B

There 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 Trait is 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 Trait in 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: Sized excludes a method from it.

Next: 3.4 β€” Where does this method come from?

In the wildgo read the real thing now, while it is fresh
  • cargo-bloatRun it on any real project. It shows which generic functions were instantiated most and how much binary each produced. Nothing makes monomorphisation concrete faster than seeing one function occupy 3% of your binary.
  • The Rust Reference β€” dyn compatibilityThe normative rules, and note the name: this was called "object safety" until Rust 1.84. If someone says object safety they mean this; the rename happened because neither "object" nor "safety" meant what they sounded like.
Scheduled for recall4 items added to your review queue

These are not shown to you now on purpose. They will surface in Review tomorrow, mixed in among items from other parts of the course β€” not grouped by topic. Sorting out which idea applies is the part that transfers.