Skip to content

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.

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:

rust // editable
  • 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`?

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.

A read-only exercise — use the playground.

Change area’s signature from fn area(s: &Shape) to fn area(s: Shape) (take it by value) and run. Read the new error at the second area(&c) call. Then explain, in ownership terms, why the borrowed version let c be measured twice while the owned version doesn’t — and connect that to why resolve returns &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.

  • & 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 &mut while 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.

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.