Interior Mutability Pattern
The Interior Mutability pattern allows mutating a specific internal field—such as a lazy cache, reference count, metrics counter, or I/O buffer—behind a shared immutable reference (&self). Instead of forcing every caller in the program to hold an exclusive &mut Client just to increment a hit counter or populate a memoization cache, interior mutability moves the aliasing check from the whole struct at compile time to a narrow, encapsulated field.
Architectural Rule for Interior Mutability:
Logically Immutable to the Caller (`&self` API)
|
+-- Physically Mutates a Private Cache / Counter Field Internally!
- Single-threaded `Copy` field: `Cell<T>` (0 runtime borrow checks!)
- Single-threaded heap/ref type: `RefCell<T>` (Runtime borrow flag)
- Single/Multi-threaded 1-time: `OnceLock<T>` / `OnceCell<T>`
- Multi-threaded counter/flag: `AtomicU64` / `AtomicBool`
- Multi-threaded complex state: `Mutex<T>` / `RwLock<T>`
Choosing the Minimal Interior Mutability Primitive
| Primitive | Thread-Safe (Sync)? |
Can Panic / Deadlock? | Cost | Ideal Architectural Pattern |
|---|---|---|---|---|
Cell<T> |
No (!Sync) |
Never! (No references into Cell can exist) |
0 cycles (Direct get/set load/store) |
Mutating Copy flags, cursors, or counters inside !Sync types. |
OnceCell<T> / LazyCell<T> |
No (!Sync) |
Never (unless recursive init) | 1 branch check | Lazy initialization of expensive single-threaded fields on first &self read. |
RefCell<T> |
No (!Sync) |
Panics if .borrow_mut() overlaps .borrow() |
1 integer increment/decrement | Mutating graphs/observers in single-threaded UI or compiler passes. |
Atomic* |
Yes (Sync) |
Never! | 1 atomic CPU instruction | Lock-free telemetry counters, sequence IDs, and cancellation flags on &self. |
OnceLock<T> / LazyLock<T> |
Yes (Sync) |
Blocks concurrent init threads once | 1 atomic load after init | Thread-safe lazy initialization of compiled regexes, schemas, or cached fields. |
Mutex<T> / RwLock<T> |
Yes (Sync) |
Deadlocks if locked twice on the same thread! | ~15ns uncontended atomic lock | Protecting shared connection pools or LRU caches inside a Sync service. |
Pattern 1: Lazy Memoization on &self with OnceLock<T>
When a struct holds raw data and has an expensive derived property (such as a SHA-256 digest or parsed AST) that may or may not be requested by callers, storing cached_hash: OnceLock<u64> inside the struct allows fn hash(&self) -> u64 to take a shared &self reference, compute the hash on the first call, and return the cached value in $O(1)$ on all subsequent calls—even across multiple concurrent threads!
use std::sync::OnceLock;
use std::sync::atomic::{AtomicU64, Ordering};
pub struct Document {
content: String,
// Lazily computed at most once, even if multiple threads call `.digest()` on `&Document`!
cached_digest: OnceLock<u64>,
// Lock-free access counter updated via `&self`:
read_count: AtomicU64,
}
impl Document {
pub fn new(content: impl Into<String>) -> Self {
Self {
content: content.into(),
cached_digest: OnceLock::new(),
read_count: AtomicU64::new(0),
}
}
pub fn text(&self) -> &str {
self.read_count.fetch_add(1, Ordering::Relaxed);
&self.content
}
pub fn digest(&self) -> u64 {
*self.cached_digest.get_or_init(|| {
// Expensive computation runs ONLY on the first call!
self.content
.bytes()
.fold(0xcbf29ce484222325u64, |acc, b| {
(acc ^ u64::from(b)).wrapping_mul(0x100000001b3)
})
})
}
pub fn reads(&self) -> u64 {
self.read_count.load(Ordering::Relaxed)
}
}
fn main() {
// Notice `doc` is NOT declared `mut` (`let doc`, not `let mut doc`)!
let doc = Document::new("immutable-on-the-outside");
assert_eq!(doc.text(), "immutable-on-the-outside");
assert_eq!(doc.text(), "immutable-on-the-outside");
assert_eq!(doc.reads(), 2);
let d1 = doc.digest();
let d2 = doc.digest(); // Returns cached value from `OnceLock` immediately!
assert_eq!(d1, d2);
println!("Document reads={}, cached digest={d1:#x}", doc.reads());
}
Document reads=2, cached digest=0xd9924c89069bc1c8
Golden Rule for RefCell and Mutex Scope
Never return a Ref<'_, T> or MutexGuard<'_, T> across a public API boundary if you can avoid it, and never hold a RefCell borrow or Mutex lock across an external callback or .await point! Keep the .borrow_mut() or .lock() confined to a 2–3 line private block so callers cannot trigger a reentrant borrow panic or deadlock.