Interior Mutability

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

  1. Mutating one field while borrowing another through &self: A method declared by an external trait with &self (such as Display::fmt, Clone::clone, or a custom Service::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.
  2. Mutating shared graph or tree nodes: When multiple handles co-own a node via Rc<Node> or Arc<Node>, no single owner holds unique access, so &mut Node is unavailable while multiple handles exist.
  3. Lazy one-time initialization: Computing and storing an expensive configuration or compiled regular expression on first access through a shared &Config or static variable.

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