Borrowing — using a value without taking it
The Rust question for this lesson: in lesson 4, handing a value to a function moved
it — the original became unusable. But most of the time you just want to look at a
value (or tweak it) and leave it where it is. How do you use a value without taking
ownership? You borrow it, with a reference: &.
Borrowing is what makes ownership practical, and it’s the reason Clinker can read a field out of a record millions of times without copying anything.
A reference lets the owner keep owning
Section titled “A reference lets the owner keep owning”Recall from lesson 4 that fn describe(s: Shape) consumed its argument. Change the
parameter to &Shape — “a reference to a Shape” — and it only borrows:
fn area(s: &Shape) -> f64 { // borrows; does not take ownership match s { Shape::Point => 0.0, Shape::Circle(r) => std::f64::consts::PI * r * r, Shape::Rectangle(w, h) => w * h, }}
let sq = Shape::Rectangle(3.0, 4.0);let a = area(&sq); // pass a reference with &println!("{sq:?} has area {a}"); // sq is STILL usable — it was only borrowed(That’s the same area from lesson 2 — now you can see the & was a borrow all along.)
The caller writes &sq to lend a reference; sq keeps ownership and stays usable after
the call. Run it:
> output appears here — press Run
Two kinds of borrow, one rule
Section titled “Two kinds of borrow, one rule”- Shared / immutable —
&T: read-only. You can have many at once. - Mutable —
&mut T: read-write. You can have exactly one, and no shared borrows may coexist with it.
That’s the borrow checker’s core rule: any number of readers, or a single writer — never
both at the same time. It’s enforced entirely at compile time, and it’s what rules out
data races and dangling references before the program ever runs. (&mut also requires
the binding itself be mut, which is why the playground says let mut c.)
// quick check
After `let a = area(&sq);`, can you still use `sq`?
A reference borrows without taking ownership. The owner (sq) keeps the value and stays usable after the borrow ends.
Why the engine borrows: reading fields without copying
Section titled “Why the engine borrows: reading fields without copying”This is borrowing’s payoff in production. When Clinker looks up a field, it returns a
borrowed &Value, not an owned Value — and the source says exactly why:
clinker-record ·resolver.rs ·FieldResolver trait @47d2e12
pub trait FieldResolver { /// Unqualified field lookup: `field_name` → `&Value`. fn resolve(&self, name: &str) -> Option<&Value>; // …}The doc comment on this trait spells out the reasoning: it returns a borrowed &Value
“so the … hot path can short-circuit through coalesce, filter, and let-binding without
cloning the underlying” value — “the clone happens at most once per field reference,”
only at the spot that truly needs to keep it. The storage trait underneath makes the same
promise — borrowing keeps field access zero-alloc:
clinker-record ·storage.rs ·RecordStorage trait @47d2e12
If resolve returned an owned Value instead of &Value, every single field read would
clone the cell — an allocation for every string, on every row. Returning a reference
turns that into a pointer hand-off: the value stays in the record, the caller just looks
at it.
The lifetime of that borrowed &Value — exactly how long it stays valid, and how the
compiler tracks “as long as the record lives” — is its own topic: lifetimes, coming
later in the track.
Your turn
Section titled “Your turn”A read-only exercise — use the playground.
Change
area’s signature fromfn area(s: &Shape)tofn area(s: Shape)(take it by value) and run. Read the new error at the secondarea(&c)call. Then explain, in ownership terms, why the borrowed version letcbe measured twice while the owned version doesn’t — and connect that to whyresolvereturns&Value.
💡 Hint 1
By value, the first area(c) moves c in and drops it; there’s nothing left to measure
the second time. A & borrow never takes c, so it survives every call — the same reason
a record survives every field resolve.
Show solution
Taking Shape by value moves c into the first area call, so the second call (and the
final println!) fail with “borrow of moved value: c”. The &Shape version only borrows
each time, so c keeps ownership and can be read repeatedly. That’s precisely why
FieldResolver::resolve returns &Value: a record is read many times per row, and an
owned return would move/clone the value out on every read — borrowing lets the record stay
put and hand out cheap, temporary looks.
Common misconceptions
Section titled “Common misconceptions”- “
&is just a C pointer.” It’s a checked borrow: the compiler enforces the readers-XOR-writer rule and guarantees the reference never outlives the value. No nulls, no dangling. - “I can take
&mutwhile a&is active.” No — a mutable borrow is exclusive. The compiler rejects a shared and a mutable borrow overlapping, which is what prevents data races.
Where this leads
Section titled “Where this leads”Moves and borrows are the whole ownership model. Next we look at where values actually
live — the stack versus the heap — and the first smart pointer, Box, which is how
types like Value keep themselves small. After that: Arc, the shared ownership Clinker
leans on everywhere.