The Value cell — nine shapes, 32 bytes
Every cell of every record in Clinker is a single Value. A run over a million rows
builds a million of them, so two questions about this one type ripple through the whole
engine: what shapes can a cell take, and what does one cost in memory? You met Value
in passing while tracing a record end-to-end, as “a cell, one of
nine shapes.” This is the deep pass: why that set of nine is closed, and why it’s
pinned at exactly 32 bytes by a test. Both are deliberate engineering. The Rust tool
underneath is the enum; we use just enough of it to read the real type, and
The Rust Book, ch. 6 is the canonical
treatment if you want enums from first principles.
What you’ll be able to do
Section titled “What you’ll be able to do”- Name each variant of the real
Valueenum and say which carry data and which are bare. - Explain why
Valueis a closed sum type, and what that closedness buys CXL and the planner. - Predict whether the compiler will let you construct a
Valuethat isn’t one of the declared variants. - Trace why one boxed variant pins the whole enum at 32 bytes, and write a small enum whose size you can read off with
size_of.
New terms in this lesson (each is also expanded inline at the point you first need it):
enum: a type whose value is exactly one of a fixed, named, closed set of kinds.- variant: one of those kinds; unit (no payload, like
Null) or carrying typed data (likeInteger(i64)). - sum type: the precise name for “a choice between alternatives”: one variant or another, never several at once.
Value: Clinker’s own enum for a single cell of a record. This whole lesson is about it.
Predict first
Section titled “Predict first”A closed set of nine
Section titled “A closed set of nine” clinker-record ·value.rs ·Value type @19acdcb4
pub enum Value { Null, Bool(bool), Integer(i64), Float(f64), String(FieldStr), Date(NaiveDate), DateTime(NaiveDateTime), Array(Vec<Value>), Map(Box<IndexMap<Box<str>, Value>>),}Read the variants as two groups. Null is a unit variant: a name with no payload.
The other eight are variants that carry data: Integer(i64) holds a 64-bit integer,
Bool(bool) a boolean, Array(Vec<Value>) a list of further Values, and so on. A
Value is exactly one of these at a time, and that is what makes it a
sum type:
a cell is an integer or a string or a map, never two at once, and never a kind
not on this list.
That closedness is the engineering point, not an accident of syntax. The compiler knows the complete list; there is no tenth shape, and there can never be one without editing this declaration. Clinker’s expression language, CXL, runs a formula over every record, and CXL can be typechecked before a job runs (a typo fails at plan time, not mid-file) precisely because the set of value shapes is fixed and known in advance. The closed enum is that shared, finite vocabulary; the language, the format layer, the planner, and the runtime all rely on it.
Why 32 bytes, and why a test guards it
Section titled “Why 32 bytes, and why a test guards it”A Record is a Vec<Value>. Every cell of every row is a Value, so the size of one
Value multiplies across all your data. An enum is as wide as its largest variant
(plus a small tag), so a single bloated variant would tax every cell.
Look closely at the last variant:
Map(Box<IndexMap<Box<str>, Value>>),An IndexMap is a big struct. If it were stored inline, every Value, even a plain
Integer, would be as wide as a map. So the map is Boxed: the variant holds just
an 8-byte pointer, and the enum’s width is set by the genuinely small variants instead.
The result is pinned by a test:
clinker-record ·value.rs ·test_value_enum_size test @19acdcb4
#[test]fn test_value_enum_size() { assert_eq!(std::mem::size_of::<Value>(), 32);}Value is 32 bytes, and that test fails the build if a change makes it bigger. Size
is treated as a contract, not an accident.
Worked → completion → faded
Section titled “Worked → completion → faded”You now read the real Value. Here you build the same machinery yourself, scaffolded
down to an unaided task. Each rung adds exactly one idea.
Worked: construct each variant and read the size
Section titled “Worked: construct each variant and read the size”Here is a small, self-contained enum that mirrors Value’s structure: a unit variant,
a couple of data-carrying variants, and one boxed variant. Run it as given. The match
shows that a value is exactly one variant; the size_of line shows the boxing trick from
above, on an enum small enough to reason about whole.
> output appears here — press Run
Completion: add the missing variant
Section titled “Completion: add the missing variant”Below, Cell is missing a Bool variant, and describe is missing the arm that handles
it. Two arms are written for you. Add the Bool(bool) variant to the enum, then add the
one match arm that names it; the compiler will refuse to build until every variant is
handled.
> output appears here — press Run
💡 Hint 1
Name(Type): declare Bool(bool) alongside Integer(i64). Its match arm binds the payload but can ignore it: Cell::Bool(_) => "...",. Once it builds, uncomment Cell::Bool(true) in main to see it run.Show solution
#[derive(Debug)]enum Cell { Null, Integer(i64), Bool(bool),}
fn describe(cell: &Cell) -> &'static str { match cell { Cell::Null => "absent", Cell::Integer(_) => "a number", Cell::Bool(_) => "a boolean", }}The closed set of variants and the exhaustive match work together: add a variant and the
compiler forces you to handle it everywhere, so the new shape can’t be silently dropped.
That is the same guarantee the real Value gives the whole engine, and it’s the subject of
the next lesson.
Faded: model the boxing decision yourself
Section titled “Faded: model the boxing decision yourself”Now with much less scaffolding. Define an enum Payload with three variants:
Flag(bool): a tiny variant.Number(i64): a small variant.Blob(Box<[u8; 4096]>): a large buffer that you mustBoxso it doesn’t bloat the whole enum.
Then print size_of::<Payload>(). The skeleton and the experiment are below; write the
enum and confirm the size stays small because the big buffer lives behind a pointer.
> output appears here — press Run
Show solution
use std::mem::size_of;
enum Payload { Flag(bool), Number(i64), Blob(Box<[u8; 4096]>), // boxed: the variant is just an 8-byte pointer}
fn main() { println!("size of Payload: {} bytes", size_of::<Payload>()); // Boxed, Payload is small (a tag + a pointer-sized field, 16 bytes on a 64-bit target). // Unboxed — Blob([u8; 4096]) — it would be just over 4 KB: every Flag and Number // would carry the cost of the largest variant.}This is exactly the decision the engine made on Map(Box<IndexMap<…>>): box the one
genuinely large variant so every cell, including a bare Null or a plain Integer,
stays small. You just reproduced the reasoning behind the 32-byte contract.
See it on the real shape
Section titled “See it on the real shape”Back to the engine’s own enum. The boxing trick you just modeled is why this runs small:
> output appears here — press Run
// quick check
Why is the Map variant wrapped in a Box?
An enum is as wide as its largest variant. Boxing the map shrinks that variant to a pointer, so the enum stays 32 bytes and a plain Integer cell doesn't pay for a map it doesn't hold.
Retrieval checkpoint
Section titled “Retrieval checkpoint”Connections
Section titled “Connections”You understand the atom: a closed sum type of nine variants, pinned at 32 bytes by a test.
Next: how the engine takes one apart safely, and why the
compiler forces a match to handle all nine cases.
Go deeper on the Rust (optional; the same enum machinery from first principles, in
the official Rust docs):
Glossary terms used: enum, variant, sum type, Value.