The promise, made precise: seven bugs, one law
Lesson 00 argued that a promise of "no undefined behavior", held under a budget of "no collector and no run-time system", leaves the compiler as the only place to keep the contract. But a compiler can only reject what it can recognize, and "undefined behavior" is a heterogeneous list. This lesson does the specification work. It asks, for each of the C++ track's seven families, what a checker would have to know — and finds that five need one local fact each while the other two are the same bug wearing different clothes. The result is a single sentence, the law that the rest of the series exists to enforce.
New idea: factor the seven families by what information a checker needs. Five need one local fact. The other two — use-after-free (with double free, iterator invalidation and dangling returns) and data races — are one hazard: an effect on a place through one name while another name that will still be used can reach it. Forbid that and both disappear. The law: for every place, at every moment, either many names may read it or exactly one may change it — never both.
Forces next: Seven kinds of bug collapse into a handful of cheap local fixes plus one law: never mutate or free a place while another name can still reach it. To enforce that we need a precise vocabulary — place, value, name, lifetime — and a model of where values live. Where do values live, and what happens to them, in the simplest case?
1 · The question a checker asks
Imagine you are writing the compiler. For each dangerous operation in a program, you must decide: accept, reject, or insert a check. You can only do that if you can find, in the program text, the information that separates a safe use from an unsafe one. So for each family ask a different question from the one the C++ track asked. Not "what goes wrong?" but "what would I have to know to prove this operation is fine?"
| Family | The bug in one line | What a checker must know | Where that knowledge can come from |
|---|---|---|---|
| Out-of-bounds access | a[i] with i past the end | the length of a, at this access | carry it with the pointer — a slice is an address and a length — and compare (Lesson 02) |
| Uninitialized read | read a variable nothing assigned | whether every path to here assigned it | flow analysis inside one function (Lesson 02) |
| Signed overflow | INT_MAX + 1 | what the answer is | define it: a panic with overflow checks on, a wrap with them off (Lesson 02) |
| Null dereference | *p where p is null | whether p may be absent | make absence a type; a reference can never be null (Lesson 09) |
| Type punning | read a float's bytes as an int through a pointer cast | that two types share storage | make the cast inexpressible in safe code (Lesson 19) |
| Use-after-free, double free, dangling reference, iterator invalidation | a name outlives, or disagrees with, the thing it names | which other names can reach this place, and until when | nothing local — this is the hard one |
| Data race | two threads, one location, at least one writer | the same, across threads | nothing local — same |
Read the third column down. Five rows ask about one operation and one value: a length, an assignment on every path, an arithmetic result, absence, a cast. Each of those facts either sits next to the operation or can be put there by a small change to the language — which is why the mechanisms in the fourth column are cheap and local, and why Lesson 00's widget showed them costing (almost) nothing. The bottom two rows are different in kind. What a checker needs is not a property of an operation. It is a property of a relationship: between the name doing the freeing or writing and every other name that might still be used. You cannot read that off any single line.
2 · Two words and a third: place, name, effect
To talk about a relationship you need vocabulary for its ends. Three words, defined once and used for the rest of the series:
Now the observation that organizes everything after it. An effect by name A can hurt name B only if three things line up: B was created before the effect; B will be used after it; and B was relying on something the effect broke — that the place still exists, or still holds what B last saw. Reads never break that. Writes and ends do. Put the bugs in this shape and the differences between them disappear:
| Bug | Name that acts | Its effect | Name that is hurt | When it is hurt |
|---|---|---|---|---|
| use-after-free | p | ends the buffer (free) | q, a copy of the address | q is dereferenced |
| double free | a | ends the buffer | b, a second "owner" | b ends it again |
| iterator invalidation | v | reallocates while growing | it, an iterator into the old buffer | it advances |
| dangling return | the callee's frame | ends its locals on return | r, the returned pointer | the caller reads r |
| data race, write/write | thread 1 | writes | thread 2's name | thread 2 writes |
| data race, read/write | writer thread | writes | reader thread's name | the reader reads |
Six stories, one sentence: a name did something to a place while a second name, which would be used again, could still reach it. Threads add nothing to the pattern except that the "while" is not ordered by the program text. That is why one rule will be enough to rule out both.
3 · Play the detector
The claim is checkable, so check it. The widget below encodes each story as events on one place, each performed by one of a few named names, and applies one rule: flag any write or end by a name while another name that was created earlier is used later. Step through the ten traces. Six are the bugs above; four are programs that are fine, or fine in every way except that the rule is stricter than it needs to be. Then switch on "apply the law" to see where a compiler holding this rule would stop the program — three places, the same three Lesson 00's error message named.
What to try. Pick use-after-free and drag the step slider: nothing is flagged until the last step, when q is read; the hazard is the pair, not any one event. Then reader done first: the same write raises nothing, because the reader has no later use. Then harmless int write: the detector flags it although nothing is corrupted, since it knows only the pattern. It is our model of the rule. Does the real compiler agree?
4 · The same bugs, in Rust's words
Every one of these stories can be written in Rust, and every one is rejected — before the program runs, with the error code shown on the badge. The badge is not decoration: a script compiles that exact snippet with the real compiler (rustc 1.98.1, edition 2024) and fails if the verdict differs.
fn main() {
let p = String::from("x");
let q = &p; // a second name for p
drop(p); // p ends the buffer
println!("{q}"); // q is used afterwards
}
fn main() {
let a = String::from("x");
let b = a; // ownership moves to b
drop(a); // a no longer exists
drop(b);
}
fn main() {
let mut v = vec![1, 2, 3];
for x in &v {
v.push(*x); // may move the buffer under the iterator
}
}
fn shout(text: &str) -> &str {
let owned = text.to_uppercase();
&owned // a name for something about to end
}
fn main() { println!("{}", shout("hi")); }
use std::thread;
fn main() {
let mut c = 0;
let t1 = thread::spawn(|| { c += 1; });
let t2 = thread::spawn(|| { c += 1; });
t1.join().unwrap();
t2.join().unwrap();
}
The data-race rejection carries E0499 — the code for "cannot borrow as mutable more than once at a time" — alongside E0373 (the closures might outlive the function whose variable they borrow). Now make the threads honest about their lifetimes by using thread::scope, which promises to join them before returning, and keep both writing:
use std::thread;
fn main() {
let mut c = 0;
thread::scope(|s| {
s.spawn(|| { c += 1; });
s.spawn(|| { c += 1; }); // second exclusive access to c
});
}
Only the concurrency-specific complaint has gone; what remains is E0499, the identical error two &mut borrows produce in a single thread. That is the whole thesis of this lesson compiled into one diagnostic: to the checker a data race is not a separate phenomenon. Remove one of the two spawns from this scoped version and the program is accepted — one thread is one exclusive name, and one exclusive name is exactly what the law allows.
error[E0499]: cannot borrow `c` as mutable more than once at a time
--> src/main.rs:7:17
|
6 | s.spawn(|| { c += 1; });
| -----------------------
| | | |
| | | first borrow occurs due to use of `c` in closure
| | first mutable borrow occurs here
| argument requires that `c` is borrowed for `'1`
7 | s.spawn(|| { c += 1; }); // second exclusive access to c
| ^^ - second borrow occurs due to use of `c` in closure
| |
| second mutable borrow occurs here
The headline is the one a single thread earns from two &mut borrows, and the labels are the anchors of Lesson 00: the first borrow, the second, and the reason the first is still needed — a scoped thread may run until the scope ends, and '1 is the compiler's name for that stretch (Lesson 06 calls it a region). The message says "mutable" where this track says exclusive. What it cannot tell you is what you meant: a lock, a channel, or one counter per thread added up afterwards all remove the overlap, and choosing between them is a design question (Lesson 17).5 · The law
Six rejections, one shape. Work backwards from the hazard. It needs an alias, and it needs an effect that changes or ends the place. Remove either and the hurt disappears. So there are exactly two safe regimes:
Both regimes are useful and everyone uses both. Reading a config from twelve places is the first; a function that updates a buffer nobody else is looking at is the second. The law does not forbid aliasing (that would forbid sharing data) and does not forbid mutation (that would forbid programming); it forbids the overlap. Notice too what it says about time: only names that will still be used matter. A reference you are finished with is not in anyone's way — the seed of a rule Lesson 07 will make exact, and already visible in the widget's "reader done first" trace and in the fact that moving the println! above the push in Lesson 00 flipped the verdict.
fn main() {
let s = String::from("x");
let r1 = &s; // many readers: fine
let r2 = &s;
println!("{r1} {r2} {s}");
let mut t = String::from("y");
let m = &mut t; // one writer, nobody else looking: fine
m.push('!');
println!("{m}");
}
Why this rule and not a weaker one?
A subtle point deserves stating, because it is where Rust's reputation for strictness comes from. The law is sufficient for safety, not necessary. Some programs that violate it are harmless. Here a write through the owner lands while a read-only name still exists — for a plain integer that corrupts nothing, and C++ accepts it:
fn main() {
let mut x = 1;
let r = &x;
x = 5; // a write while r is still to be used
println!("{r}");
}
Rust rejects it anyway. Why not a subtler rule — "allow the write when it cannot invalidate anything"? Because that rule would depend on the type and the operation (an integer overwrite is harmless; a Vec push may move the buffer; a String assignment frees the old text), so a reader could no longer tell, looking at a signature, whether a call is allowed; the check would stop being local and simple. The uniform rule is one sentence, it is checkable one function at a time, and — the part that pays for the strictness — it hands the optimizer a fact it can rely on.
Measure that last claim rather than take it on trust. Two functions, compiled by rustc 1.98.1 with -O for AArch64 (Apple silicon, one machine, 2026-09; opt-level 1 to 3 give the same instructions):
pub fn add_twice(a: &mut i32, b: &mut i32) {
*a += 1;
*b += 1;
*a += 1; // could a and b be the same place?
}
pub fn read_twice(a: &i32, b: &mut i32) -> i32 {
let x = *a;
*b += 1;
x + *a // could the write to *b have changed *a?
}
add_twice: read_twice:
ldr w8, [x0] ldr w8, [x0] ; *a loaded ONCE
ldr w9, [x1] ldr w9, [x1]
add w9, w9, #1 add w9, w9, #1
str w9, [x1] str w9, [x1]
add w8, w8, #2 ; +1+1 lsl w0, w8, #1 ; x + x: second read reused
str w8, [x0] ret
ret
In add_twice the two increments of *a were fused into a single +2 across a store through b; in read_twice the second read of *a was never issued. Both transformations are wrong if the two references can alias (in C without restrict the compiler must assume they might). They are right here because the law guarantees an exclusive name has no company and a shared name will not change under you. The rule that forbids overlap is the same rule that licenses the optimization: the strictness and the speed are one fact.
unsafe, where a human takes over (Lessons 19–20).6 · What it takes to enforce a law about names over time
We have a specification. To turn it into a compiler check we need three things, and each is the subject of the next stretch of the series. This is the outline of Part II, derived rather than announced:
Before any of that, one thing is missing that even this lesson assumed: what a place and a value actually are on a real machine, and what happens to them in the simplest case, with no heap and no borrowing at all. That is where the derivation resumes.
Common mistakes / failure modes
E0499) covers both.Checkpoint exercise
Where this points next
The law is stated and the checker's three needs are on the table. The first need — a unique name responsible for ending a place — presupposes that we know what places are and where they live, and we have been using words like "buffer", "frame" and "stack" as if they were obvious. Lesson 02 makes them precise for the simplest case: no heap, no borrowing, only values on the stack. That immediately pays back three of the five cheap rows of §1 — a slice with its length, a variable that must be assigned before it is read, an integer whose overflow has an answer — and it exposes exactly the boundary where the trouble begins: the moment a value's size is not known until run time. First, though, the simplest case itself: where do values live, and what happens to them?
&mut (both are E0499). The law is sufficient rather than necessary, so it is conservative and rejects some safe programs; in exchange it is uniform, local, and the very guarantee that lets the optimizer fuse writes and reuse reads. Enforcing it needs a unique owner, declared reader/writer borrows, and a precise notion of how long a name matters — the outline of Part II.Interview prompts
- Why are use-after-free, double free and iterator invalidation "the same bug"? (§2 — each is an effect (end or reallocate) by one name on a place while a second name, created earlier and used later, can still reach it; the table in §2 fills in the same four slots for all of them.)
- State the law Rust enforces and why it is "shared XOR exclusive" rather than "no aliasing". (§5 — the hazard needs both an alias and a write/end; forbidding aliasing entirely forbids sharing data, forbidding mutation forbids programming; forbidding only the overlap keeps both useful regimes.)
- Which undefined-behavior families does the law not address, and how does Rust handle each? (§1 — out-of-bounds: a length carried in the slice plus a check; uninitialized read: definite-assignment analysis; overflow: defined result; null: absence as a type; type punning: casts unavailable in safe code.)
- Why does a data race produce the same compiler error as two mutable borrows on one thread? (§4 — the checker sees two exclusive names on one place; threads only remove the ordering between them, so the two-scoped-threads example yields E0499, identical to the single-threaded case.)
- Give a program the law rejects that is actually safe, and defend the rule anyway. (§5 — writing an integer while a shared reference to it is still used later: harmless in C++, rejected with E0506; a type-dependent exception would make the check non-local, and the uniform rule feeds the optimizer.)
- How does the same rule that forbids overlap speed the code up? (§5 — an exclusive reference cannot alias another and a shared one cannot change, so the compiler may fuse two increments across a store through another reference and reuse a value it already read; both showed up in the measured assembly.)
- What three things does a compiler need in order to enforce the law? (§6 — a unique owner responsible for ending a place, borrows declared as shared or exclusive, and a precise notion of how long each name is still needed.)
Companion reads: C++ · 04 The heap and the lifetime problem (dangling pointer, double free and the invalidation preview: this lesson's first three rows),C++ · 13 STL containers (iterator invalidation rules), C++ · 18 Atomics (why a data race is undefined), and Go · 09 Channels and CSP ("share memory by communicating" — this law as a convention).