One reader, every format — generics
The engine’s field-read path runs on every cell of every row: millions of calls into a
storage backend the code doesn’t name. It cannot afford the per-call indirection the IO seam
shrugged off, so it reaches for the opposite dispatch strategy and pays zero runtime
cost. You met the first answer to “how does this code call into a type it doesn’t name?” in
The IO seam: a Box<dyn FormatReader> resolved through a vtable,
chosen because the format is unknown until run time. This lesson is the same question answered
the other way, for the opposite reason. The Rust tool underneath is generics with trait
bounds; we use just enough to read RecordView’s real signature, and
The Rust Book, ch. 10 is the canonical
treatment if you want generics from first principles.
What you’ll be able to do
Section titled “What you’ll be able to do”- Read a generic type with a trait bound,
RecordView<'a, S: RecordStorage>, and name whatSis constrained to and what the bound lets you call. - Explain what monomorphization does at compile time, and why it makes a generic call direct and inlinable with no vtable.
- Distinguish static dispatch (
T: Traitgenerics) from the previous lesson’s dynamic dispatch (dyn Trait), and say which trade-off each makes and when each fits. - Write a generic function with a trait bound and trace the separate, specialised copies the compiler stamps out for it.
New terms in this lesson (each is also expanded inline at the point you first need it):
- generic: code written once, reused for many concrete types.
- type parameter, the placeholder type (the
S) the compiler fills in per use. - trait bound, the
S: RecordStorageconstraint that says whatScan do. - monomorphization: the compiler stamping out one specialised copy per concrete type.
- static dispatch: compile-time-resolved, direct, inlinable calls (the opposite of
dyn’s dynamic dispatch). - trait object (
dyn RecordStorage): last lesson’s dynamic form, contrasted here.
Predict first
Section titled “Predict first”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(the IO seam): one machine-code copy that dispatches through a vtable at run time. Flexible, slightly slower per call, type chosen at run time. That’s a trait object.T: Traitbounds (this lesson): generic code: the compiler stamps out a separate, specialised copy of it for each concreteTyou actually use. Calls are direct and inlinable; the type is fixed at compile time. This stamping-out is monomorphization, and the resulting compile-time-resolved calls are static dispatch.
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”Data & Representation 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 @19acdcb4
/// 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 is a type parameter,
a placeholder the compiler fills in per use, and S: RecordStorage is its
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 @19acdcb4
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.
Worked → completion → faded
Section titled “Worked → completion → faded”You just read the generic seam. Now you build one, scaffolded down to writing the whole thing yourself. Each rung adds one idea: first see monomorphization made visible; then supply one missing trait bound; then write a whole generic function and its driver from a skeleton.
Worked: monomorphization made visible
Section titled “Worked: monomorphization made visible”Here’s the compile-time stamping-out in miniature. One generic function, two concrete types,
and the compiler produces a specialised copy of first_field for each. Run it and notice one
source function driving two unrelated structs:
> 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: exactly the two copies
you predicted. 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.
Completion: supply the missing trait bound
Section titled “Completion: supply the missing trait bound”Below, the trait, two implementors, and main are done. The generic function record_count
is missing only its trait bound, the <S: …> part. Without it the compiler can’t know
that S has a len method, so the body won’t compile. Fill in the bound so record_count
may call s.len().
> output appears here — press Run
💡 Hint 1
s.len() is a Storage method, so S must be constrained to Storage. The syntax is <S: TraitName>.Show solution
fn record_count<S: Storage>(s: &S) -> u64 { s.len()}The bound S: Storage is what unlocks s.len(). It’s the compiler’s proof that every
concrete S you pass has that method. Drop the bound and the call is rejected at the
definition, before any concrete type is even chosen. That up-front checking is exactly what
makes monomorphization safe: each stamped-out copy is guaranteed to type-check.
Faded: write the whole generic function
Section titled “Faded: write the whole generic function”Now with no body given. Define a generic function first_two<S: Storage>(s: &S) -> (u64, u64)
that calls s.cell(0) and s.cell(1) and returns them as a pair, driving any Storage. The
trait, both implementors, and main (which asserts the expected output) are written for you.
> output appears here — press Run
💡 Hint 1
<S: Storage>. The body just calls the two cell reads and returns them as a tuple, and the compiler stamps out one specialised copy for Arena and one for Doubler.Show solution
fn first_two<S: Storage>(s: &S) -> (u64, u64) { (s.cell(0), s.cell(1))}You wrote the generic seam end to end: one function, written once against Storage, driving
two unrelated structs the compiler specialises it for. Scale first_two up to RecordView’s
field reads, swap your two structs for the real Arena, and you have the monomorphized hot
path: direct, inlinable calls, no vtable. The shape does not change.
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.
That’s 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 @19acdcb4
#[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. Still, 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.
Retrieval checkpoint
Section titled “Retrieval checkpoint”Connections
Section titled “Connections”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,
Proof tokens, we change subject from dispatch to proof. A
tiny struct wrapper can make an entire class of bug impossible to write.
Go deeper on the Rust (optional, one-directional, for generics and trait bounds taught from first principles in The Rust Book):
Glossary terms used: generic, type parameter, trait bound, monomorphization, static dispatch.