17 - Interior Mutability
📋 Jump to Takeaways🎁 The borrow checker says you can't mutate through a shared reference. But what if you need to? A cache that fills on first access, a counter inside a shared struct, multiple owners that all need to write. Rust has a way, and it doesn't require unsafe.
The Problem
The borrow checker enforces one rule: if you only have &T (a shared reference), you can't mutate. This comes up in two common situations:
&selfmethods — you want to mutate a field (a cache, a counter, a log buffer) but the method signature only gives you shared access- Through
Rc<T>—Rconly hands out&T, never&mut T, because multiple owners exist
In both cases, the compiler can't prove mutation is safe at compile time, so it rejects it. The types in this lesson are the escape hatch: they move the borrow check to runtime.
RefCell: Runtime Borrow Checking
RefCell<T> wraps a value and lets you get &mut T from a &T. The same borrowing rules apply (one mutable OR many shared), but they're checked when you call .borrow() or .borrow_mut() instead of at compile time. Violations panic instead of failing to compile.
use std::cell::RefCell;
struct Cache {
data: RefCell<Vec<i32>>,
}
impl Cache {
fn add(&self, val: i32) { // &self, not &mut self
self.data.borrow_mut().push(val);
}
fn get(&self) -> Vec<i32> {
self.data.borrow().clone()
}
}
fn main() {
let cache = Cache { data: RefCell::new(vec![]) };
cache.add(1);
cache.add(2);
println!("{:?}", cache.get()); // [1, 2]
}.borrow() gives a shared reference, .borrow_mut() gives a mutable one. The same rules as always, just checked at runtime. Holding both at the same time panics:
let data = RefCell::new(vec![1, 2]);
let _a = data.borrow();
let _b = data.borrow_mut(); // panics: already borrowed as immutableUse RefCell sparingly. If you find yourself reaching for it often, it usually means the struct's design can be improved to make the mutability explicit.
Cell: Copy-Based Interior Mutability
Cell<T> solves the same problem as RefCell<T> (mutating through &self) but for simple Copy types like integers and booleans. Instead of issuing references that need runtime borrow tracking, it just copies the value in and out with .get() and .set(). No borrow check, no panic possible.
use std::cell::Cell;
fn main() {
let counter = Cell::new(0);
counter.set(counter.get() + 1);
counter.set(counter.get() + 1);
println!("count: {}", counter.get()); // count: 2
}A practical use is a struct that tracks internal state through &self methods, something the borrow checker would normally forbid:
use std::cell::Cell;
struct Button {
label: String,
click_count: Cell<u32>,
}
impl Button {
fn click(&self) { // &self, not &mut self
self.click_count.set(self.click_count.get() + 1);
}
fn clicks(&self) -> u32 {
self.click_count.get()
}
}
fn main() {
let btn = Button { label: "Submit".to_string(), click_count: Cell::new(0) };
btn.click();
btn.click();
println!("{} clicked {} times", btn.label, btn.clicks()); // Submit clicked 2 times
}Cell<T> only works with Copy types (integers, booleans, char). For String, Vec, or anything heap-allocated, use RefCell<T> instead. It issues references rather than copying.
Cell vs RefCell
Cell<T> |
RefCell<T> |
|
|---|---|---|
| Works with | Copy types only |
Any type |
| API | .get() / .set() (copies value) |
.borrow() / .borrow_mut() (returns references) |
| Can panic? | No | Yes, if you violate borrow rules |
| Use when | You need a simple counter or flag | You need to mutate a Vec, String, or complex type |
Threading Equivalents
Cell<T> is not thread-safe. It doesn't implement Sync, so the compiler rejects it at thread boundaries. Think of it as the single-threaded equivalent of an atomic variable. When you cross a thread boundary, swap it for the real thing:
| Need | Single-threaded | Multi-threaded |
|---|---|---|
Mutate an integer through &self |
Cell<i32> |
AtomicI32 |
Mutate a bool through &self |
Cell<bool> |
AtomicBool |
Mutate a Vec or String through &self |
RefCell<T> |
Mutex<T> |
Atomics use atomic CPU instructions for thread-safe access. Cell opts for no overhead at all (single-threaded only), while atomics use CPU-level synchronization so multiple threads can safely read and write without a lock.
Rc<RefCell>: Shared Mutable Data
Rc<T> gives you multiple owners. RefCell<T> gives you interior mutability. Combine them and you get data that multiple parts of the program can own and mutate.
use std::rc::Rc;
use std::cell::RefCell;
fn main() {
let shared = Rc::new(RefCell::new(0));
let a = Rc::clone(&shared);
let b = Rc::clone(&shared);
*a.borrow_mut() += 10;
*b.borrow_mut() += 5;
println!("shared: {}", shared.borrow()); // shared: 15
}Why does this need RefCell? Because Rc only gives you &T. Without RefCell, you'd have three read-only handles to the same integer. RefCell lets each handle call .borrow_mut() and get &mut T at runtime.
This pattern has a direct multi-threaded equivalent: Arc<Mutex<T>>. Arc is the thread-safe version of Rc, and Mutex is the thread-safe version of RefCell. For single-threaded code, Rc<RefCell<T>> is cheaper because neither type uses atomic operations or OS locks.
Key Takeaways
RefCell<T>defers borrow checks to runtime; violations panic instead of failing to compile.borrow()gives a shared reference,.borrow_mut()gives a mutable one; overlapping them panicsRefCellis for when you only have&Tbut need&mut T(both&selfmethods and throughRc)Cell<T>is interior mutability forCopytypes; moves values in/out with.get()/.set(), no borrow check or panic possibleCell<T>andRefCell<T>are single-threaded only; equivalents across threads are atomics andMutex<T>Rc<RefCell<T>>combines shared ownership with interior mutability for single-threaded code- The multi-threaded equivalent is
Arc<Mutex<T>>
🎁 Your data is owned, shared, and mutated safely. But how do you split a growing codebase across multiple files and share it with the world? Next up: Rust's module system, crates, and how Cargo wires them all together.