Skip to content

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.

  • Read a generic type with a trait bound, RecordView<'a, S: RecordStorage>, and name what S is 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: Trait generics) 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: RecordStorage constraint that says what S can 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.

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: Trait bounds (this lesson): generic code: the compiler stamps out a separate, specialised copy of it for each concrete T you 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.

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.

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:

rust // editable

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

rust // editable
💡 Hint 1
Inside a generic function you may call only what the bound promises. 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.

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.

rust // editable
💡 Hint 1
The signature carries the trait bound: <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 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?

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.