Interior Mutability

Rust's core rule is shared-XOR-mutable: you can have many &T or one &mut T, never both. Smart Pointers Demystified introduced RefCell and Mutex as the wrappers that bend this rule. This tutorial is the deeper look: interior mutability is the controlled exception that lets you mutate a value through a shared &T, with the exclusivity check moved from compile time to runtime (or to a lock). The whole game is picking the cheapest tool that still upholds the rule, and knowing the trap each one hides.


The Problem: Mutating Through &self

A method that takes &self can't mutate a field, even when the mutation is logically harmless. A cache that fills lazily is the classic case:

struct Parser {
    source: String,
    line_count: Option<usize>,  // computed once, then cached
}

impl Parser {
    // wanted: a read-only-looking accessor that memoizes
    fn line_count(&self) -> usize {
        if let Some(n) = self.line_count {
            return n;
        }
        let n = self.source.lines().count();
        self.line_count = Some(n);  // ERROR: cannot assign through `&self`
        n
    }
}

The naive fixes are both bad: take &mut self (now every reader needs a mutable borrow, poisoning call sites that only want to read), or drop the cache entirely (recompute every call). Interior mutability is the real fix, it lets line_count(&self) mutate the cached field without exposing &mut to callers.


Cell<T>: Move Values In and Out, No Borrows

Cell<T> is the cheapest wrapper. It never hands out a reference to its interior, instead you get() a copy out or set()/replace() a whole value in. Because no reference ever escapes, there's nothing to track and nothing that can panic.

use std::cell::Cell;

struct HitCounter {
    hits: Cell<u32>,
}

impl HitCounter {
    fn record(&self) {          // &self, not &mut self
        self.hits.set(self.hits.get() + 1);
    }
    fn total(&self) -> u32 {
        self.hits.get()
    }
}

get() requires T: Copy; for non-Copy types you use replace()/take() (swap a new value in, get the old one out by value). The limitation is the point: Cell works only when you can move values whole, you can never get a & or &mut into what it holds. When that's enough, it's the fastest option with zero runtime checks.


RefCell<T>: Runtime Borrows, With a Catch

When you need an actual reference into the wrapped value (to call a method, push to a Vec, mutate one field), Cell can't help. RefCell<T> hands out Ref/RefMut guards via borrow() and borrow_mut(), enforcing shared-XOR-mutable at runtime by counting live borrows.

use std::cell::RefCell;

struct Logger {
    entries: RefCell<Vec<String>>,
}

impl Logger {
    fn log(&self, msg: &str) {                 // &self, yet mutates
        self.entries.borrow_mut().push(msg.into());
    }
    fn dump(&self) -> Vec<String> {
        self.entries.borrow().clone()
    }
}

The catch is that the borrow rules still exist, they're just checked dynamically. Violate them and you don't get a compile error; you get a panic at runtime.

Gotcha: borrow_mut() while any other borrow of the same RefCell is live panics with already borrowed: BorrowMutError. The trap is a method that calls another method which re-borrows the same cell, or holding a Ref across a call that needs borrow_mut(). Keep guards short-lived (don't bind a borrow() to a long-lived let), and never call back into self while a borrow is held. A RefCell turns a borrow-checker error into a production crash, that's the price of the flexibility.

Cell vs RefCell: reach for Cell when you can move the whole value (counters, flags, small Copy state); reach for RefCell when you need a reference into the value (mutating a collection or a field in place). Cell can't panic; RefCell can.


Crossing Threads: Mutex<T> and RwLock<T>

Cell and RefCell are single-threaded only, they're !Sync, so the compiler won't let them cross a thread boundary. The threaded equivalents from Concurrency in Practice are Mutex<T> (one accessor at a time) and RwLock<T> (many readers or one writer):

use std::sync::RwLock;

struct Config {
    settings: RwLock<std::collections::HashMap<String, String>>,
}

impl Config {
    fn get(&self, key: &str) -> Option<String> {
        self.settings.read().unwrap().get(key).cloned()   // shared read lock
    }
    fn set(&self, key: String, val: String) {
        self.settings.write().unwrap().insert(key, val);   // exclusive write lock
    }
}

RwLock pays off only when reads genuinely dominate and the critical section is long enough that concurrent readers matter; if writes are frequent or sections are tiny, a plain Mutex is simpler and often faster (no reader/writer bookkeeping). Both return guards that release on Drop (from Drop, RAII, and Resource Cleanup), and both replace RefCell's panic with blocking.

Gotcha: the same re-entrancy that panics with RefCell deadlocks with Mutex/RwLock. Locking a Mutex you already hold, or taking a write() while holding a read() on the same RwLock, hangs the thread forever instead of crashing. And never hold a lock guard across an .await (Async/Await with Tokio), use an async-aware lock there. The single-thread rule "keep guards short, don't re-enter" becomes a hard requirement once a lock is involved.


Init-Once: OnceCell, OnceLock, and LazyLock

A common need is "compute this expensive value the first time it's asked for, then reuse it", lazy initialization. Doing it with RefCell<Option<T>> works but re-checks and re-borrows on every access. The Once* family is purpose-built: write at most once, read freely forever after, no guard juggling.

use std::sync::OnceLock;

fn config() -> &'static Config {
    static CONFIG: OnceLock<Config> = OnceLock::new();
    CONFIG.get_or_init(|| load_config_from_disk())   // runs the closure exactly once
}
  • OnceCell<T> — single-threaded, set once then borrow as &T.
  • OnceLock<T> — the thread-safe version; safe in a static, ideal for global lazy singletons (the modern replacement for the lazy_static!/once_cell crates, now in std).
  • LazyLock<T>OnceLock plus the init closure baked in, so first access initializes automatically: static TABLE: LazyLock<HashMap<u32,&str>> = LazyLock::new(|| { ... });.

The win over RefCell<Option<T>>: once initialized, access is a cheap shared read with no borrow counting and no chance of a BorrowMutError, the type encodes "write-once" so the runtime check all but disappears.


Choosing the Wrapper

NeedUseCost / failure mode
Mutate Copy state behind &self, single threadCell<T>none — can't panic
Mutate a value in place behind &self, single threadRefCell<T>runtime borrow check — panics on overlap
Shared mutable state across threadsMutex<T>blocks; re-entrancy deadlocks
Cross-thread, reads dominateRwLock<T>blocks; reader/writer overhead
Initialize once, then read forever (global)OnceLock<T> / LazyLock<T>set-once; cheap reads after
Initialize once, single threadOnceCell<T>set-once

A guiding rule: prefer the compiler's static check whenever you can restructure to use &mut self. Interior mutability is for the cases where you genuinely can't, a shared cache, a global, a counter behind &self, and there you should pick the least powerful tool that fits (Cell over RefCell, OnceLock over Mutex<Option<T>>), because less power means fewer ways to panic or deadlock.


Key Takeaways

  • Interior mutability is the controlled exception to shared-XOR-mutable: mutate through &T by moving the exclusivity check from compile time to runtime (or a lock). Use it only when &mut self genuinely can't be restructured in.
  • Cell<T> moves whole values in/out (get/set/replace), hands out no references, and can never panic, the cheapest choice for Copy state and counters behind &self.
  • RefCell<T> gives real references via borrow/borrow_mut but enforces the rules at runtime: an overlapping borrow_mut() panics (BorrowMutError). Keep guards short and never re-enter the same cell.
  • Mutex<T>/RwLock<T> are the thread-safe equivalents (RefCellMutex, with RwLock for read-heavy workloads); re-entrancy deadlocks instead of panicking, and a guard must never be held across .await.
  • OnceCell/OnceLock/LazyLock express write-once-read-many directly, beating RefCell<Option<T>> for lazy init and globals; OnceLock/LazyLock are the in-std replacement for lazy_static!.
  • Pick the least powerful wrapper that fits: fewer capabilities mean fewer runtime failure modes.