Skip to content
advanced Phase 11 · Smart Pointers and Interior Mutability

Cell, RefCell, and Interior Mutability

Mutate data behind a shared reference while enforcing rules at runtime.

50m
0 problems
Topic Progress 0%

&self Methods That Mutate: The Rules, Checked at Runtime

A hit counter that any component can bump, a cache filled on first request, a flag flipped during traversal — all want to mutate through &self, because the callers hold shared references. The borrow checker says no: &self is &Self, and writing self.hits += 1 inside a fn hit(&self) is error[E0596]: cannot borrow ... as mutable, as it is not declared as mutable. You could demand &mut self, but then every holder would need exclusive access — impossible when the value is shared.

Cell<T> and RefCell<T> solve this by moving the enforcement point. The rules — many shared borrows or one exclusive borrow — stay exactly the same; what changes is when they are checked: not by the compiler over your source, but by a tiny flag inside the value as you borrow it.

use std::cell::Cell;

struct Stats { hits: Cell<u32> }

impl Stats {
    fn hit(&self) {                                  // note: &self, still mutates
        self.hits.set(self.hits.get() + 1);          // no guards, no borrow flags
    }
}

let s = Stats { hits: Cell::new(0) };
s.hit(); s.hit();
assert_eq!(s.hits.get(), 2);                         // Copy in, Copy out

RefCell<T> is the general form — it hands out actual guarded borrows:

use std::cell::{RefCell, RefMut};

struct Registry { entries: RefCell<Vec<String>> }

impl Registry {
    fn add(&self, name: &str) {
        let mut list: RefMut<'_, Vec<String>> = self.entries.borrow_mut();  // runtime check: PASS
        list.push(name.to_string());
    }                                                 // RefMut dropped here -> flag released
}

let reg = Registry { entries: RefCell::new(Vec::new()) };
reg.add("alpha");
assert_eq!(reg.entries.borrow().len(), 1);            // borrow() also checks, then passes

borrow() and borrow_mut() return Ref<T>/RefMut<T> guards, which deref to T and release the flag on drop — scope-based release, identical in spirit to a lock guard. While a RefMut is alive, a second borrow_mut() is refused, because that would mean two &mut to one value: the compiler's E0499, reborn as a runtime check.

The refusal has two faces, and you should know both strings:

let cell = RefCell::new(vec![1]);
let a = cell.borrow_mut();               // exclusive borrow held...
// cell.borrow();                        // thread 'main' panicked at 'already mutably borrowed: BorrowError'
// cell.borrow_mut();                    // thread 'main' panicked at 'already borrowed: BorrowMutError'
let e = cell.try_borrow().unwrap_err();  // Err(BorrowError) instead of a panic
assert_eq!(e.to_string(), "already mutably borrowed: BorrowError");

try_borrow()/try_borrow_mut() return Result — the API for code that must recover (skip the cache update, retry later) instead of crashing. And to be clear about what this is: RefCell is implemented with unsafe internally, but soundly — it maintains the borrow invariants with a flag rather than a static analysis, so safe code still cannot obtain two overlapping &mut. That distinction — rules preserved, enforcement relocated — is the whole content of "interior mutability", and it is one of the most common Rust interview questions: expect to explain it in exactly those terms.

One discipline note the compiler cannot help you with: guards are values, not references. A RefMut bound to a local holds the flag until that local drops, even if you never touch it again — and since the flag is invisible to the borrow checker, the runtime check is the only referee you get. Keep guards short-lived and let them die at the end of the statement that needed them.

The pattern is everywhere once you look for it. OnceLock<T> in std is get_or_init(&self) — a cell filling a value exactly once behind a shared reference; lazy statics are the same trick; Mutex<T> hands out &mut through &self by locking first; even AtomicU32 is a cell whose flag happens to be a CPU atomic instruction. Interior mutability is not an exotic escape hatch — it is how Rust implements any API whose work happens behind &self.

Cell for Copy, RefCell for Guarded Access: Choosing the Cheaper One

Both types move mutation behind &self; they differ in how much machinery you pay for it.

Cell<T> RefCell<T>
Read get() — requires T: Copy, copies out borrow() → Ref<T> guard
Write set(v), replace(v), take() (T: Default) borrow_mut() → RefMut<T> guard
Runtime borrow flag none (no borrows exist to check) one flag; every borrow checked
Panics never yes — holding a guard wrongly
Best for counters, indices, bool flags, small Copy data Vec/String/structs you mutate in place
Bounds Send if T: Send; !Sync Send if T: Send; !Sync
use std::cell::Cell;

struct Parser { consumed: Cell<usize>, overflow: Cell<bool> }

impl Parser {
    fn advance(&self, n: usize) {
        self.consumed.set(self.consumed.get() + n);   // two reads/writes of a usize — no flag, no guard
        if n > 1024 { self.overflow.set(true); }
    }
}

Cell's operations on a Copy value are effectively a read plus a write of the raw bytes — no guard construction, no flag update, and structurally no panic path. That last property matters more than the nanoseconds: a Cell field cannot be the cause of an "already borrowed" crash in production, which is why counters and flags are Cell by convention.

Cell even reaches past Copy with take-it-out-put-it-back semantics:

use std::cell::Cell;

let queue: Cell<Option<String>> = Cell::new(None);
queue.set(Some(String::from("job-1")));
if let Some(job) = queue.take() {        // moves the value OUT, leaves None behind
    println!("running {job}");
    queue.set(Some(job));                // ownership returned — no reference ever lent
}

At no moment does a reference to that String exist, so nothing can dangle, nothing can alias, and — again — nothing can panic. Cell::swap exchanges two cells by moving and replace hands back the previous value. The cost of a Cell operation scales with size_of::<T>() because it is a copy, which is why the guidance reads "small Copy types": parking a 4 MiB buffer in a Cell makes every set expensive, and RefCell (or plain &mut) becomes the better trade.

RefCell's per-operation overhead is a flag read-modify-write plus constructing and dropping the two-pointer guard — trivial beside a lock, but it is what buys the only mechanism that lends a real &mut T through &self.

RefCell costs more because it must track live borrows: each borrow/borrow_mut reads and updates the flag, each guard is a small struct with a back-pointer, and misuse ends in a panic. In exchange you get what Cell cannot give — a real &mut T to non-Copy data, usable with any standard mutating API (list.push, map.insert, in-place edits).

Two design points worth having ready. First, Cell can handle non-Copy values too through replace/take/swap — you just never hold a reference in; you take the value out, use it, put it back (works because T: Default for take). Second, both types are !Sync even when Send: sharing a &Cell<T> across threads would race the flag itself. The thread-safe analogue of RefCell is Mutex<T> — same idea (exclusive access granted at runtime), atomic flag, blocking instead of panicking — and that pairing, Rc<RefCell<T>> versus Arc<Mutex<T>>, is exactly what chapter 3 pins down.

Interview framing: "Cell vs RefCell" is a compact question with a precise answer: guards versus copy — no flag and no panic path versus checked borrows of non-Copy data. Follow with the !Sync fact; it is the bridge to every concurrency topic after this one.

Rc<RefCell<T>>: Shared Mutable Ownership, One Thread at a Time

Put the two topics together and you get the pattern that single-threaded Rust code actually uses:

use std::rc::{Rc, RefCell};

let shared = Rc::new(RefCell::new(vec![1, 2]));
let a = Rc::clone(&shared);          // second owner (counting, topic 5)
let b = Rc::clone(&shared);          // third owner

a.borrow_mut().push(3);              // mutation through handle a — runtime-checked
b.borrow_mut().push(4);              // same allocation, allowed: a's guard is already dropped
assert_eq!(shared.borrow()[..], [1, 2, 3, 4]);

Rc alone hands out shared ownership but no writes (Deref without DerefMut); RefCell alone writes through &self but cannot multiply owners. Together: many owners, one mutable value, checked every time you borrow. Real shapes it appears in: a document model shared by a tree view and an undo log, graph nodes that several traversals mutate, a Rc<RefCell<State>> closure environment captured by many callbacks.

The failure mode is self-inflicted and predictable — a guard held across the operation that needs the next borrow:

let cell = Rc::new(RefCell::new(vec![1]));
let mut held = cell.borrow_mut();
held.push(2);
// println!("{:?}", cell.borrow());     // panics: 'already mutably borrowed: BorrowError'
                                       // the held guard is still alive (used after this line)
drop(held);                            // or end the block the guard lives in
cell.borrow_mut().push(3);             // now fine — borrows kept short

The diagnostic arrives at the second borrow, while the culprit is the first guard's live range — the same "look at both spans" discipline as a borrow-checker error, except the spans are gone and you must find the guard yourself (usually a Ref/RefMut stored in a struct field or a Vec of guards — the classic bug). The prevention rule is one line: borrow at the narrowest scope — call borrow_mut() directly on the statement that needs it, never keep a guard in a field or across a call into unknown code.

Now the trade-off table, which is where this topic meets the threads topics ahead:

Rc<RefCell<T>> Arc<Mutex<T>>
Owners many, non-atomic count many, atomic count
Access check borrow flag, instant lock acquire, may block
Failure panic ("already borrowed") block, then possible poison on panic-in-critical-section
Cost one flag word, no syscalls atomics + OS parking under contention
Threads one (!Sync) yes (Send + Sync when T: Send)

Why not Arc<RefCell<T>> for threads? Because Arc<T> is Send only when T: Send + Sync, and RefCell is !Sync — the flag has no synchronization, so even moving that Arc to another thread is rejected. If you can move but never share... you still should not: reading the flag concurrently is precisely the race !Sync forbids. Single thread → Rc<RefCell<T>> (no locking cost, panic risk you control with short borrows); crossing threads → Arc<Mutex<T>> (next phase's pattern), where blocking, not panicking, is the price of the same guarantee.

Interview framing: be ready to write Rc<RefCell<T>> from memory and then defend it: why each half is necessary, why it cannot cross threads (!Sync flag), how the panic arises, and how it compares to Mutex. That chain — ownership, mutation, thread bounds — is the interior-mutability interview in full. With mutable sharing explained for one thread, the next topic handles its nastiest consequence: Reference Cycles and Weak.

Quiz

1. Why can a RefCell hand out `&mut T` where the borrow checker would refuse?

Question 1 options

2. You hold `let a = cell.borrow_mut();` and call `cell.borrow()` while `a` is still alive. What happens?

Question 2 options

3. Which statement correctly contrasts Cell and RefCell?

Question 3 options

4. Why is `Arc<RefCell<T>>` rejected for cross-thread sharing while `Rc<RefCell<T>>` is fine on one thread?

Question 4 options

Flashcards

Question

What does interior mutability mean in Rust?

Answer

Mutating data through a shared reference while the borrow rules stay intact: Cell/RefCell (and Mutex) keep the value behind &self, replace the compiler's static check with a runtime flag, and grant borrows only when legal. Soundness is preserved — enforcement moves from compile time to runtime, it is not removed.

Question

What are RefCell's borrow APIs and their failure modes?

Answer

`borrow()` → Ref<T> (shared), `borrow_mut()` → RefMut<T> (exclusive), both flags-checked and released on guard drop; `try_borrow()`/`try_borrow_mut()` return Result. Failure while a conflicting guard is live: panics with `already mutably borrowed: BorrowError` / `already borrowed: BorrowMutError` — recoverable equivalents: Err(BorrowError/BorrowMutError).

Question

When do you pick Cell over RefCell?

Answer

For small Copy values (counters, flags, indices): Cell::get/set/replace/take copy the value with no guards, no borrow flag, and no possible panic. Pick RefCell when you need a real &mut T to non-Copy data in place (Vec, String, structs) and can accept the flag check plus the misuse-panic risk.

Question

What is the Rc<RefCell<T>> pattern, and its rules of use?

Answer

Single-threaded shared mutable ownership: Rc multiplies owners (non-atomic count), RefCell grants writes through &self (runtime-checked). Rules: borrow at the narrowest scope (borrow_mut directly in the statement), never store guards in fields or hold them across calls, and expect panics like `already borrowed` as the diagnosis — this type is !Sync and cannot cross threads.

Question

Rc<RefCell<T>> vs Arc<Mutex<T>> — when does each win?

Answer

One thread: Rc<RefCell<T>> — no atomics, no lock, instant checks, only panic risk (control it with short borrows). Threads: Arc<Mutex<T>> — atomic counts plus a real lock that blocks instead of panicking and poisons if a thread dies inside the critical section. Arc<RefCell<T>> is neither: RefCell is !Sync, so it isn't even Send.

Revision Notes

Key Takeaways

  • 1. Interior mutability preserves the borrow rules and relocates their enforcement: a runtime flag inside RefCell grants shared/exclusive borrows exactly when legal — sound, not weaker
  • 2. RefCell misuse is loud: `already mutably borrowed: BorrowError` / `already borrowed: BorrowMutError` panics, with try_borrow/try_borrow_mut as the recoverable path
  • 3. Cell is the zero-panic lane for Copy values (get/set/replace/take, no guards, no flag) — the default for counters and flags; RefCell exists for real &mut access to non-Copy data
  • 4. Rc<RefCell<T>> = many owners + checked writes on one thread; keep borrows at statement scope, because a guard held across the next borrow is the classic self-inflicted panic
  • 5. RefCell is !Sync (unsynchronized flag), so the cross-thread answer is Arc<Mutex<T>> — atomics, blocking, and poisoning as the price of sharing across cores

Interview Tips

  • • "Explain interior mutability" — rules unchanged, enforcement moved: RefCell's flag implements at runtime what the borrow checker proves statically; say "sound because every path checks the flag" to close the safety question
  • • "What happens if I borrow twice?" — quote both panic strings, then show try_borrow/try_borrow_mut and the fix (narrow the guard's scope); knowing the exact messages signals hands-on experience
  • • "Cell or RefCell?" — Copy + counters/flags → Cell (no guard, no flag, no panic path); non-Copy in-place mutation → RefCell (guards, flag, panic risk); both !Sync
  • • "Rc<RefCell<T>> vs Arc<Mutex<T>>?" — one thread, no locking cost, panic risk you control vs cross-thread, atomics + blocking + poison; add that Arc<RefCell<T>> fails because RefCell is !Sync to show you know the bounds, not just the recipe

Cheat Sheet

Cell, RefCell, and Interior Mutability

The idea: keep the borrow rules, move enforcement from compiler to a runtime flag.

Cell::new(v)      // Copy in/out: get() set() replace() take() — no guards, no flag, no panic
RefCell::new(v)   // borrow() -> Ref<T> · borrow_mut() -> RefMut<T> (guards, flag-checked)
cell.try_borrow() // Result<Ref<T>, BorrowError> — handle refusal without panicking

Failure strings (memorize):

Situation borrow() try_borrow()
exclusive guard live, asking shared panic already mutably borrowed: BorrowError Err(BorrowError)
shared or exclusive guard live, asking exclusive panic already borrowed: BorrowMutError Err(BorrowMutError)

Choose:

guards? flag? panic path? good for
Cell<T> no (copy/replace out) no never counters, flags, Copy
RefCell<T> yes, Ref/RefMut yes yes in-place edits of Vec/String/structs

Shared mutable pattern (one thread):

let rc = Rc::new(RefCell::new(vec![]));
rc.borrow_mut().push(1);   // borrow narrow: guard dies at end of statement
drop(guard);               // release before the next borrow — never store guards in fields

Bounds: both Send if T: Send, both !Sync → Rc<RefCell<T>> single-threaded only; Arc<RefCell<T>> is not even Send. Threads → Arc<Mutex<T>>: atomics + blocking + poison instead of flag + panic.

Continue Learning

Before You Start

Related Reading