Skip to content

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.

  • Read a lifetime annotation like RecordView<'a, S> and name what the 'a ties 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):

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
}

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

rust // editable

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.

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.

rust // editable

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.

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.

rust // editable
💡 Hint 1
A struct that stores a & 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).

rust // editable
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.

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 function

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

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.