Rust Patterns That Matter #4: Interior Mutability
Post 4 of 22 in Rust Patterns That Matter. Companion series: Building a Chat Server in Rust.
Previous: #3: Error Handling | Next: #5: Shared Ownership
You have a struct with a cache. The struct is passed around by shared reference. You
need to insert into the cache inside a method that takes &self. The
compiler says no. This is the first wall.
The motivation
Imagine a configuration loader that parses expensive files on demand and caches the results. Callers only need a shared reference - they're reading configuration, not modifying the loader itself. But the loader needs to write to its internal cache.
use std::collections::HashMap;
struct ConfigLoader {
cache: HashMap<String, String>,
}
impl ConfigLoader {
fn get(&self, key: &str) -> Option<&String> {
if !self.cache.contains_key(key) {
let value = self.load_from_disk(key);
self.cache.insert(key.to_string(), value); // ERROR
}
self.cache.get(key)
}
}
The compiler rejects this:
error[E0596]: cannot borrow `self.cache` as mutable,
as it is behind a `&` reference
The method takes &self - a shared, immutable reference. Shared
references are read-only. That's not a suggestion; it's a guarantee the entire borrow
system depends on. You cannot mutate through &self.
Why the compiler does this
Rust's core safety guarantee: if you have a &T, nobody is mutating
T right now. If you have a &mut T, nobody else is
looking at T right now. These two rules eliminate data races, iterator
invalidation, and use-after-free - at compile time. No runtime cost.
The problem is that the cache is a legitimate mutation behind a logically immutable
interface. From the caller's perspective, get() is a read operation. The
cache is an implementation detail. But the type system doesn't know that - it
sees a write through a shared reference and says no.
The pattern: RefCell<T>
RefCell<T> moves borrow checking from compile time to runtime.
It wraps a value and tracks borrows dynamically: you call .borrow() for
shared access and .borrow_mut() for exclusive access. The rules are
identical - many readers or one writer, never both - but they're enforced
when the code runs instead of when it compiles.
use std::cell::RefCell;
use std::collections::HashMap;
struct ConfigLoader {
cache: RefCell<HashMap<String, String>>,
}
impl ConfigLoader {
fn get(&self, key: &str) -> Option<String> {
if !self.cache.borrow().contains_key(key) {
let value = self.load_from_disk(key);
self.cache.borrow_mut().insert(key.to_string(), value);
}
self.cache.borrow().get(key).cloned()
}
}
The method still takes &self. The cache is wrapped in
RefCell, which allows mutation through a shared reference. The compiler
is satisfied because RefCell promises to enforce the borrow rules itself.
What happens at runtime
RefCell maintains a counter. Each .borrow() increments
it; each dropped borrow decrements it. .borrow_mut() checks that the
counter is zero (no outstanding borrows) and sets a flag. If you violate the rules
- say, calling .borrow_mut() while a .borrow() is
still alive - it panics.
let cell = RefCell::new(42);
let a = cell.borrow(); // shared borrow — fine
let b = cell.borrow(); // second shared borrow — fine
let c = cell.borrow_mut(); // PANIC: already borrowed
The panic is not a bug in your program's logic (though it might indicate one). It's
RefCell enforcing the same rule the compiler would have enforced at
compile time: you cannot have a mutable borrow while shared borrows exist.
The tradeoff
With RefCell, you trade a compile-time guarantee for a runtime check.
The compiler no longer catches double-mutable-borrows - you'll find out at
runtime via a panic. This is a conscious, local relaxation of Rust's rules, not
a hole in the safety model. The borrow rules are still enforced; the enforcement
just moved.
The runtime cost is small - an integer check on each borrow. In practice, the
real cost is the possibility of panics. If you can structure your code so the compiler
checks borrows statically (by taking &mut self instead), do that.
Reach for RefCell when the API genuinely requires shared access with
internal mutation.
When to use it
Good uses:
- Caches behind a shared interface (the example above)
- Lazily initialized fields that compute on first access
- Observer lists where the subject is shared but the list changes
- Any struct where the mutation is an implementation detail invisible to callers
When not to use it:
- If you can restructure to take
&mut self, do that - it's strictly better (compile-time checked, no overhead) - If you need thread safety,
RefCellwon't work - it's!Sync. UseMutexorRwLockinstead (#19)
What comes next
RefCell solves the mutation problem, but only for a single owner.
When multiple parts of your program need to share the same data,
you need Rc - which is the
next post. And when you need shared ownership and mutation,
#6 puts the two together.
See it in practice: Building a Chat Server #2: Rooms and Users uses this pattern for runtime-checked mutable room membership.
Telex