Reading a field without copying — lifetimes
The engine reads a field by handing back a borrow into the record (resolve returns
&Value), so evaluating an expression walks a record’s cells without copying a single one.
That zero-copy read is what keeps the hot path cheap, and it ties the whole data layer
together. You met the borrow itself back in Ownership: move & borrow,
where &value let you read without taking ownership. But a borrow raises an obvious danger:
what if the record is gone and you still hold the reference? The Rust tool that rules that
out is the lifetime; we use just enough of it to read the engine’s zero-copy reader, and
The Rust Book, ch. 10.3 is the
canonical treatment if you want lifetimes from first principles.
What you’ll be able to do
Section titled “What you’ll be able to do”- Read a lifetime annotation like
RecordView<'a, S>and name what the'aties together. - Explain how a lifetime prevents a dangling reference, and what the compiler does instead of crashing at runtime.
- Predict whether a borrow that outlives its source compiles, and read the borrow-checker error when it doesn’t.
- Distinguish a zero-copy read from the one place the engine must pay for an owned copy, and say why each is correct.
New terms in this lesson (each is also expanded inline at the point you first need it):
- lifetime: a name (
'a) for how long a borrow stays valid. - zero-copy: read a value in place through a borrow, copying nothing.
- borrow:
&value, use it without taking ownership (from Ownership: move & borrow). - reference validity: a reference is only good while its target is still alive.
Predict first
Section titled “Predict first”A lifetime is “how long this borrow is valid”
Section titled “A lifetime is “how long this borrow is valid””A reference can’t outlive the thing it points at. A
lifetime
(written 'a) is the compiler’s name for “the span during which this borrow is valid.” Most
of the time you never write one; the compiler infers them. But when a type holds a
borrow, it must say so, and that annotation is what guarantees the borrow can’t dangle. The
property it protects is plain:
reference validity,
a reference is good only while its target is alive.
The engine’s zero-copy field reader is exactly such a type. RecordView<'a, S> is a tiny,
Copy handle (a pointer plus an index) that borrows into record storage for the
lifetime 'a:
clinker-record ·record_view.rs ·RecordView type @19acdcb4
#[derive(Clone, Copy)]pub struct RecordView<'a, S: RecordStorage + ?Sized> { storage: &'a S, // borrows the record storage for the lifetime 'a index: u64, // which record}The 'a ties the view to the storage it came from. The compiler will not let a
RecordView outlive that storage, so a view onto a record that’s been dropped is not a
runtime crash you debug, it’s a program that won’t compile. The storage it reads
through is itself a trait, so the same view works over the real arena in production and a
stub in tests:
clinker-record ·storage.rs ·RecordStorage trait @19acdcb4
pub trait RecordStorage: Send + Sync { fn resolve_field(&self, index: u64, name: &str) -> Option<&Value>; // ... resolve_qualified, available_fields, record_count}Zero-copy, and the one clone
Section titled “Zero-copy, and the one clone”Put the pieces together. Reading a field returns &Value (a
borrow,
Ownership: move & borrow), the absent case is None
(Option and Result), and shared backing data lives behind
Arc (Smart pointers). So evaluating a CXL expression (coalescing, comparing, filtering)
walks through fields without copying a single one. That is
zero-copy:
the string "active" is read in place, compared, and discarded; it’s never duplicated. A
copy is paid exactly once, and only at the spot that must keep a value past the borrow
(an output that writes it, say), not on every read along the way.
There’s even a small trick for the absent case: rather than allocate an empty value to
return, the resolver can hand back a borrow of a single shared static “null” value, a
reference with a 'static lifetime that’s always valid, so None-ish reads cost nothing
either. ('static is the one lifetime with a name you’ll see often: it means “valid for
the whole program,” which is exactly true of a value baked into the binary.)
Lifetimes stop a dangling read
Section titled “Lifetimes stop a dangling read”> output appears here — press Run
Run it, then uncomment the two lines. The borrow checker refuses to compile a program
where field could be read after record is gone. That refusal, at compile time and every
time, is what lets the engine borrow fearlessly.
Worked → completion → faded
Section titled “Worked → completion → faded”You met lifetimes on a real engine type above. Now you read one, fill a gap in one, and write a borrowing type from scratch. One new idea per rung.
Worked: a view that borrows, fully annotated
Section titled “Worked: a view that borrows, fully annotated”Here is a self-contained RecordView-shaped type that mirrors the engine’s: a small Copy
handle holding a borrow plus an index, with the lifetime spelled out on every line that
carries it. Run it and watch the view read a field without copying anything.
> output appears here — press Run
The 'a appears three times, on the struct, on the impl, and on the returned
&'a Value, and they all name the same span: as long as record is alive. The view is
Copy (it’s just a pointer and an integer), so handing it around costs nothing, and it
never owns the data it reads.
Completion: add the missing lifetime
Section titled “Completion: add the missing lifetime”This type is one annotation short of compiling. The field stores a borrow, but the struct
doesn’t yet declare the lifetime that borrow lives for. Add it: declare a lifetime
parameter on the struct and use it on the storage field, so the borrow has a name.
> output appears here — press Run
💡 Hint 1
& must declare the lifetime as a generic parameter, exactly like RecordView<'a, S> did. The name goes in angle brackets after the struct name, then on the borrowed field: struct FieldPeek<'a> { storage: &'a [Value], … }.Show solution
#[derive(Clone, Copy)]struct FieldPeek<'a> { storage: &'a [Value], // the borrow now has a name: 'a index: usize,}Declaring <'a> and writing &'a [Value] is the type saying out loud “I hold a borrow,
and here is the span it’s valid for.” That single annotation is what lets the compiler
prove no FieldPeek ever outlives the record it points into: the same guarantee
RecordView<'a, S> gives the engine.
Faded: write a borrowing type and trigger the dangling-read error
Section titled “Faded: write a borrowing type and trigger the dangling-read error”Now write one yourself from a skeleton. The goal: a Cursor<'a> that borrows a slice of
Values and reads the cell at its position without copying it, then a main that builds
one, reads through it while the data is alive, and a commented-out line that would be a
dangling read if you uncommented it (drop the data, then read through the cursor).
> output appears here — press Run
Show solution
#[derive(Clone, Copy)]struct Cursor<'a> { data: &'a [Value], // borrow, valid for 'a pos: usize,}
impl<'a> Cursor<'a> { fn current(&self) -> Option<&'a Value> { self.data.get(self.pos) // borrows for the same 'a — no copy }}
fn main() { let data = vec![Value::Integer(1), Value::Text(String::from("active"))];
let cur = Cursor { data: &data, pos: 1 }; // borrows data println!("{:?}", cur.current()); // zero-copy read
// drop(data); // retire the data early... // println!("{:?}", cur.current()); // error[E0505]: cannot move out of `data` // // because it is borrowed by `cur`}Cursor<'a> borrows; it never owns the Values, so current() reads them in place. The
'a on the struct, the impl, and the returned &'a Value all name the span the borrow
is valid: data’s life. Uncomment the two lines and the compiler points at exactly the
drop that would make the cursor dangle. There is no path to a runtime dangling read.
Real-source grounding
Section titled “Real-source grounding”Here is the engine type the worked example mirrors. RecordView<'a, S> is the zero-copy
field reader: a Copy handle that borrows storage for 'a and reads fields through it.
clinker-record ·record_view.rs ·RecordView type @19acdcb4
#[derive(Clone, Copy)]pub struct RecordView<'a, S: RecordStorage + ?Sized> { storage: &'a S, // borrows storage for 'a — the view can't outlive it index: u64,}What the compiler enforces: a RecordView can never be used after the S it borrows is
gone. Drop the storage while a view still references it and the program does not build;
there is no code path that reads a dropped record through a view.
What a junior might misread: thinking the 'a makes the view own a copy of the
storage (it doesn’t; the view owns nothing, it only borrows), or thinking a missing
lifetime is a runtime risk to test for (it’s a compile error; the dangling program never
runs). The ?Sized and the S: RecordStorage bound also let one view type work over the
real arena and a test stub alike: storage is a trait, not a concrete type.
The storage it reads through is that trait:
clinker-record ·storage.rs ·RecordStorage trait @19acdcb4
pub trait RecordStorage: Send + Sync { fn resolve_field(&self, index: u64, name: &str) -> Option<&Value>; // returns Option<&Value> — a BORROW (lesson 11) of the cell, never a copy // ... resolve_qualified, available_fields, record_count}That Option<&Value> return is the zero-copy read in one line: Option for “maybe the
field is absent” (the Option/Result lesson), &Value for “borrowed, not duplicated” (the
ownership lesson). The
lifetime on that borrow is tied, through the view, all the way back to the storage’s life.
Why-bridge: lifetimes are what make zero-copy safe, not just fast
Section titled “Why-bridge: lifetimes are what make zero-copy safe, not just fast”Why does the engine read everything through borrows and lifetimes instead of handing back
owned Values? Because zero-copy is only worth doing if it’s also safe. Borrowing a field
instead of cloning it saves an allocation on every read (the ownership lesson), but a borrow with no
guarantee behind it would just be a dangling-pointer bug waiting to happen, the exact class
of crash Rust exists to rule out.
The lifetime is that guarantee. RecordView<'a, S> can read a field with no copy because
the 'a proves, at compile time, that the view never outlives the storage it reads. The
engine gets the performance of pointer-chasing C with none of its dangling-read risk: the
fast path and the safe path are the same path.
Apply: predict the compiler, then fix the dangling return
Section titled “Apply: predict the compiler, then fix the dangling return”This is a generation task, not recognition. Read the function below. It’s meant to be broken: it tries to return a borrow of a value that the function is about to drop.
// WON'T COMPILE — kept here as a deliberate counterexample.fn first_field() -> &'static Value { let record = vec![Value::Text(String::from("active"))]; // owned here, dropped at the } &record[0] // returning a borrow of something about to die}
enum Value { Text(String) }Predict the compiler first. What error does the &record[0] return produce, and why?
Show the compiler's answer
error[E0515]: cannot return reference to local variable `record` --> src/main.rs | | &record[0] // returning a borrow of something about to die | ^^^^^^^^^^ returns a reference to data owned by the current functionrecord is owned by first_field and is dropped when the function returns, so any borrow
of it would dangle the instant the caller received it. There is no valid lifetime to give
the returned &Value: it would have to outlive the data it points at. The fix is to make
the caller own the data and lend it in, so the borrow’s lifetime is tied to something that
actually outlives the call:
// The caller owns the record; the borrow is valid for as long as the caller's 'a.fn first_field<'a>(record: &'a [Value]) -> &'a Value { &record[0]}This is the entire safety promise of lifetimes in one error message: the compiler will not
let you return a borrow that outlives its data. It’s the same rule RecordView<'a, S> rides
on: a view, or a returned &Value, is only ever valid for as long as the storage it came
from.
Retrieval checkpoint
Section titled “Retrieval checkpoint”Connections: and the end of Data & Representation
Section titled “Connections: and the end of Data & Representation”You can now read a lifetime annotation, explain how it forbids a dangling reference at
compile time, write a type that borrows, and name the one place the engine pays for an owned
copy. That closes Data & Representation. You’ve gone from “a
record is a row of cells” to the real machinery: a 32-byte closed Value, exhaustive
match, borrow-don’t-copy field access, Option/Result for absence and failure,
Arc-shared schemas and context, and lifetimes that make all the borrowing provably safe.
Planning & Expressions moves up a layer: how the engine turns
YAML and CXL into the validated CompiledPlan you’ve been running.
Go deeper on the Rust (optional; The Rust Book teaches borrowing from first principles and folds lifetimes into that chapter rather than a standalone one):
Glossary terms used: lifetime, zero-copy, borrow, reference validity.