Smart Pointers

Interior Mutability

Interior mutability is a design pattern in Rust that permits mutating data through a shared, immutable reference (&T). While inherited mutability requires a mutable path (let mut or &mut T) from the root variable down to the modified field and verifies exclusivity at compile time, interior mutability moves exclusivity enforcement inside a specialized container powered by std::cell::UnsafeCell<T>.

Inherited Mutability vs. Interior Mutability

Property Inherited Mutability (&mut T) Interior Mutability (&Cell<T>, &RefCell<T>, &Mutex<T>)
Receiver signature fn mutate(&mut self) fn mutate(&self)
Aliasing check timing Compile time (borrow checker) Move/copy only (Cell), runtime borrow count (RefCell), or thread lock/atomics (Mutex, RwLock, Atomic*)
Can mutate inside Rc<T> / Arc<T>? Only when unique (get_mut / make_mut) Yes, while shared across multiple handles
Can mutate individual field of immutable struct? No (mutability applies to the whole binding) Yes (wrap only the mutable field in Cell or RefCell)

The Foundation: UnsafeCell<T>

In Rust's abstract machine, converting a shared reference &T into *mut T and writing through it is immediate undefined behavior unless the memory resides inside std::cell::UnsafeCell<T>. Every interior mutability type in the standard library (Cell, RefCell, OnceCell, Mutex, RwLock, AtomicUsize, etc.) is built directly on top of UnsafeCell<T>.

                       +----------------------+
                       |   UnsafeCell<T>      |
                       | (Compiler primitive) |
                       +----------+-----------+
                                  |
         +------------------------+------------------------+
         | (Single-Threaded: !Sync)                        | (Multi-Threaded: Sync)
         v                                                 v
+-------------------------+                     +--------------------------+
|  Cell<T>                |                     |  AtomicBool / AtomicU64  |
|  RefCell<T>             |                     |  Mutex<T> / RwLock<T>    |
|  OnceCell<T> / LazyCell |                     |  OnceLock<T> / LazyLock  |
+-------------------------+                     +--------------------------+

Combining Rc<T> and RefCell<T> for Shared Mutable Graphs

Pairing Rc<T> (multiple owners) with RefCell<T> (runtime-checked mutation) allows multiple handles to both read and mutate the same heap object on a single thread.

use std::cell::RefCell;
use std::rc::Rc;

#[derive(Debug)]
struct CounterNode {
    name: &'static str,
    value: RefCell<i32>,
}

fn main() {
    let shared_node = Rc::new(CounterNode {
        name: "primary",
        value: RefCell::new(10),
    });

    let observer_a = Rc::clone(&shared_node);
    let observer_b = Rc::clone(&shared_node);

    *observer_a.value.borrow_mut() += 5;
    *observer_b.value.borrow_mut() *= 2;

    println!(
        "node {:?} value = {}",
        shared_node.name,
        shared_node.value.borrow()
    );
}
node "primary" value = 30

Architectural Use Cases for Interior Mutability

  1. Logical immutability (lazy caching / memoization): A method whose observable behavior is read-only (fn compute(&self) -> u64) populates an internal OnceCell<u64> or RefCell<HashMap<K, V>> cache on first access without forcing callers to hold &mut self.
  2. Test doubles and mocks: Implementing a trait whose method takes &self (such as fn send(&self, msg: &str)) while recording sent messages in a RefCell<Vec<String>> for test assertions.
  3. Reference-counted graph nodes: Mutating edges or weights inside Rc<Node> or Arc<Node> structures.
use std::cell::OnceCell;

struct ExpensiveReport {
    raw_numbers: Vec<u64>,
    cached_sum: OnceCell<u64>,
}

impl ExpensiveReport {
    fn new(raw_numbers: Vec<u64>) -> Self {
        Self {
            raw_numbers,
            cached_sum: OnceCell::new(),
        }
    }

    fn sum(&self) -> u64 {
        *self
            .cached_sum
            .get_or_init(|| self.raw_numbers.iter().copied().sum())
    }
}

fn main() {
    let report = ExpensiveReport::new(vec![100, 200, 300, 400]);
    println!("first call:  {}", report.sum());
    println!("second call: {}", report.sum());
}
first call:  1000
second call: 1000