Reading Rust

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.

50 minproductive failurecontrasting casesPOE
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.

Attempt first β€” 10 minutes, honestlywrite it β€” thinking it does not count

Model this in Rust with only what Part 2 has taught you: ownership, &, &mut.

A Document has many Sections. Each Section needs a reference back to its Document so 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

Unlearn thiscarried over from the assumption that safety features can be switched off
You think

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.

Wrong here

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.

Instead

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

TypeGives youRule enforcedCostThreads?
Cell<T>get / set whole valuesTrivially β€” no references ever escapezeroNo
RefCell<T>real & and &mutAt run time; panics on violationa counter per borrowNo
Rc<T>shared ownershipNot a mutability tool β€” Rc alone is read-onlynon-atomic refcountNo
Arc<T>shared ownershipSame, across threadsatomic refcountYes
Mutex<T>&mut via a guardAt run time; blocks instead of panickinglock/unlockYes
RwLock<T>many readers or one writerAt run time; blocksmore than MutexYes
UnsafeCell<T>raw permissionBy you, in your headzerodepends

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.

Predict firstcommit in writing, then look
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);
}

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 β€” Rc and RefCell are single-threaded. If this data ever needs to cross a thread boundary you rewrite to Arc<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::forget is 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 Copy and 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.”

Explain it to yourselfwrite it β€” thinking it does not count

For each, name the tool and one sentence of justification:

  1. A request counter incremented from one thread, read occasionally.
  2. A configuration struct loaded once and read by twenty threads, never modified.
  3. A cache shared by an async web handler, read often, written rarely.
  4. 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:

  1. State the borrow rule. (2.1)
  2. Give the three reasons it exists. (2.1)
  3. Why is &mut T not Copy? (2.2)
  4. What is a lifetime? (2.3)
  5. What are the two meanings of 'static? (2.4)
  6. Six of the nine error codes are the same rule β€” which rule? (2.5)
  7. What does RefCell change 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.