Why Interior Mutability Exists
Interior mutability is the language mechanism that allows a Rust type to mutate its internal state through a shared reference (&T). While Rust's default compile-time borrow checker enforces inherited mutability—where every field of a struct is mutable only when accessed through a mutable binding (let mut) or exclusive reference (&mut T)—some safe program architectures require mutating localized state while multiple shared references (&T, Rc<T>, or Arc<T>) exist.
Problems That Compile-Time Exclusive References Cannot Solve Alone
- Mutating one field while borrowing another through
&self: A method declared by an external trait with&self(such asDisplay::fmt,Clone::clone, or a customService::handle(&self, req: Request)) needs to increment a telemetry counter, update an internal cache, or record call history in a test mock without changing the trait signature to&mut self. - Mutating shared graph or tree nodes: When multiple handles co-own a node via
Rc<Node>orArc<Node>, no single owner holds unique access, so&mut Nodeis unavailable while multiple handles exist. - Lazy one-time initialization: Computing and storing an expensive configuration or compiled regular expression on first access through a shared
&Configorstaticvariable.
UnsafeCell<T>: The Single Legal Escape Hatch for Shared Mutation
In Rust's memory model, the compiler optimizes code under the assumption that data behind a shared reference &T never changes while that reference is live (noalias / readonly in LLVM IR). Casting &T to *mut T and writing through it is immediate undefined behavior unless the mutated bytes reside inside std::cell::UnsafeCell<T>.
UnsafeCell<T> is the compiler-recognized lang item (#[lang = "unsafe_cell"]) that disables immutable-aliasing optimizations for the memory it wraps. Every safe interior mutability type in the standard library wraps UnsafeCell<T> and enforces aliasing safety through its own API contract:
+-----------------------------------------+
| std::cell::UnsafeCell<T> |
| (Disables LLVM readonly optimization) |
+--------------------+--------------------+
|
+----------------------------+----------------------------+
| |
v (Single-Threaded: !Sync) v (Multi-Threaded: Sync)
+-----------------------------------+ +-----------------------------------+
| By-value move/copy: Cell<T> | | Hardware atomics: Atomic* |
| Runtime borrow count: RefCell<T> | | Write-once init: OnceLock<T> |
| Write-once init: OnceCell<T> | | Lazy init on deref: LazyLock<T> |
| Lazy init on deref: LazyCell<T> | | Mutual exclusion: Mutex<T> |
+-----------------------------------+ | Reader-writer lock: RwLock<T> |
+-----------------------------------+
Example: Implementing a Read-Only Trait with Internal Call Tracking
When implementing a trait whose method signature takes &self, wrapping the mutable state in Cell<T> or RefCell<T> keeps the outer struct usable behind &self:
use std::cell::Cell;
trait Mailer {
fn send(&self, recipient: &str, subject: &str);
}
struct TrackedMailer {
sent_count: Cell<usize>,
}
impl Mailer for TrackedMailer {
fn send(&self, recipient: &str, subject: &str) {
self.sent_count.set(self.sent_count.get() + 1);
println!(
"[mail #{}] to={:?} subject={:?}",
self.sent_count.get(),
recipient,
subject
);
}
}
fn notify_team(mailer: &impl Mailer) {
mailer.send("alice@example.com", "Build passed");
mailer.send("bob@example.com", "Deploy ready");
}
fn main() {
let mailer = TrackedMailer {
sent_count: Cell::new(0),
};
notify_team(&mailer);
println!("total emails sent: {}", mailer.sent_count.get());
}
[mail #1] to="alice@example.com" subject="Build passed"
[mail #2] to="bob@example.com" subject="Deploy ready"
total emails sent: 2