all_lessons/Rust/01 · The promise, made preciselesson 2 / 23

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.

The thesis, here
Every design starts by turning a slogan into a specification. "Safe code has no undefined behavior" is a slogan; "the compiler rejects any program in which a name can be used after another name has ended, moved or changed what it points to" is a specification. The interesting discovery is how small the specification is once you factor out the cheap cases — and how thoroughly it explains why Rust looks the way it does.
Linear position
Forced by: Suppose safe code must never exhibit undefined behavior, with no garbage collector and no run-time checking beyond the cheap ones. "Undefined behavior" is a long list of different bugs, and a compiler can only reject what it can recognize. What exactly must be ruled out, and what does each rule need to know?
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?
The plan
Six moves. (1) Ask, of each undefined-behavior family, what a checker would need to know. (2) Watch five of them dissolve into one local fact each. (3) Take the other two apart with three words — place, name, effect — and see they are the same bug. (4) Play the detector on ten traces. (5) State the law, derive why it is shared XOR exclusive and not something weaker, and measure what it buys the optimizer. (6) List what a machine would need in order to enforce it — which is the outline of Part II.

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?"

FamilyThe bug in one lineWhat a checker must knowWhere that knowledge can come from
Out-of-bounds accessa[i] with i past the endthe length of a, at this accesscarry it with the pointer — a slice is an address and a length — and compare (Lesson 02)
Uninitialized readread a variable nothing assignedwhether every path to here assigned itflow analysis inside one function (Lesson 02)
Signed overflowINT_MAX + 1what the answer isdefine it: a panic with overflow checks on, a wrap with them off (Lesson 02)
Null dereference*p where p is nullwhether p may be absentmake absence a type; a reference can never be null (Lesson 09)
Type punningread a float's bytes as an int through a pointer castthat two types share storagemake the cast inexpressible in safe code (Lesson 19)
Use-after-free, double free, dangling reference, iterator invalidationa name outlives, or disagrees with, the thing it nameswhich other names can reach this place, and until whennothing local — this is the hard one
Data racetwo threads, one location, at least one writerthe same, across threadsnothing 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:

place
A location in memory that can hold a value: a local variable, a struct field, an array slot, a heap buffer. A place exists for a stretch of time and then ends.
name
Anything through which a place can be reached: the variable itself, a pointer or reference to it, an iterator into it, another thread's copy of the address. Two names alias when both can reach the same place.
effect
What a name does to a place: read it, write it, or end it — free it, move it, or reallocate the buffer that holds it. A read changes nothing. A write or an end changes what the place holds or whether it exists.

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:

BugName that actsIts effectName that is hurtWhen it is hurt
use-after-freepends the buffer (free)q, a copy of the addressq is dereferenced
double freeaends the bufferb, a second "owner"b ends it again
iterator invalidationvreallocates while growingit, an iterator into the old bufferit advances
dangling returnthe callee's frameends its locals on returnr, the returned pointerthe caller reads r
data race, write/writethread 1writesthread 2's namethread 2 writes
data race, read/writewriter threadwritesreader thread's namethe 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.

Seven bugs, one detector
One place, a few names, a few events. The detector knows nothing about bugs — only effect by A while B (created earlier) is used later. Drag the step slider to reveal the trace one event at a time.
hazards found
—
C++ outcome
—
Rust verdict
—
Show the core JS
// An effect (write or end) by A hurts every OTHER name B that already exists
// (created before the effect) and will be used again (some event after it).
// An event is [step, who, act, text]; act is mk | rd | wr | end.
function hazards(tr, upto) {
  var ev = tr.ev.slice(0, upto), out = [];
  ev.forEach(function (e) {
    if (e[2] !== 'wr' && e[2] !== 'end') return;
    tr.names.forEach(function (nm) {
      var B = nm[0]; if (B === e[1]) return;
      var created = -1, next = -1;
      ev.forEach(function (f) {
        if (f[1] !== B) return;
        if (f[2] === 'mk' && created < 0) created = f[0];
        if (f[0] > e[0] && f[2] !== 'mk' && next < 0) next = f[0];
      });
      if (created >= 0 && created < e[0] && next > e[0]) out.push({ a: e[1], at: e[0], act: e[2], b: B, created: created, use: next });
    });
  });
  return out;
}

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.

use-after-free — a name outlives the end of its place
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
}
double free — two names both believe they must end it
fn main() {
    let a = String::from("x");
    let b = a;            // ownership moves to b
    drop(a);              // a no longer exists
    drop(b);
}
iterator invalidation — an iterator is a name for the buffer
fn main() {
    let mut v = vec![1, 2, 3];
    for x in &v {
        v.push(*x);       // may move the buffer under the iterator
    }
}
dangling return — the callee's frame ends its locals
fn shout(text: &str) -> &str {
    let owned = text.to_uppercase();
    &owned                // a name for something about to end
}
fn main() { println!("{}", shout("hi")); }
data race — two threads, one counter
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.

Reading the error
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:

The law · shared XOR exclusive (aliasing XOR mutation)
For every place, at every moment, either any number of names may reach it and none may write or end it (shared, read-only), or exactly one name may reach it and that name may write or end it (exclusive). Never many names and a writer.

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}");
}
Road not taken · functional programming takes one side of the law
The Scala track makes exactly this observation from the other end: if nothing is ever written, sharing is unlimited and nobody can be hurt — immutable data plus a collector. That is the first regime, universalized. Rust keeps both regimes and lets each use choose one, checking at compile time that they never overlap. The trade: FP buys simplicity by giving up in-place update (and paying for the collector); Rust buys in-place update by paying in checking effort.

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.

Sound, not complete
The law is a sufficient condition, checked conservatively. There will be programs that are safe which the checker still rejects — the integer above is one; Lesson 07 shows subtler ones. This is unavoidable in principle: what an arbitrary program will do later cannot be decided exactly (it is the halting problem), so a checker that accepts only safe programs must also refuse some safe ones. Rust's design choice is to err on the side of rejecting, and to provide two escape hatches — types that move the same check to run time (Lesson 15) and 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:

1 · A unique name responsible for ending
"End" is an effect, and it must happen exactly once, by exactly one name. Something has to be the owner. (Lessons 03, 04.)
2 · A way for other names to declare their intent
The checker can only apply the law to a name it knows is a reader or a writer. Borrowing must come in two kinds: shared and exclusive. (Lesson 05.)
3 · A way to know how long a name matters
"While another name will still be used" is a claim about time. The checker needs a precise notion of when a name is done. (Lessons 06–08.)

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

"Aliasing is the problem"
Aliasing without a writer is harmless and everywhere: twelve readers of one string. The law forbids the overlap of aliasing and mutation, not aliasing.
"Mutation is the problem"
A lone writer is fine — it is the second regime. What hurts is a writer who has company that will still be used.
"This is a threading rule"
Iterator invalidation and dangling returns happen on one thread. Threads only remove the ordering; the pattern is unchanged, which is why one diagnostic (E0499) covers both.
"Only pointers count as names"
An iterator, a slice, a closure that captured a variable, and another thread's copy of an address are all names. Anything that can reach the place and will be used again counts.
"Rejected means buggy"
Rejected means not shown safe. The harmless-integer example is rejected and correct. Read a rejection as "restructure so the proof is visible", not as an accusation.
"A read can't hurt anyone"
A read by one thread while another writes is a data race, and a read through a stale name after an end is a use-after-free. Reads are harmless only when nobody writes or ends the place meanwhile.

Checkpoint exercise

Try it
Open the widget and take the "harmless int write" trace. With the law off, the detector still flags it — find the three positions (where the reader was created, where the write happens, where the reader is used again) and confirm they are the three lines the compiler labelled in Lesson 00. (They are t1, t2 and t3: the detector flags the write at t2.) Now go back to your bug from the Lesson 00 checkpoint. Fill in the five-column table from §2 for it: the bug, the name that acts, its effect, the name that is hurt, and when. If you cannot fill it in, you have not yet found the second name — and finding it is the real debugging step. Finally, decide which of the three needs in §6 your bug would have been stopped by.

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?

Takeaway
Ask of each undefined-behavior family what a checker would need to know. Five families need one local fact (a length, an assignment on every path, an arithmetic answer, absence as a type, no unchecked casts) and yield to cheap local rules. Use-after-free, double free, dangling returns, iterator invalidation and data races are one hazard: an effect (write or end) by one name on a place while another name, created earlier and used later, can still reach it. Forbid that overlap and they all vanish — that is the law, shared XOR exclusive: many readers, or one writer, never both. The compiler already treats a data race as the same bug as two &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

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).