Skip to main content

Series tracker: The Borrow Checker

Slug: borrow-checker ยท set series.name: borrow-checker on each post's front matter when it goes live, series.order = the # column below.

Nine posts. One frame โ€” building code you've obeyed without reading โ€” and one build artifact: an LRU cache implemented four times, one implementation per escape hatch. The value of every post is in the specific thing the exercise produces: the exact compiler error, the exact panic, the exact benchmark delta, the exact Miri finding. Those can't be reconstructed from memory, so the rule is: run the exercise before writing the post.

Each post is structured for all experience levels at once (one spine, typed Floor/Deep/Cross dropdowns) and pushes the reader toward running the code. That convention is specified once in engagement-patterns.md and reused across every post โ€” post 1 is where it was worked out.

Statusโ€‹

ex = exercise run and output captured ยท draft = markdown written, draft: true ยท live = draft: false

#postexdraftlive
1The rule you've been obeying without knowing itโ˜โ˜‘โ˜
2Borrows end where you stop using them (NLL)โ˜โ˜โ˜
3'static does not mean "lives forever"โ˜โ˜โ˜
4Ownership has to be a tree (the naive doubly-linked list)โ˜โ˜โ˜
5Escape hatch one: move the check to runtime (Rc<RefCell>)โ˜โ˜โ˜
6Escape hatch two: make it a tree by fiat (arena / indices)โ˜โ˜โ˜
7Escape hatch three: raw pointers, and Miriโ˜โ˜โ˜
8The checker is conservative, not complete (NLL case #3)โ˜โ˜โ˜
9Why async is the hard part (Pin)โ˜โ˜โ˜

Repo: lru โ€” one repo for the whole series, four LRU implementations in it, linked from every post. That repo is a better portfolio artifact than any single post. (Add the full GitHub URL here once it's pushed public.)

If it stalls: posts 1โ€“3 stand alone as a complete short series; 4โ€“7 become a second one. Ship in those halves rather than blocking on all nine.


The frame: building codeโ€‹

The analogy that runs through the series (replacing the throwaway "celebrity" version). You want something omnipresent, named, unidentifiable, and enforcing โ€” which kills "bacteria" and "tensile strength" (present, but they don't say no).

Building code โ€” the series frame. You've never read it; it shaped every room you've stood in. You only meet it when your renovation gets rejected. It fits better than a celebrity because it's conservative by design (written for the worst case, so it forbids things that would've been fine), there's a formal way around it (permit, engineer's stamp โ€” that's unsafe), it varies by edition (editions, NLL vs. Polonius), and nobody resents it once they see what it prevents. It stretches to the end: post 8 is "the code rejects a sound design," a story every contractor has.

Load-bearing walls โ€” the sub-analogy for ownership. You know the phrase; you can't point at which walls in your own house are load-bearing. You find out by knocking one down. "Ownership must form a tree" and "structural loads must reach the ground" are the same claim. Use this inside posts 4โ€“6.

Right-of-way โ€” for aliasing XOR mutation in post 1. You've driven thousands of intersections and can't state the uncontrolled four-way rule. The rule is: one party proceeds at a time. Literally the borrow rule.

Three registers of one metaphor family, so it stays coherent without repeating.

The arcโ€‹

Three acts:

  1. It's protecting you from something real โ€” posts 1โ€“3.
  2. Your data doesn't fit, and here's the design space โ€” posts 4โ€“7.
  3. It's conservative, not correct โ€” posts 8โ€“9.

The middle act is carried by the one artifact: the LRU cache, four times. Each escape hatch gets its own post and its own working code.


Postsโ€‹

1. The rule you already followโ€‹

Thesis: The borrow checker isn't about threads. It's aliasing XOR mutation, and it bites hardest in single-threaded code.

Repro (Go โ€” compiles, broken):

s := []int{1, 2, 3}
p := &s[0]
s = append(s, 4) // may reallocate
*p = 99 // writes into the dead array

Exercise: Write that Go program. Run it with a slice small enough to force reallocation and confirm the write goes nowhere. Then write the Rust equivalent and paste the exact compiler error โ€” the error message is the punchline.

Frontmatter stub:

title: "The Borrow Checker, Part 1: The Rule You Already Follow"
tags: [rust, borrow-checker, memory]

Insights captured while running: (fill after doing the exercise)

2. Borrows end where you stop using themโ€‹

Thesis: Non-lexical lifetimes. A borrow's life ends at its last use, not at the closing brace. Pre-2018 Rust was the other way, and most "why is Rust so painful" folklore predates this.

Exercise: Write a function that fails under lexical lifetimes but compiles today (mutate a collection after the last read of a borrow into it). Then read the NLL RFC's motivation section and note which of its three problem cases got fixed. Cases 1 and 2 shipped. Case 3 is post 8.

Insights captured while running: (fill after doing the exercise)

3. 'static does not mean "lives forever"โ€‹

Thesis: T: 'static means "contains no references with a lifetime shorter than 'static." An owned String satisfies it. The single most common misconception in the language.

Exercise: Write fn spawn<T: Send + 'static>(t: T). Pass an owned String (works). Pass a &str borrowed from a local (fails). Pass a &'static str (works). Write down why, in your own words, before reading anyone else's explanation.

Insights captured while running: (fill after doing the exercise)

4. Ownership has to be a treeโ€‹

Thesis: The naive doubly-linked list. Every node needs a back-pointer; ownership can't be a DAG. Fail honestly and in public. (Load-bearing walls earn their keep here.)

Exercise: Attempt the LRU cache (attempt 1) with plain Box<Node> and &mut links. Document the exact sequence of errors as you fight it. Don't skip ahead โ€” the post is worthless if you didn't actually get stuck.

Insights captured while running: (fill after doing the exercise)

5. Escape hatch one: move the check to runtimeโ€‹

Thesis: Rc<RefCell<T>>. Same rule, enforced at runtime with a panic instead of at compile time with an error.

Exercise: LRU attempt 2, working. Then deliberately trigger a BorrowMutError โ€” call a method that borrows while a borrow is live. Capture the panic. That panic is exactly the bug post 1's Go program had silently.

Insights captured while running: (fill after doing the exercise)

6. Escape hatch two: make it a tree by fiatโ€‹

Thesis: Arena allocation. Nodes live in a Vec, links are usize indices. Ownership becomes a tree because you declared it one; the indices are pointers the compiler doesn't have to reason about.

Exercise: LRU attempt 3. Benchmark it against attempt 2 with criterion. Report the delta and explain it โ€” trading pointer chasing and refcount traffic for contiguous memory. Include a flamegraph.

This is one of the two posts that will actually get read โ€” it's where the series stops being about syntax and starts being about layout.

Insights captured while running: (fill after doing the exercise)

7. Escape hatch three: raw pointers, and the tool that checks themโ€‹

Thesis: unsafe with *mut Node โ€” what std::collections::LinkedList actually does. Then Miri.

Exercise: LRU attempt 4. Then cargo +nightly miri test. Miri checks your unsafe against Stacked Borrows, the formal aliasing model. Good chance it finds something in code that compiled and passed your tests โ€” if it does, that's the whole post. If it doesn't, write about what it was checking for.

The climax of the series. The compiler is one enforcement layer; Miri is the layer underneath it, and most Rust developers have never run it.

Insights captured while running: (fill after doing the exercise)

8. The checker is conservative, not completeโ€‹

Thesis: It rejects sound programs. NLL Problem Case #3.

Repro:

fn get_or_insert(map: &mut HashMap<K, V>, k: K) -> &V {
if let Some(v) = map.get(&k) { return v; } // rejected
map.insert(k, default());
map.get(&k).unwrap()
}

Exercise: Write it. Get rejected. Implement both standard workarounds (the double-lookup, and the entry API). Then read where Polonius currently stands and note what it would change.

Why it matters: it changes the reader's relationship to the compiler. Rejection stops meaning "you are wrong." That's the emotional payoff of the whole series. (Building-code callback: the sound design that fails inspection anyway.)

Insights captured while running: (fill after doing the exercise)

9. Why async is the hard partโ€‹

Thesis: A future holding a reference across an .await is self-referential. Self-referential structs are the one thing ownership genuinely cannot express. Hence Pin. Hence "single-threaded Rust is fine, async Rust is brutal."

Exercise: Write an async fn holding a & to a local across an await. Look at what the compiler generates conceptually. Then explain Pin in one paragraph without using the word "pin."

Closer: points at what's next without pretending to have covered it.

Insights captured while running: (fill after doing the exercise)


Execution notesโ€‹

  • Do the exercises before writing. Every post's value is the specific error, panic, or benchmark number. That's what separates this from the hundred other borrow-checker explainers.
  • One repo, four LRU implementations, linked from every post โ€” a better portfolio artifact than any individual post.
  • Posts 6 and 7 are the ones that will get read. Benchmarks and Miri output are scarce; explanations of ownership are not.
  • Standalone LRU explainer (drafted): what an LRU cache is โ€” the "if you don't even know what an LRU cache is, head over here" entry point, linked from the repo and the natural prerequisite for post 4. Carries the first data-structure animation (approach decided in engagement-patterns.md).

Comments

No comments yet. Be the first!