Interior Mutability
Interior mutability is a design pattern in Rust that permits mutating data through a shared, immutable reference (&T). While inherited mutability requires a mutable path (let mut or &mut T) from the root variable down to the modified field and verifies exclusivity at compile time, interior mutability moves exclusivity enforcement inside a specialized container powered by std::cell::UnsafeCell<T>.
Inherited Mutability vs. Interior Mutability
| Property | Inherited Mutability (&mut T) |
Interior Mutability (&Cell<T>, &RefCell<T>, &Mutex<T>) |
|---|---|---|
| Receiver signature | fn mutate(&mut self) |
fn mutate(&self) |
| Aliasing check timing | Compile time (borrow checker) | Move/copy only (Cell), runtime borrow count (RefCell), or thread lock/atomics (Mutex, RwLock, Atomic*) |
Can mutate inside Rc<T> / Arc<T>? |
Only when unique (get_mut / make_mut) |
Yes, while shared across multiple handles |
| Can mutate individual field of immutable struct? | No (mutability applies to the whole binding) | Yes (wrap only the mutable field in Cell or RefCell) |
The Foundation: UnsafeCell<T>
In Rust's abstract machine, converting a shared reference &T into *mut T and writing through it is immediate undefined behavior unless the memory resides inside std::cell::UnsafeCell<T>. Every interior mutability type in the standard library (Cell, RefCell, OnceCell, Mutex, RwLock, AtomicUsize, etc.) is built directly on top of UnsafeCell<T>.
+----------------------+
| UnsafeCell<T> |
| (Compiler primitive) |
+----------+-----------+
|
+------------------------+------------------------+
| (Single-Threaded: !Sync) | (Multi-Threaded: Sync)
v v
+-------------------------+ +--------------------------+
| Cell<T> | | AtomicBool / AtomicU64 |
| RefCell<T> | | Mutex<T> / RwLock<T> |
| OnceCell<T> / LazyCell | | OnceLock<T> / LazyLock |
+-------------------------+ +--------------------------+
Combining Rc<T> and RefCell<T> for Shared Mutable Graphs
Pairing Rc<T> (multiple owners) with RefCell<T> (runtime-checked mutation) allows multiple handles to both read and mutate the same heap object on a single thread.
use std::cell::RefCell;
use std::rc::Rc;
#[derive(Debug)]
struct CounterNode {
name: &'static str,
value: RefCell<i32>,
}
fn main() {
let shared_node = Rc::new(CounterNode {
name: "primary",
value: RefCell::new(10),
});
let observer_a = Rc::clone(&shared_node);
let observer_b = Rc::clone(&shared_node);
*observer_a.value.borrow_mut() += 5;
*observer_b.value.borrow_mut() *= 2;
println!(
"node {:?} value = {}",
shared_node.name,
shared_node.value.borrow()
);
}
node "primary" value = 30
Architectural Use Cases for Interior Mutability
- Logical immutability (lazy caching / memoization): A method whose observable behavior is read-only (
fn compute(&self) -> u64) populates an internalOnceCell<u64>orRefCell<HashMap<K, V>>cache on first access without forcing callers to hold&mut self. - Test doubles and mocks: Implementing a trait whose method takes
&self(such asfn send(&self, msg: &str)) while recording sent messages in aRefCell<Vec<String>>for test assertions. - Reference-counted graph nodes: Mutating edges or weights inside
Rc<Node>orArc<Node>structures.
use std::cell::OnceCell;
struct ExpensiveReport {
raw_numbers: Vec<u64>,
cached_sum: OnceCell<u64>,
}
impl ExpensiveReport {
fn new(raw_numbers: Vec<u64>) -> Self {
Self {
raw_numbers,
cached_sum: OnceCell::new(),
}
}
fn sum(&self) -> u64 {
*self
.cached_sum
.get_or_init(|| self.raw_numbers.iter().copied().sum())
}
}
fn main() {
let report = ExpensiveReport::new(vec![100, 200, 300, 400]);
println!("first call: {}", report.sum());
println!("second call: {}", report.sum());
}
first call: 1000
second call: 1000