Part 2 Β· The compile-time machine I β borrows & lifetimes
The escape hatches
Ownership is a tree. Your data might be a graph. Interior mutability does not break the rule β it moves the check from compile time to run time, and you pay for it.
What this lesson is trying to break (3)
- `RefCell` turns off the borrow checker.
- `Rc<RefCell<T>>` is the normal way to write shared mutable state in Rust.
- Reference counting means memory cannot leak.
Try this before reading anything else. It is meant to fail.
Model this in Rust with only what Part 2 has taught you: ownership, &, &mut.
A
Documenthas manySections. EachSectionneeds a reference back to itsDocumentso it can ask about document-wide settings. The document must remain editable β you can add sections at any time.
Write the struct definitions. Do not write the methods. Just the two structs and their fields.
Get properly stuck. Write down the exact point at which it stops working, and what the compiler would say.
You cannot do it, and the reason is precise: ownership is a tree. Document
owns its Sections. A back-reference from Section to Document makes it a
cycle, and there is no way to write that with & without the lifetime propagating
into both structs and then deadlocking on itself β Document<'a> containing
Section<'a> containing &'a Document<'a> is a type that cannot be constructed.
That failure is worth having had. Everything below exists because of it, and the tools make much more sense when you have felt the gap they fill.
The key idea
RefCell is the escape hatch that turns the borrow checker off for a value,
the way any turns off TypeScript. If the compiler is in your way, wrap it in
a RefCell and get on with your life.
The rule is not disabled. RefCell enforces exactly the same rule β
shared XOR exclusive β just at a different time. Break it and you do not get
a compile error; you get a panic at run time.
Interior mutability relocates the check, and you pay for the relocation: a runtime cost, a panic path, and the loss of a compile-time guarantee. That is a real trade, sometimes a good one. It is not an escape.
Everything in std::cell is built on one primitive: UnsafeCell<T>, the single
type in the language where the compiler permits mutation through a &. Every
safe wrapper β Cell, RefCell, Mutex, RwLock, the atomics β is
UnsafeCell plus a discipline that makes it sound.
The menu
Varies: who enforces the rule, and when
| Type | Gives you | Rule enforced | Cost | Threads? |
|---|---|---|---|---|
Cell<T> | get / set whole values | Trivially β no references ever escape | zero | No |
RefCell<T> | real & and &mut | At run time; panics on violation | a counter per borrow | No |
Rc<T> | shared ownership | Not a mutability tool β Rc alone is read-only | non-atomic refcount | No |
Arc<T> | shared ownership | Same, across threads | atomic refcount | Yes |
Mutex<T> | &mut via a guard | At run time; blocks instead of panicking | lock/unlock | Yes |
RwLock<T> | many readers or one writer | At run time; blocks | more than Mutex | Yes |
UnsafeCell<T> | raw permission | By you, in your head | zero | depends |
Read the rows in pairs. RefCell:Mutex is the same relationship as single-threaded:multi-threaded β and so is Rc:Arc. That symmetry is the whole design.
Two things fall out of the table that people routinely get wrong.
Rc is not a mutability tool. Rc<T> gives shared ownership, and shared
ownership means you only ever get &T out of it. To mutate you need
Rc<RefCell<T>> β two separate mechanisms doing two separate jobs. The
combination is common enough to look like one thing, but it is not.
Cell is genuinely free. It never hands out a reference, so there is nothing
to track. You replace whole values (get, set, replace, take). For a
counter or a flag, Cell is the right tool and costs nothing at all β a fact
that surprises people who think all interior mutability is expensive.
use std::cell::RefCell;
fn main() {
let c = RefCell::new(vec![1, 2, 3]);
let first = c.borrow();
c.borrow_mut().push(4);
println!("{:?}", first);
}It compiles cleanly and panics at run time:
thread 'main' panicked at src/main.rs:7:7:
already borrowed: BorrowMutErrorThis is exactly the E0502 from 2.1 β a shared borrow live across a mutable one β except nobody caught it until the line executed.
That is the trade stated as plainly as it can be. Same rule, same violation, different moment of discovery. The compile error costs you five minutes; the panic costs you a production incident on the code path your tests did not cover.
Use RefCell when you genuinely need it. Do not use it to make an error message
go away.
Rc<RefCell<T>>, and its bill
This is the pattern that solves the opening exercise:
use std::rc::{Rc, Weak};
use std::cell::RefCell;
struct Document {
sections: RefCell<Vec<Rc<Section>>>,
}
struct Section {
doc: Weak<Document>, // note: Weak, not Rc
}
It works. It is also the pattern newcomers reach for far too often, so it is worth naming the costs explicitly:
- Runtime borrow tracking, and a panic path in code that was not there before.
- No
SendβRcandRefCellare single-threaded. If this data ever needs to cross a thread boundary you rewrite toArc<Mutex<T>>, and that is not a find-and-replace. - Cycles leak. This is the big one.
Rc is reference counting with no cycle collector. If Document holds
Rc<Section> and Section holds Rc<Document>, both counts stay at 1 forever
and neither is ever dropped. Memory leaked, quietly, with no unsafe code and no
warning.
That is why the field above is Weak<Document>. A Weak does not contribute to
the strong count; you call .upgrade() to get an Option<Rc<T>> and handle the
case where the target is gone.
Worth being precise about in conversation: Rust does not prevent memory leaks, and never claimed to. Leaks are safe.
mem::forgetis a safe function. The guarantee is about use-after-free and data races, not about leaking β and people who say βRust prevents memory leaksβ are overselling it in a way a knowledgeable listener will notice.
What experienced Rust programmers do instead
Here is the part most tutorials never get to, and it is the most useful thing in this lesson.
For graph-shaped data, real Rust codebases usually do not use
Rc<RefCell<T>>. They use an arena with indices:
struct Graph {
nodes: Vec<Node>, // the arena owns everything
edges: Vec<(usize, usize)>,
}
struct Node {
name: String,
// no pointers, no lifetimes, no Rc
}
Nodes refer to each other by usize index. What that buys:
- Ownership stays a tree β the arena owns every node, so no cycles, no leaks, no refcounts.
- Indices are
Copyand carry no lifetimes, so they can be stored anywhere, sent between threads, and serialised. - Everything is contiguous, which is dramatically better for cache locality than a graph of separate allocations.
The cost is that an index is not checked: remove a node and old indices now point
at the wrong element or out of bounds. Real libraries fix this with generational
indices β the index carries a generation counter, so a stale index fails to
resolve. That is what slotmap and petgraph do, and it is why petgraph
addresses nodes with NodeIndex rather than pointers.
Knowing this distinguishes someone who has read about Rust from someone who has
written it. The newcomer question is βhow do I do a linked list in Rust?β; the
experienced answer is usually βuse a Vec and indices, and ask why you wanted a
linked list.β
For each, name the tool and one sentence of justification:
- A request counter incremented from one thread, read occasionally.
- A configuration struct loaded once and read by twenty threads, never modified.
- A cache shared by an async web handler, read often, written rarely.
- A tree of UI nodes where each child needs to reach its parent.
If you reached for Arc<Mutex<T>> more than once, look again β at least two of
these have a cheaper answer.
Part 2 checkpoint
Out loud, no notes:
- State the borrow rule. (2.1)
- Give the three reasons it exists. (2.1)
- Why is
&mut TnotCopy? (2.2) - What is a lifetime? (2.3)
- What are the two meanings of
'static? (2.4) - Six of the nine error codes are the same rule β which rule? (2.5)
- What does
RefCellchange about the borrow rule? (2.6)
You have now crossed the hardest part of the course. Part 3 is a different machine β the trait solver β and most people find it substantially easier, because it rewards the kind of type-level thinking a TypeScript developer already has.
Next: 3.1 β Enums are TypeScript discriminated unions, done right.