So far, every borrow checker you’ve met enforces the law at compile time — and sooner or later you’ll write a structure it can’t express: a doubly linked list, a graph, a cache shared by eight components. The compiler says “no,” as always — but it never said “never.” Behind every “no,” the standard library keeps a door with a price tag hanging on it: one moves your data, one trades counting for sharing, one moves the check from compile time to runtime and trades a panic for flexibility. This part is the manual for those three doors: Box, Rc, RefCell — what they are, which rule each one relaxes, what each one costs, and why you should take the cheapest door most of the time.
Box<T>: not an escape hatch — an entry ticket
First, correct the most widely repeated misconception: Box<T> relaxes zero ownership rules. It’s a pointer to heap-allocated data with a single owner, checked at compile time, that cleans up its contents when it goes out of scope — every rule from Part 2 intact. The problem it solves is different: giving a value a known size.
Try writing a linked list without it:
enum BadList {
Cons(i32, BadList), // error[E0072]: recursive type has infinite size
Nil,
}
A Cons holds a BadList, which holds a BadList, which holds… the compiler can’t compute how many bytes the type occupies, because the answer is “infinitely many.” Box truncates that infinity down to the size of one pointer:
enum List {
Cons(i32, Box<List>), // a Box is 8 bytes — known size, legal type
Nil,
}
use List::{Cons, Nil};
let list = Cons(1, Box::new(Cons(2, Box::new(Cons(3, Box::new(Nil))))));
let mut total = 0;
let mut cursor = &list;
while let Cons(value, next) = cursor {
total += value;
cursor = next; // &Box<List> auto-derefs to &List
}
println!("sum: {total}"); // 6
Look at the cursor = next line in the traversal: next has type &Box<List>, and it’s silently used where an &List is expected — that’s the machinery from Part 5 at work. Box implements the Deref trait, so it behaves like a reference everywhere a reference is wanted; it implements Drop, so the heap data dies with its owner. The “smart” in smart pointer comes entirely from these two traits — they’re not new language features, they’re contracts you already know.
The price of Box: one heap allocation and one indirection. Three legitimate uses: recursive types, moving ownership of large data without moving the data itself, and holding trait objects (Box<dyn Error>, in depth in Part 7). That’s all it does — it doesn’t share, and it won’t get you around the mutability rules.
Rc<T>: shared ownership, still checked at compile time
Some data structures have no answer to “who is the single owner”: one config held by ten components, one graph node pointed at by three edges. Compile time can’t tell who uses it last — and “can’t tell” is exactly what the compiler rejects. Rc<T> (reference counted) answers: fine, don’t name an owner — count instead.
use std::rc::Rc;
let config = Rc::new(String::from("production"));
let for_handler = Rc::clone(&config); // count 2 — no data copied
let for_logger = Rc::clone(&config); // count 3
println!("count = {}", Rc::strong_count(&config)); // 3
drop(for_handler);
println!("count = {}", Rc::strong_count(&config)); // 2
Three properties, in order of importance:
Rc::cloneclones a handle, never the data. WritingRc::clone(&config)instead ofconfig.clone()is community convention — the former screams “just a count increment,” the latter makes readers worry about a deep copy. That naming discipline is a gift to whoever reads the code in three months. (It’s you.)- The data dies with the last handle. No owner, just the moment the count reaches zero. The rule is relaxed, but cleanup stays deterministic —
Dropruns as always. Rchonestly refuses to cross threads. Its count isn’t atomic, soRcdoesn’t implementSend— sending it tostd::thread::spawnis a compile error. The multi-threaded version isArc(atomic Rc), coming in Part 9. This isn’t a flaw; it’s the recurring theme of this series: encode the truth in the type, stop the mistake at build time.
Two prices to know: the count itself costs (every clone and drop mutates it), and an Rc cycle — A holds B, B holds A — keeps the count forever above zero, leaking memory quietly. The fix is Weak<T>, a “weak” handle that doesn’t bump the strong count; in parent-child structures, the child’s back-pointer uses Weak. Knowing it exists is enough; the docs will catch you when you need it.
And one rule Rc never touches: shared references remain read-only. Ten handles pointing at the same data are ten shared borrows — Part 2’s readers-XOR-writer rule still stands guard at compile time. Want to mutate the data? That’s the next door.
RefCell<T>: borrow checking, moved to runtime
What RefCell<T> does fits in one sentence: Part 2’s “many readers XOR one writer” rule, verbatim — but enforced at runtime instead of compile time. This is interior mutability: the value claims to be immutable (let, not let mut), yet internally lets you request mutable access:
use std::cell::RefCell;
let log: RefCell<Vec<String>> = RefCell::new(Vec::new());
log.borrow_mut().push("started".into()); // runtime-granted mutable borrow
{
let snapshot = log.borrow(); // shared borrows coexist as usual
println!("{} entries", snapshot.len());
// log.borrow_mut(); // PANICS here: already borrowed
} // snapshot dies here
log.borrow_mut().push("stopped".into()); // fine again
The guards returned by borrow() and borrow_mut() (Ref / RefMut) return their “borrow slot” via Drop when the scope ends. And the price of a violation is paid in a different currency: the compile error becomes a panic. A borrow_mut() that meets a living borrow() kills the thread on the spot with already mutably borrowed: BorrowMutError. The rule didn’t disappear — what disappeared is the compiler’s proof, and what you gained is the ability to write the class of code that’s “safe but unprovable.” If your old language checked everything at runtime anyway, this sounds unremarkable; the difference is that in Rust this is a trade you explicitly chose, not a default tax.
Like Rc, RefCell lives on a single thread — the cross-thread counterparts are Mutex / RwLock, also Part 9. And stacking the two single-threaded tools produces one of the most frequent combos in the Rust ecosystem:
use std::cell::RefCell;
use std::rc::Rc;
#[derive(Debug)]
struct Counter { hits: RefCell<u32> }
let counter = Rc::new(Counter { hits: RefCell::new(0) });
let handler_view = Rc::clone(&counter);
let logger_view = Rc::clone(&counter);
*handler_view.hits.borrow_mut() += 1; // mutate through one shared handle
*logger_view.hits.borrow_mut() += 1; // mutate through another
println!("{counter:?}"); // Counter { hits: RefCell { value: 2 } }
The division of labor is exact: Rc provides “many places can hold it,” RefCell provides “and they can still change it.” Rc<RefCell<T>> is the Rust equivalent of “a plain object reference” in other languages — except every capability has its price printed on the type, visible to the reader at a glance. GUI state, test doubles, callback registries: you’ll meet it constantly.
The price list: rules are never free, but some doors are cheap
The three doors, ordered by “which should you think of first” — which happens to also be by price:
Box<T>— relaxes zero rules, buys an entry ticket to the heap. Price: one allocation. Recursive types, large-value moves, trait objects — you need no excuse to reach for it.Rc<T>— relaxes “single owner,” buys shared ownership. Price: counting overhead, single-thread confinement, cycle-leak risk (Weakfixes).RefCell<T>— relaxes “compile-time borrow checking,” buys runtime flexibility. Price: violations panic — errors deferred from build time to tests (or worse, production).
The engineering judgment compresses to one sentence: what can be proven at compile time, don’t pay to move to runtime. Start with Box — “do I really need sharing?” If not, Box is the end of the road. Sharing without mutation: Rc plus immutable data goes further than you’d think. Only when “shared and mutable” both hold does Rc<RefCell<T>> take the stage. You now hold the key to the door behind every “no” the borrow checker says — and the person who knows the door prices is the one who appreciates the free roads in front of them.
Practice, then Part 7
- Complete the
Listabove: write a recursivesum(&self) -> i32, then anappend(self, v: i32) -> List. Now remove theBoxand read E0072 in full — the words “infinite size” will have a concrete shape for you from now on. - Do the
Rcdrill: share anRc<String>, printstrong_countbefore and after each clone and drop, and watch the count breathe. Then send it tostd::thread::spawnand read the rejection — that “notSend” is single-thread honesty incarnate, and a trailer for Part 9. - Build a shared log with
Rc<RefCell<Vec<String>>>: two handles pretending to be “components” can both append and read. Then deliberately overlap aborrow_mut()with a livingborrow()and read the panic message in full — notice it tells you exactly which line the first borrow happened on. The runtime law-enforcer still gives a precise statement.
Part 7 unfolds “anything that signed the contract” into its full grammar: generics and trait bounds, the two directions of impl Trait in argument and return position, the trade-off between static dispatch and dyn, and the return-position lifetime capture that the 2024 edition fixed with + use<...> — the detail Part 2 owes you, repaid with interest.