Generics & monomorphization
Last lesson, the IO seam chose dynamic dispatch: a Box<dyn FormatReader> resolved
through a vtable, because the format is unknown until run time and the per-call cost is noise
against file IO. This lesson is the same question — “how does this code call into a type it
doesn’t name?” — answered the opposite way, for the opposite reason. The field-read path
runs on every cell of every row, so it uses generics and pays zero dispatch cost.
You’ll be able to: read a generic type with a trait bound, explain what
monomorphization does at compile time, and say why RecordView is generic over its storage
rather than holding a dyn RecordStorage.
Two ways to be generic over behaviour
Section titled “Two ways to be generic over behaviour”A trait says what a type can do. There are two ways to write code against “any type that can do this”:
dyn Trait(last lesson) — one machine-code copy that dispatches through a vtable at run time. Flexible, slightly slower per call, type chosen at run time.<T: Trait>(this lesson) — the compiler stamps out a separate, specialised copy of the code for each concreteTyou actually use. Calls are direct and inlinable; the type is fixed at compile time. This stamping-out is monomorphization.
Same trait, two dispatch strategies, opposite trade-offs. Clinker uses each exactly where it fits.
The hot path: RecordView over RecordStorage
Section titled “The hot path: RecordView over RecordStorage”Phase 2 introduced RecordView as the zero-copy field reader. Look now at its signature —
it is generic over the storage it reads through:
clinker-record ·record_view.rs ·RecordView type @47d2e12
/// Zero-allocation view into an arena-backed record.////// 16 bytes: pointer + u64 index. `Copy` and stack-allocated.#[derive(Clone, Copy)]pub struct RecordView<'a, S: RecordStorage + ?Sized> { storage: &'a S, index: u64,}S: RecordStorage is a trait bound: S can be any type that implements RecordStorage,
and inside RecordView you may call only what that trait promises. The trait itself is four
methods — resolve a field, resolve a qualified field, list fields, count records:
clinker-record ·storage.rs ·RecordStorage trait @47d2e12
pub trait RecordStorage: Send + Sync { fn resolve_field(&self, index: u64, name: &str) -> Option<&Value>; fn resolve_qualified(&self, index: u64, source: &str, field: &str) -> Option<&Value>; fn available_fields(&self, index: u64) -> Vec<&str>; fn record_count(&self) -> u64;}Because RecordView is generic, the production build monomorphizes it into
RecordView<'_, Arena> — a concrete type whose resolve calls Arena::resolve_field
directly. No vtable, no pointer-chase, and the call is small enough to inline. On a path
that runs once per field per record, that’s the difference that matters.
Monomorphization, made visible
Section titled “Monomorphization, made visible”Here’s the compile-time stamping-out in miniature. One generic function; two concrete types;
the compiler produces a specialised copy of first_field for each:
> output appears here — press Run
After compilation there is no single first_field that “figures out” which Storage it has.
There are two functions, each calling one concrete field directly. That’s why generic code
can be both abstract in the source and free of dispatch cost in the binary — you write it
once, the compiler writes it N times.
The cost, and why it’s worth paying here
Section titled “The cost, and why it’s worth paying here”Monomorphization isn’t free: each concrete instantiation is a separate copy of the machine
code, so over-generic code can bloat the binary and slow compiles. The discipline is to use
generics where the type set is small and known at compile time and the call is hot —
exactly the field-read path, which has a couple of storage backends and runs astronomically
often. The engine confirms it never wants the dynamic form here: there is no
dyn RecordStorage anywhere in the codebase. Storage is always a concrete S.
And the view stays tiny. RecordView is a borrow plus an index — a Copy, 16-byte,
stack-only handle — pinned by a test so it can never silently grow:
clinker-record ·resolver.rs ·test_record_view_size test @47d2e12
#[test]fn test_record_view_size() { assert_eq!(std::mem::size_of::<RecordView<'_, DummyStorage>>(), 16, /* … */); // Verify Copy by assignment let view = RecordView::new(&storage, 0); let _copy = view; // Copy let _ = view; // still usable — proves it's Copy, not move}The same test doubles as a Copy proof: it uses view after let _copy = view, which only
compiles because copying a RecordView leaves the original intact. The generic S is the
test’s lever for swapping the real Arena for a lightweight DummyStorage — generics make
the type testable as well as fast.
The ?Sized footnote
Section titled “The ?Sized footnote”The bound is written S: RecordStorage + ?Sized. The ?Sized relaxes the usual “every
generic type has a known size” rule, leaving the door open for an unsized S (such as a
trait object behind the &'a S reference). In practice the engine always monomorphizes over a
concrete, sized Arena, so this is headroom, not a feature in use — but it’s a good example of
how a bound can be loosened deliberately. You don’t need to reach for it yet.
// quick check
Why is the field-read path generic over S: RecordStorage instead of holding a dyn RecordStorage?
With a small, compile-time-known set of storage types and a call that runs per field per record, monomorphizing to a concrete RecordView makes resolve a direct, inlinable call. A vtable indirection would be real cost here, unlike on the IO seam.
Pin it down
Section titled “Pin it down”You now have both halves of “calling into a type you don’t name”: dynamic dispatch for open,
run-time-chosen seams, and generic monomorphization for closed, hot paths. Next we change
subject from dispatch to proof: how a tiny struct wrapper can make an entire class of
bug impossible to write.