Reading Rust

Part 2 · The compile-time machine I — borrows & lifetimes

Aliasing XOR mutability

Rust has one rule about references. Everything the borrow checker ever says to you is a consequence of it — including the parts that feel unfair.

45 minThreshold conceptrefutation textPOEcontrasting cases
What this lesson is trying to break (3)
  • The borrow checker prevents crashes by predicting what would go wrong.
  • The rule exists only for multithreaded code.
  • If the code provably cannot crash, the compiler should accept it.
Liminality warning

This is a threshold concept: troublesome, transformative, and irreversible. You should expect to be genuinely stuck here, and being stuck is the mechanism rather than a sign that you are failing. Budget several sessions. Do not move on because you are bored — move on when you can explain it out loud without notes.

Part 1 was about what your program does. Part 2 is about what the compiler will let you write, which is a completely different question, decided by a completely different machine.

Almost all of it comes from one rule.

At any point in the program, for any piece of data, you may have either any number of shared references (&T) or exactly one exclusive reference (&mut T) — never both at the same time.

That is it. Every borrow-checker error you will ever see is this rule, or a consequence of it. The rest of Part 2 is learning to see it in situations where it is not obvious.

First, the misconception that makes it feel arbitrary

Unlearn thiscarried over from every language with a garbage collector
You think

The borrow checker is a very clever bug detector. It analyses my program, works out that something bad would happen, and stops me. So if I can convince myself no crash is possible, the compiler is being unnecessarily strict and I have found a limitation.

Wrong here

The borrow checker does not predict runtime behaviour and does not look for crashes. It enforces a rule about references — a purely structural property — and does so whether or not violating it would actually hurt in your particular case.

Instead

Read every borrow error as “you broke the reference rule at line N”, never as “this would crash”. The rule is deliberately simpler than reality: it rejects some programs that are provably fine. That is the price of an analysis a human can predict, and predictability is what you are buying.

This reframing matters practically. If you believe the compiler is predicting crashes, every rejection invites an argument — but look, it can’t reallocate here. If you know it is checking a structural rule, you skip straight to the useful question: which two references overlap, and how do I stop them overlapping?

What goes wrong without the rule

Take the bug in a language you know well. This is real JavaScript:

const xs = [1, 2, 2, 3];
for (const x of xs) {
  if (x === 2) xs.splice(xs.indexOf(x), 1);
}
console.log(xs);   // [1, 2, 3]  ← one of the 2s survived

The iterator holds a position into xs. splice shifts everything down. The iterator’s index now points past an element that moved, so one 2 is silently skipped. Nothing crashes; you just get the wrong answer, probably in production, probably months later.

Every language deals with this somehow. Java throws ConcurrentModificationException at runtime. Go lets you do it and hope. Python quietly gives you the same class of wrong answer as JavaScript.

Rust’s equivalent does not compile:

let mut xs = vec![1, 2, 2, 3];
for x in &xs {
    if *x == 2 { xs.retain(|v| *v != 2); }
}
error[E0502]: cannot borrow `xs` as mutable because it is also borrowed as immutable

for x in &xs holds a shared borrow for the whole loop. retain needs &mut xs. Shared and exclusive cannot overlap. Same bug, caught at compile time, in a language with no runtime checks at all.

Three reasons, not one

Most explanations give the first reason and stop. Give all three and you sound like you have thought about it.

1. Invalidation. A mutation can move or free the data another reference points at. Vec::push may reallocate, which moves every element to a new address; any outstanding & into the old buffer would dangle. This is the memory-safety argument, and it is the one everyone knows.

2. Data races. A data race is exactly two accesses to the same location, at least one a write, unsynchronised. The borrow rule forbids “shared and mutable at once” — which is the same shape. This is why Rust gets thread safety without a second mechanism: it is the same rule, extended across threads by Send and Sync (4.1). That reuse is genuinely elegant and worth being able to explain.

3. Optimisation. This is the reason people leave out, and it is the one that survives the objection “but my code is single-threaded”.

When a function takes &mut T, the compiler knows nothing else can see that T. So it may keep the value in a register across a function call, reorder reads and writes, and skip re-loading after an opaque call. In C this requires the restrict keyword and a promise from the programmer; in Rust it is the default and it is checked. rustc emits LLVM’s noalias on &mut parameters for exactly this reason.

So the rule is not purely a tax. It buys optimisations that C compilers cannot safely make.

Predict firstcommit in writing, then look
fn main() {
    let mut v = vec![1, 2, 3];
    let first = &v[0];
    println!("{first}");
    v.push(4);
    println!("{:?}", v);
}

Careful — this looks like the example from lesson 0.2, but it is not the same.

The shape of the rule

Varies: only which kinds of reference are alive at the same moment

Alive at the same pointAllowed?Reasoning
&T and &TYes, any numberNobody can write, so nobody can invalidate anything.
&mut T aloneYes, exactly oneThe writer is the only observer, so there is nothing to invalidate.
&T and &mut TNo — E0502A write could move or free what the reader is looking at.
&mut T and &mut TNo — E0499Two writers is the data-race shape, even on one thread.
&T and T moved awayNo — E0505Moving destroys the place the reference points into.

Five combinations, two allowed. Every error in lesson 2.5 is one of the three bottom rows in disguise.

Note the last row: a move counts as a mutation for this purpose. That is consistent — moving invalidates the source place just as thoroughly as overwriting it does.

Explain it to yourselfwrite it — thinking it does not count

A colleague who writes Go says:

“I get why you need this for threads. But my service is single-threaded and I still have to fight the borrow checker. That is pure overhead.”

Write your reply. You must include the optimisation argument, and you must concede the part where they are right — because they partly are, and a reply that concedes nothing will not land.

The honest part

Rust rejects programs that are correct. Not rarely, and not only in exotic cases.

The canonical one is disjoint field borrows through a method:

struct S { a: Vec<u32>, b: Vec<u32> }

impl S {
    fn sum_a(&self) -> u32 { self.a.iter().sum() }

    fn bad(&mut self) {
        let total = self.sum_a();     // borrows all of *self
        self.b.push(total);           // E0502 — needs &mut all of *self
    }
}

Nothing here can possibly go wrong: sum_a only touches a, and we only mutate b. But borrows are tracked per path, and a method call borrows the whole self. The compiler cannot see inside sum_a when checking bad.

The fix is to make the disjointness visible — read the fields directly rather than through a method:

let total: u32 = self.a.iter().sum();   // borrows only self.a
self.b.push(total);                     // borrows only self.b — fine

The compiler does track disjoint fields when it can see them. It just cannot see through a function signature.

This is worth knowing for two reasons. It is the single most common source of “the borrow checker is wrong” complaints, and the answer — make the disjointness visible at the borrow site — is a real technique you will use constantly. And it is honest: a language design has trade-offs, and being able to name Rust’s without defensiveness is more credible than insisting it has none.

What you should now be able to say

  • One rule: shared XOR exclusive. Any number of &T, or exactly one &mut T, never both.
  • It enforces a structural property of references, not a prediction of crashes.
  • Three reasons: invalidation, data races, and noalias optimisation.
  • Borrows end at their last use (NLL), not at the end of the block — so most fixes are about shortening a region.
  • A move counts as a mutation.
  • Borrows are tracked per path, and a method call borrows all of self — which is the main source of false rejections.

Next: 2.2 — &mut does not mean mutable, where the name turns out to be actively misleading.

In the wildgo read the real thing now, while it is fresh
  • Vec::push — why &mut self mattersRead only the signature, then the sentence about reallocation. The `&mut self` in that signature is what makes the classic error in this lesson happen — and it is why the rule cannot be relaxed "just for reads".
  • Rustonomicon — aliasingThe optimisation argument stated properly, with the example of what the compiler is allowed to assume. This is the reason people usually leave out, and it is the one that survives the "but my code is single-threaded" objection.
Scheduled for recall3 items added to your review queue

These are not shown to you now on purpose. They will surface in Review tomorrow, mixed in among items from other parts of the course — not grouped by topic. Sorting out which idea applies is the part that transfers.