Interior Mutability
Rust's core rule is shared-XOR-mutable: you can have many &T or one &mut T, never both. Smart Pointers Demystified introduced RefCell and Mutex as the wrappers that bend this rule. This tutorial is the deeper look: interior mutability is the controlled exception that lets you mutate a value through a shared &T, with the exclusivity check moved from compile time to runtime (or to a lock). The whole game is picking the cheapest tool that still upholds the rule, and knowing the trap each one hides.
The Problem: Mutating Through &self
A method that takes &self can't mutate a field, even when the mutation is logically harmless. A cache that fills lazily is the classic case:
struct Parser {
source: String,
line_count: Option<usize>, // computed once, then cached
}
impl Parser {
// wanted: a read-only-looking accessor that memoizes
fn line_count(&self) -> usize {
if let Some(n) = self.line_count {
return n;
}
let n = self.source.lines().count();
self.line_count = Some(n); // ERROR: cannot assign through `&self`
n
}
}
The naive fixes are both bad: take &mut self (now every reader needs a mutable borrow, poisoning call sites that only want to read), or drop the cache entirely (recompute every call). Interior mutability is the real fix, it lets line_count(&self) mutate the cached field without exposing &mut to callers.
Cell<T>: Move Values In and Out, No Borrows
Cell<T> is the cheapest wrapper. It never hands out a reference to its interior, instead you get() a copy out or set()/replace() a whole value in. Because no reference ever escapes, there's nothing to track and nothing that can panic.
use std::cell::Cell;
struct HitCounter {
hits: Cell<u32>,
}
impl HitCounter {
fn record(&self) { // &self, not &mut self
self.hits.set(self.hits.get() + 1);
}
fn total(&self) -> u32 {
self.hits.get()
}
}
get() requires T: Copy; for non-Copy types you use replace()/take() (swap a new value in, get the old one out by value). The limitation is the point: Cell works only when you can move values whole, you can never get a & or &mut into what it holds. When that's enough, it's the fastest option with zero runtime checks.
RefCell<T>: Runtime Borrows, With a Catch
When you need an actual reference into the wrapped value (to call a method, push to a Vec, mutate one field), Cell can't help. RefCell<T> hands out Ref/RefMut guards via borrow() and borrow_mut(), enforcing shared-XOR-mutable at runtime by counting live borrows.
use std::cell::RefCell;
struct Logger {
entries: RefCell<Vec<String>>,
}
impl Logger {
fn log(&self, msg: &str) { // &self, yet mutates
self.entries.borrow_mut().push(msg.into());
}
fn dump(&self) -> Vec<String> {
self.entries.borrow().clone()
}
}
The catch is that the borrow rules still exist, they're just checked dynamically. Violate them and you don't get a compile error; you get a panic at runtime.
Gotcha:
borrow_mut()while any other borrow of the sameRefCellis live panics withalready borrowed: BorrowMutError. The trap is a method that calls another method which re-borrows the same cell, or holding aRefacross a call that needsborrow_mut(). Keep guards short-lived (don't bind aborrow()to a long-livedlet), and never call back intoselfwhile a borrow is held. ARefCellturns a borrow-checker error into a production crash, that's the price of the flexibility.
Cell vs RefCell: reach for Cell when you can move the whole value (counters, flags, small Copy state); reach for RefCell when you need a reference into the value (mutating a collection or a field in place). Cell can't panic; RefCell can.
Crossing Threads: Mutex<T> and RwLock<T>
Cell and RefCell are single-threaded only, they're !Sync, so the compiler won't let them cross a thread boundary. The threaded equivalents from Concurrency in Practice are Mutex<T> (one accessor at a time) and RwLock<T> (many readers or one writer):
use std::sync::RwLock;
struct Config {
settings: RwLock<std::collections::HashMap<String, String>>,
}
impl Config {
fn get(&self, key: &str) -> Option<String> {
self.settings.read().unwrap().get(key).cloned() // shared read lock
}
fn set(&self, key: String, val: String) {
self.settings.write().unwrap().insert(key, val); // exclusive write lock
}
}
RwLock pays off only when reads genuinely dominate and the critical section is long enough that concurrent readers matter; if writes are frequent or sections are tiny, a plain Mutex is simpler and often faster (no reader/writer bookkeeping). Both return guards that release on Drop (from Drop, RAII, and Resource Cleanup), and both replace RefCell's panic with blocking.
Gotcha: the same re-entrancy that panics with
RefCelldeadlocks withMutex/RwLock. Locking aMutexyou already hold, or taking awrite()while holding aread()on the sameRwLock, hangs the thread forever instead of crashing. And never hold a lock guard across an.await(Async/Await with Tokio), use an async-aware lock there. The single-thread rule "keep guards short, don't re-enter" becomes a hard requirement once a lock is involved.
Init-Once: OnceCell, OnceLock, and LazyLock
A common need is "compute this expensive value the first time it's asked for, then reuse it", lazy initialization. Doing it with RefCell<Option<T>> works but re-checks and re-borrows on every access. The Once* family is purpose-built: write at most once, read freely forever after, no guard juggling.
use std::sync::OnceLock;
fn config() -> &'static Config {
static CONFIG: OnceLock<Config> = OnceLock::new();
CONFIG.get_or_init(|| load_config_from_disk()) // runs the closure exactly once
}
OnceCell<T>— single-threaded, set once then borrow as&T.OnceLock<T>— the thread-safe version; safe in astatic, ideal for global lazy singletons (the modern replacement for thelazy_static!/once_cellcrates, now instd).LazyLock<T>—OnceLockplus the init closure baked in, so first access initializes automatically:static TABLE: LazyLock<HashMap<u32,&str>> = LazyLock::new(|| { ... });.
The win over RefCell<Option<T>>: once initialized, access is a cheap shared read with no borrow counting and no chance of a BorrowMutError, the type encodes "write-once" so the runtime check all but disappears.
Choosing the Wrapper
| Need | Use | Cost / failure mode |
|---|---|---|
Mutate Copy state behind &self, single thread | Cell<T> | none — can't panic |
Mutate a value in place behind &self, single thread | RefCell<T> | runtime borrow check — panics on overlap |
| Shared mutable state across threads | Mutex<T> | blocks; re-entrancy deadlocks |
| Cross-thread, reads dominate | RwLock<T> | blocks; reader/writer overhead |
| Initialize once, then read forever (global) | OnceLock<T> / LazyLock<T> | set-once; cheap reads after |
| Initialize once, single thread | OnceCell<T> | set-once |
A guiding rule: prefer the compiler's static check whenever you can restructure to use &mut self. Interior mutability is for the cases where you genuinely can't, a shared cache, a global, a counter behind &self, and there you should pick the least powerful tool that fits (Cell over RefCell, OnceLock over Mutex<Option<T>>), because less power means fewer ways to panic or deadlock.
Key Takeaways
- Interior mutability is the controlled exception to shared-XOR-mutable: mutate through
&Tby moving the exclusivity check from compile time to runtime (or a lock). Use it only when&mut selfgenuinely can't be restructured in. Cell<T>moves whole values in/out (get/set/replace), hands out no references, and can never panic, the cheapest choice forCopystate and counters behind&self.RefCell<T>gives real references viaborrow/borrow_mutbut enforces the rules at runtime: an overlappingborrow_mut()panics (BorrowMutError). Keep guards short and never re-enter the same cell.Mutex<T>/RwLock<T>are the thread-safe equivalents (RefCell→Mutex, withRwLockfor read-heavy workloads); re-entrancy deadlocks instead of panicking, and a guard must never be held across.await.OnceCell/OnceLock/LazyLockexpress write-once-read-many directly, beatingRefCell<Option<T>>for lazy init and globals;OnceLock/LazyLockare the in-stdreplacement forlazy_static!.- Pick the least powerful wrapper that fits: fewer capabilities mean fewer runtime failure modes.