Lesson 05 / 14
The Borrow Checker
The borrow checker tests a value's ownership and borrowing rules at compile time; three rejection examples (a live borrow during a move, mutating while reading, returning a reference to a local owner) are always described in prose, with an accepted solution run alongside each. Two different solutions doing the same job can produce the same ledger record; code that follows the borrowing rules can still give a wrong result — the checker guarantees not correctness, only ownership discipline.
Published Updated
In this lesson
- Shows what the borrow checker tests at compile time through three rejection examples (a borrow during a move, mutating while reading, returning a local reference)
- Proves by running it that two different solutions doing the same job (narrowing the scope, taking a copy) produce the same ledger record
- Proves with an example that code fully following the borrowing rules can still give a wrong result
Contents
The previous four lessons established borrowing’s rules one by one: access without giving up ownership, the source not ending, the life not exceeding the source, the shared/mutable distinction. This lesson looks at the mechanism that enforces these rules itself: what exactly does the borrow checker test at compile time? What the checker tracks is a value’s life — not the question asked in the Concurrency course, “how does a running unit stop.”
All four lessons shared one assumption: every piece of code shown already followed the rule, and stood on the accepted side. This lesson makes that line itself visible — it shows what the rule separates by describing the rejected side in prose and running the accepted side. That multiple accepted pieces of code doing the same job can exist is also a separate finding of this lesson: the rule does not impose a single correct form, it only draws a boundary.
A compiler rejecting a piece of code was, for the first time in this curriculum, the main measurement axis in the Go’s Design Goals lesson — there, whether the source compiled or not was itself the thing measured. In this course, the same question cannot be the course’s main measurement; it is only this lesson’s limited contribution. This lesson sees more rejection than any of the other four lessons, but each one stays bound to the same rule: a rejection is described in prose, an accepted solution is run.
The rejections in the previous three lessons were, one by one, concrete examples of a single rule — in this lesson, three rejections stand side by side to show the checker’s general way of working. What all three have in common is this: the checker does not read a source line by line, it tracks, as a whole, how long every value in the source is valid and who accesses it with what authority during that time. A line looking valid on its own is not enough; the relationship that line forms with the lines before and after it also has to follow the rule.
Rejection 1: A Live Borrow During a Move
A value cannot be moved while it is lent out. A call trying to move value into a
function while a reference (view) to it is still held does not compile — because the
move invalidates the old name, but view would still be looking at where that name
pointed. No source showing this violation is written at all; instead, two separate
accepted solutions doing the same job are run, and both are measured to produce the
same ledger record.
This rejection is where the Ownership and Moves lesson’s single-owner rule and the Borrowing lesson’s stay-inside-the-scope rule intersect. A move alone is free, borrowing alone is free; the checker steps in when the two overlap on the same value, at the same time. As the move invalidates the old name, what happens to a reference left over becomes unclear — the checker resolves this by requiring every borrow on the value to have already closed before allowing the move.
Solution A — narrowing the scope: the reference is left in its own narrow scope and closed before the move.
// trace.rs — a measurement helper that records a value's ending
use std::cell::RefCell;
use std::rc::Rc;
pub type Ledger = Rc<RefCell<Vec<String>>>;
pub struct Trace {
pub name: String,
ledger: Ledger,
}
impl Trace {
pub fn new(name: &str, ledger: &Ledger) -> Trace {
Trace { name: name.to_string(), ledger: Rc::clone(ledger) }
}
}
impl Drop for Trace {
fn drop(&mut self) {
self.ledger.borrow_mut().push(self.name.clone());
}
}
pub fn new_ledger() -> Ledger {
Rc::new(RefCell::new(Vec::new()))
}
pub fn read_log(ledger: &Ledger) -> Vec<String> {
ledger.borrow().clone()
}
// narrow_scope.rs — the borrow is ended in a narrow scope before the move
mod trace;
use trace::{Trace, new_ledger, read_log};
fn process_task(value: Trace) {
println!("name moved inside the function: {}", value.name);
}
fn main() {
let ledger = new_ledger();
let value = Trace::new("task", &ledger);
{
let view = &value;
println!("current name: {}", view.name);
}
process_task(value);
println!("ledger after narrowing the scope: [{}]", read_log(&ledger).join(", "));
}
current name: task name moved inside the function: task ledger after narrowing the scope: [task]
Solution B — taking a copy: the data needed for reading is copied up front, and no
reference to value is taken at all.
// take_copy.rs — the data to be read is copied beforehand, and the borrow is never taken
mod trace;
use trace::{Trace, new_ledger, read_log};
fn process_task(value: Trace) {
println!("name moved inside the function: {}", value.name);
}
fn main() {
let ledger = new_ledger();
let value = Trace::new("task", &ledger);
let current_name = value.name.clone();
println!("current name: {}", current_name);
process_task(value);
println!("ledger after taking a copy: [{}]", read_log(&ledger).join(", "));
}
current name: task name moved inside the function: task ledger after taking a copy: [task]
OR13. Both solutions produce the same ledger record ([task]). The checker does
not impose a single correct piece of code; it only enforces one rule (there cannot be a
live borrow during a move), and every piece of code that satisfies this rule is
accepted. Narrowing the scope shortens the reference’s life, taking a copy never sets
up the reference at all — both follow the same rule by a different path and arrive at
the same observable result.
The difference between the two solutions is in cost. Narrowing the scope builds no new
data at all, it only narrows the reference’s scope; taking a copy, in contrast, produces
a copy of value.name — Trace itself is still one, but the content of the name
field gets built once more. Both pieces of code follow the same rule, but which one to
choose depends on the rest of the source: if the reference’s scope can fit into a
narrow block, narrowing the scope is preferred; if the data to be read is needed
repeatedly at different points in the function, taking a copy can stay simpler.
Rejection 2: Mutating While Reading
Trying to change the same list while iterating over it with a shared borrow (a loop, for example) is also rejected — while the loop itself holds a shared reference to the list, adding an element to the list asks for a mutable reference, and the two collide. The solution is to split reading and writing into two separate steps. This pattern is a situation often met when new elements derived from each element of a collection are meant to be added to the same collection — trying to grow a collection while iterating over it.
// read_then_write.rs — first read with a shared borrow, then change in a separate step
mod trace;
use trace::{Trace, new_ledger, read_log};
fn main() {
let ledger = new_ledger();
let mut list = vec![Trace::new("p", &ledger), Trace::new("q", &ledger)];
let mut copy_names = Vec::new();
for trace in &list {
copy_names.push(format!("{}-copy", trace.name));
}
for name in ©_names {
list.push(Trace::new(name, &ledger));
}
println!("list length: {}", list.len());
println!("nothing has ended yet, ledger: [{}]", read_log(&ledger).join(", "));
}
list length: 4 nothing has ended yet, ledger: []
The first loop iterates over &list and collects a copy of each name into a separate
Vec<String>; when this loop ends, the shared borrow closes too. The second loop
changes list freely, because there is no borrow left on it anymore. Code trying to do
the same job in a single loop, adding an element to the list while iteration is still
going, would not compile.
This rejection is from the same family as Rejection 1, but its source is different. In
Rejection 1, the two things colliding were a move and a borrow; here, the two things
colliding are a shared borrow (the loop itself) and a mutable borrow (the push call).
What the two have in common is that the checker finds the collision not by looking at
the order of the lines, but by looking at whether the borrows’ scopes overlap.
Even while the loop visits each of list’s elements one by one, the loop itself holds a
single, continuous shared borrow on list — it does not close until the loop ends.
Rejection 3: Returning a Reference to a Local Owner
A function cannot return a reference to a value it builds inside itself — once the function ends, that value’s owner (the function’s own scope) ends too, and the returned reference would end up pointing to somewhere that no longer existed. The solution is the same pattern established in the Ownership and Moves lesson: returning not a reference, but the value itself.
The first two rejections were a borrow colliding with a move, or a borrow colliding with another borrow, in the same scope — in both, the two problematic accesses sat between the same lines, in the same source. The third rejection is different: the problem crosses a single function’s boundary, between the caller side and the called side.
// return_ownership.rs — the function returns the local value by ownership, not by reference
mod trace;
use trace::{Trace, new_ledger, read_log, Ledger};
fn create(name: &str, ledger: &Ledger) -> Trace {
let local = Trace::new(name, ledger);
local
}
fn main() {
let ledger = new_ledger();
let value = create("produced", &ledger);
println!("produced name: {}", value.name);
println!("value is still in its owner, ledger: [{}]", read_log(&ledger).join(", "));
}
produced name: produced value is still in its owner, ledger: []
The create function builds local inside its own body and returns it by moving
it — not &local, but local itself. Because ownership passes to the caller, value
stays valid all the way to the end of main; the ledger stays empty. If create’s
signature had returned &Trace, the compiler would not have accepted even that
signature, because which source the returned reference was tied to would stay unclear —
local, built inside the function, ends once the function ends.
This rejection stands in a different place from the previous two: Rejection 1 and
Rejection 2 forbade a specific call order, the source itself was invalid from the
start. Here, what actually gets rejected is the function’s signature — the code
fn create(...) -> &Trace is problematic without even looking at its body. Where a
reference a function returns is tied to is information that has to be known at every
place the function is called; when a function returns a reference to a value it builds
inside itself, this information is not written anywhere in the source. The rule seen in
the Ownership and Moves lesson — “a function returning a value it owns is a move” —
comes back here as the only solution.
OR14. This rejection is a direct consequence of the rule (from the Borrowing lesson) that the interval during which a reference is valid can never exceed its source’s scope. A concept not yet named comes into play here: this interval, which determines “how long” a reference can stay valid, has a name of its own, but this lesson does not give that name.
Unlike the first two rejections, this one can be detected without looking at code
anywhere the function is called, just by reading create‘s own body. The checker does
this by looking at the function’s signature and tracking which parameter or which local
value the returned reference is tied to; because local’s source does not come from
outside the function, there is no way for a returned reference to stay valid outside
it.
Boundary Measurement: The Checker Does Not Guarantee Correctness
All the examples so far showed what the checker rejects. Now the opposite: code the checker accepts, code that violates no rule at all, can still give a wrong result.
// correctness_not_guaranteed.rs — code that follows the borrowing rules can still give a wrong result
fn push_and_count(list: &mut Vec<i32>, value: i32) -> usize {
let length = list.len();
list.push(value);
length
}
fn main() {
let mut list = Vec::new();
let count1 = push_and_count(&mut list, 10);
let count2 = push_and_count(&mut list, 20);
println!("counts returned by the function: {}, {}", count1, count2);
println!("the list's real length: {}", list.len());
}
counts returned by the function: 0, 1 the list's real length: 2
push_and_count violates no ownership or borrowing rule at all — it takes list by a
mutable reference, adds the element, returns a valid number. But length holds the
length before the addition, and the returned value is wrong if it is meant to be
read as “the count after adding”: the two calls return 0 and 1, while the list’s
real length should have been 1 and 2 respectively.
OR15. The borrow checker tests only ownership and access discipline — who accesses a value at the same time, with what authority. At which point a computation is read, whether a variable is updated on the correct line, are outside this check; a source can carry a completely valid ownership/borrowing arrangement and still hold a logic error. The one thing this lesson measures is this: a piece of code compiling does not mean it is correct.
Fixing the error completes the same observation: the problem is not in ownership, it is
in which line length is read on.
// fixed.rs — same function, length is read after the addition
fn push_and_count(list: &mut Vec<i32>, value: i32) -> usize {
list.push(value);
list.len()
}
fn main() {
let mut list = Vec::new();
let count1 = push_and_count(&mut list, 10);
let count2 = push_and_count(&mut list, 20);
println!("counts returned by the function: {}, {}", count1, count2);
println!("the list's real length: {}", list.len());
}
counts returned by the function: 1, 2 the list's real length: 2
list.push(value) and list.len() swapped places, nothing else changed — the
parameter type, the borrow, the signature all stayed the same. The fixed version passes
the borrow checker just like the buggy version, because the checker never sees the
difference between the two at all: in both versions, list is taken by a mutable
reference once, used once, and no rule is violated. The checker staying silent is not
proof the code is correct, only proof that the access rules were not violated.
Three rejections and one correctness boundary together draw the borrow checker’s limits. When the checker rejects a source, the reason is always from the same family: more than one conflicting access existing on a value at the same time (Rejection 1, Rejection 2), or a reference that would live longer than its source (Rejection 3). When it accepts, what the checker says is narrow — only that this access arrangement is safe, not that the computation the code performs is correct. The ownership, move, borrow, and slice rules built across four lessons all tie into a single mechanism, a single check, with this lesson; but the limits of what that mechanism tests are seen just as clearly.
Summary
- The borrow checker tests, at compile time, whether the ownership and borrowing rules are violated; a source that violates them does not compile at all.
- There cannot be a live borrow during a move; narrowing the scope and taking a copy can both satisfy this constraint in two different ways, and both produce the same ledger record: the value ends at scope end in both pieces of code.
- Changing the same source while iterating over it with a shared borrow is rejected; it is solved by splitting reading and writing into separate steps.
- A function cannot return a reference to a value it builds inside itself; the solution is to return the value itself (by ownership), not a reference.
- Code that fully follows the borrowing rules can still give a wrong result — the checker guarantees not correctness, only access discipline.
- Multiple pieces of code following the same rule (narrowing the scope, taking a copy) can be accepted; the checker does not impose a single correct form, it only draws a boundary.
Next Step
This lesson repeatedly showed that what the checker tests is an interval — a boundary that determines how long a reference can stay valid. In Rejection 1 this interval had to avoid colliding with a move, in Rejection 2 it had to avoid colliding with a change, and in Rejection 3 it could never exceed its source’s own scope. All three speak of the same thing, but this interval’s own name was never given across these five lessons. The next topic gives exactly this name: the concept of a reference’s own validity interval, when this interval can be read from the source, and when it has to be written explicitly.
To keep your progress and take notes, Log in
My notes
Log in to take notes.