Who owns a record's data
A run over a million rows reads each record’s fields over and over; every predicate, every
projection, every coercion starts by looking at a cell. So one question decides the engine’s
hot path: when code reads a field, who keeps owning that data, and does the read copy it or
just point at it? You just took a Value apart with an exhaustive
match; now the question is what happens to that Value when you
hand it around. The Rust tool that answers it is ownership: every value has exactly one
owner, handing it off moves it (the source goes invalid), and lending it with & is a
borrow that copies nothing and keeps the owner intact. Borrowing pays off twice, in order.
First, safety (the borrow checker allows either many shared readers or one writer,
never both, and never lets a reference outlive what it points to, all at compile time). Then
performance as the consequence (no copy, so reading a field a million times costs nothing).
This is where the engine lives; The Rust Book, ch.
4 is the canonical treatment
if you want ownership from first principles.
What you’ll be able to do
Section titled “What you’ll be able to do”- State the borrow checker’s one rule (either many readers or one writer, never both).
- Distinguish move, borrow, and clone, and predict which one a given line performs.
- Predict whether a value is still usable after it has been moved out, and read the compiler error when it isn’t.
- Explain why
FieldResolver::resolvehands back a borrow of a field (&Value) rather than a copy.
New terms in this lesson (each is also expanded inline at the point you first need it):
- ownership: exactly one owner per value, no garbage collector.
- move: assigning or passing transfers ownership; the source goes invalid.
- borrow:
&value, used without taking ownership; safe, and cheap. - clone: an explicit, possibly-expensive deep copy you opt into.
Predict first
Section titled “Predict first”The engine reads fields by borrowing
Section titled “The engine reads fields by borrowing”When CXL evaluates status == "active", it has to read the status field of the
record. The trait that resolves a field name to its value is FieldResolver:
clinker-record ·resolver.rs ·FieldResolver trait @19acdcb4
pub trait FieldResolver { fn resolve(&self, name: &str) -> Option<&Value>; // ^^^^^^^^^^^^^^^^ // a BORROW of the value, not a copy}That return type, Option<&Value>, is the whole lesson. &Value is a
borrow
of the value living inside the record. The resolver doesn’t hand back a Value (which
would mean copying it out); it hands back a window onto the one that’s already there. CXL
can read status, compare it, and move on without ever duplicating it. (We’ll cover the
Option part, what the None means, in the next lesson.)
What a clone would cost
Section titled “What a clone would cost”Why does this matter so much? Because copying a Value isn’t always cheap. A
Value::Integer is a few bytes, sure. But a Value::String owns its text and a
Value::Array owns a whole Vec. Cloning
those allocates. Now multiply by every field access, of every record, in a
million-row file. Borrowing is free; cloning is a tax. So the engine’s rule is: borrow to
read, and only pay for a clone at the one spot that genuinely needs to keep a value (a
topic we finish in the zero-copy lesson).
Worked → completion → faded
Section titled “Worked → completion → faded”You met move, borrow, and clone above. Now you run them, fill a gap, and write the whole thing, one new idea per rung.
Worked: see all three on one value
Section titled “Worked: see all three on one value”Here is a fully annotated, runnable example. Each line is labeled with which of the three
operations it performs. Run it, then uncomment the line after the move and watch the
borrow checker explain that status no longer holds anything.
> output appears here — press Run
The two read(&status) calls are the borrow checker’s “many readers” rule in action:
nothing here writes, so any number of shared & borrows coexist. The .clone() is the
one line that allocates a fresh String; the move is the one line that retires status.
Completion: name what each line does
Section titled “Completion: name what each line does”This compiles, but two annotations are missing. Each // ??? marks a line. Decide
whether it is a borrow, a move, or a clone, and why last_seen is still usable on
the final line while owned_copy came from a deliberate allocation. Predict before you
reveal.
> output appears here — press Run
💡 Hint 1
&last_seen; the & is the tell. Line (2) calls .clone() explicitly. One of these leaves last_seen owning its String; the other makes a second, independent String. Neither one retires last_seen, which is why the final println! can still print it.Show solution
let n = length_of(&last_seen); // (1) BORROW — &last_seen lends a window; ownership stays putlet owned_copy = last_seen.clone(); // (2) CLONE — a brand-new String is allocated alongside the originalBecause neither line is a move, last_seen is still the owner of its Value on the last
line, so printing it is fine. Contrast the worked example, where let moved = status; did
move and the later use was rejected. Borrow and clone both leave the source intact; only a
move retires it.
Faded: write the function and trigger the error
Section titled “Faded: write the function and trigger the error”Now write it yourself, with only a skeleton. The goal: a function summarize that
borrows a Value and returns an owned String describing it (so the caller keeps its
Value), then a main that borrows the same value twice, makes one clone, and finally
moves it, and a commented-out line that would be a use-after-move if you uncommented it.
> output appears here — press Run
Show solution
fn summarize(v: &Value) -> String { match v { Value::Integer(n) => format!("integer {n}"), Value::Text(s) => format!("text of {} bytes", s.len()), }}
fn main() { let cell = Value::Text(String::from("active"));
println!("{}", summarize(&cell)); // borrow — cell unaffected println!("{}", summarize(&cell)); // borrow again — many readers, fine
let kept = cell.clone(); // clone — independent allocation let moved = cell; // move — cell is retired here // println!("{}", summarize(&cell)); // error[E0382]: borrow of moved value: `cell`
println!("kept = {kept:?}, moved = {moved:?}");}summarize takes &Value, so each call only borrows; that is what lets you call it
twice and still clone and move afterward. The exhaustive match inside it is the same
exhaustiveness rule from the previous lesson: drop the Integer
arm and this won’t build.
Uncomment the last summarize(&cell) and the compiler points at exactly the move that
retired cell.
Why-bridge: borrowing is the engine’s hot-path default
Section titled “Why-bridge: borrowing is the engine’s hot-path default”Why does the engine reach for a borrow at nearly every field read? Because the alternative
is a copy, and copies cost. Reading is the most common thing the engine does: every
predicate, every projection, every coercion starts by looking at a Value. If each look
duplicated the value, a Value::String or Value::Array would re-allocate on every touch,
and the per-record cost would scale with the data, not the work.
A borrow sidesteps that: resolve returns &Value, the caller reads through the reference,
and the original stays exactly where it is. The single owner, the record, keeps the value
alive for as long as the borrow needs it; the borrow checker proves at compile time that no
reader ever outlives the record it points into.
Retrieval checkpoint
Section titled “Retrieval checkpoint”Connections
Section titled “Connections”You understand why records flow on borrows: ownership stays with the record, reads go
through cheap &Value windows, and a clone is a deliberate cost you pay only when a value
must outlive its source. Next: the two types that encode “maybe absent” and “maybe
failed”, namely Option and Result, which you already half-met
in that Option<&Value>.
Go deeper on the Rust (optional, one-directional; for the same concepts taught from first principles in The Rust Book):
Glossary terms used: ownership, move, borrow, clone.