Skip to content

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.

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 concrete T you 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.

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:

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

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.