Home Design Patterns Interior Mutability
Interior Mutability
Ownership and LifetimesMutate through a shared reference by moving the borrow check from compile time to run time.
Purpose
Rust's central rule is that you may have many shared references to a value or one exclusive reference to it, never both. Interior mutability is the sanctioned way around that rule for cases where it is too strict: a value that everyone can see, and that something needs to change.
The rule is not abandoned. It is still enforced, just later, by a flag checked at run time instead of by the compiler. Break it and the program panics rather than failing to build. That is a real downgrade and the pattern should be understood as a trade rather than a feature.
Design
The standard library offers a graded set of these, and picking the cheapest one that fits is most of the skill:
Cell<T>holds aCopyvalue and lets you get and set it. There are no references handed out, so there is nothing to check and nothing that can panic.RefCell<T>hands out references and tracks how many are outstanding. Asking for a mutable borrow while any other borrow is live panics.Mutex<T>andRwLock<T>do the same job across threads, blocking rather than panicking.- Atomics do it for single primitive values with no lock at all.
RefCell is doing at run time
what the compiler would otherwise have done at compile time. Everything else
about the diagram is ordinary composition.Example
Autobot's office reporting client holds a model of each arm on the floor. Asking a model for its calibration factor is an expensive operation involving a lookup and some arithmetic, and the answer never changes for the life of the object, so it obviously wants to be cached.
The problem is that reading a calibration is conceptually a read. Every caller
holds a shared reference, dozens of them exist at once across the reporting
views, and the signature that makes sense is fn calibration(&self).
Caching means writing. Rust will not let a method taking a shared reference write
to a field, and rewriting every caller to take an exclusive reference so that one
private cache can be filled would be the tail wagging the dog.
RefCell is the right answer here, and it is worth being precise
about why: the mutation is genuinely invisible from outside. Two callers cannot
observe a difference in behavior depending on which of them ran first, because
the cached value is the same value the computation would have produced anyway.
use std::cell::RefCell;
struct ArmModel {
id: u8,
cache: RefCell<Option<f32>>,
}
impl ArmModel {
fn calibration(&self) -> f32 {
let cached = *self.cache.borrow();
if let Some(value) = cached {
return value;
}
let computed = self.measure();
*self.cache.borrow_mut() = Some(computed);
computed
}
fn measure(&self) -> f32 {
// expensive, and always produces the same answer
1.0
}
}
borrow_mut is called. Holding both at once compiles
perfectly and panics at run time, which is the characteristic bug of this
pattern.Seen in the Wild
Rc<RefCell<T>> is the standard shape for a shared
mutable graph in safe Rust, and it appears in almost every tutorial that builds
a linked structure. In the standard library, OnceCell and
LazyLock are interior mutability specialized for initialize-once
data, and every Mutex in every threaded Rust program is the same
mechanism with blocking instead of panicking. The idea is not Rust-specific:
C++'s mutable member qualifier exists precisely so that a
const method can update a cache, which is this pattern with the
check removed entirely.
Comparisons
- Arena is the alternative worth trying first. Most
Rc<RefCell<T>>graphs are arenas that have not realized it yet, and converting to indices removes both the refcount and the runtime borrow check. - Proxy describes what
RefCellis: a protection proxy that guards access, except that what it protects is not the data but the borrowing rule. - Observer cannot be written in safe Rust without
something from this family. The subject holds the observers, the observers
frequently need to reach back, and that is a cycle requiring
Weakalongside the interior mutability. - Lazy Load is the most common use of it, and the caching example above is exactly that.
- RAII and Drop Guards is how the borrow is tracked.
The
RefandRefMutvalues are guards, and the flag is cleared when they drop.
Why It Exists
My take
Interior mutability exists because Rust's borrow checker is a conservative approximation, and every conservative approximation rejects programs that are actually fine. The checker proves the absence of a class of bug, and the price of a proof is that anything it cannot prove is refused, whether or not it was correct. This pattern is the pressure valve that keeps the language usable, and needing it at all is an honest admission that static analysis has limits.
The part I think is under-appreciated is that it is a downgrade, and the
documentation is generally too cheerful about that. Rust's pitch is that a
whole category of error becomes a compile failure. Reaching for
RefCell takes one specific error and moves it back to run time, in
a form that is harder to debug than the C++ equivalent would have been, because
the offending borrow is often a temporary created inside a function three
levels away. That can absolutely be the right trade. It should be a conscious
one.
My rule of thumb is to ask whether the mutation is observable. Filling a
cache is not: no caller can tell. Mutating shared application state through a
handle everybody holds absolutely is, and reaching for
RefCell there is usually a sign that the ownership model was never
worked out and the borrow checker is being argued with rather than listened to.
The first case is the pattern. The second is a refactor being deferred.
Criticisms
It converts a compile error into a panic, in a language whose entire proposition is the reverse. A double borrow is not caught by review, is not caught by the type system, and may not be caught in testing if the two borrows only overlap on an uncommon path. The failure then arrives in production as a process abort.
Those panics are unusually hard to diagnose. The message tells you a borrow was already active, not where it was taken, and the culprit is regularly a temporary whose lifetime extends further than the author expected. The classic version is calling a method that borrows while already holding a borrow across a statement, which reads as obviously fine and is not.
Rc<RefCell<T>> also brings the reference-counting
problems along with it. Cycles leak, because a cycle keeps its own refcount above
zero forever, and the fix is to demote one direction to Weak and
handle the resulting Option at every access. Beyond that, the
combination is the single most common crutch among people new to the language:
it makes the compiler stop complaining, which feels like progress, while leaving
the actual ownership question unanswered. If you are reaching for it more than
occasionally, the design underneath is usually the thing that needs attention.