Cell<T> mutates by moving whole values in and out, handing out no references at all;RefCell<T> mutates through borrows that are checked at runtime in stead of compile time, and what that costs;Cell versus RefCell, reduced to one simple question;&self -- the pattern behind counters, caches, loggers, and the famous Rc<RefCell<T>> combination.Learn Rust Series):At the end of the last episode I promised something that should sound a little bit impossible after everything we drilled into your head about ownership: types that let you reach in and mutate a value even when all you are holding is a shared, immutable-looking reference. That is interior mutability, and it is not a loophole or a hack -- it is a first-class, fully safe part of the language, and today we meet the two single-threaded tools for it: Cell<T> and RefCell<T>.
Having said that, this matters more than it might look at first. Rust's default rule is strict: if you only have a shared reference &T, you cannot change what it points to. That rule is what prevents data races, and it is right the vast majority of the time. But occasionally it is simply too tight. A method that logically only reads still needs to bump a hit-counter, or fill in a lazily-computed cache, or append to a log -- and forcing that method to take &mut self would poison every caller up the chain. Interior mutability is the sanctioned escape hatch for exactly these cases. Let me clear last episode's homework first, as always, and then we bend the rules ;-)
Episode 31 was the move semantics deep dive. There were three tasks, and here is full runnable code for each.
Exercise 1 asked you to move a Vec<String> into a function that prints its length, read the move error when you try to use the vector afterward, and then fix it by having the function borrow in stead of taking ownership:
fn print_len_move(v: Vec<String>) {
println!("len is {}", v.len());
} // v is dropped here -- the caller no longer owns it
fn print_len_borrow(v: &Vec<String>) {
println!("len is {}", v.len());
} // nothing is dropped -- we only borrowed
fn main() {
let names = vec![String::from("ada"), String::from("bit")];
// print_len_move(names);
// println!("{names:?}"); // would NOT compile: value borrowed after move
print_len_borrow(&names); // borrow: names survives
println!("{names:?}"); // still valid: ["ada", "bit"]
}
The moving version consumes names outright, so any use after the call is a compile error ("borrow of moved value"). Switching the signature to &Vec<String> -- or, more idiomatically, &[String] -- says "I only want to look", and the caller keeps ownership. The signature is the contract, exactly as we discussed.
Exercise 2 wanted a struct with two String fields, moving one field out into its own binding, then confirming you can still read a Copy field but can no longer move the struct as a whole:
struct Post {
title: String,
author: String,
votes: u32, // Copy field
}
fn main() {
let p = Post {
title: String::from("interior mutability"),
author: String::from("scipio"),
votes: 42,
};
let title = p.title; // moves the title String out of p
println!("took: {title}");
println!("author still here: {}", p.author); // the other String is untouched
println!("votes (Copy): {}", p.votes); // u32 reads fine, it is Copy
// let whole = p; // would NOT compile: p is partially moved
}
Moving p.title out leaves p in a partially moved state. The other String field (author) is still perfectly readable, and the Copy field votes reads fine because accessing a u32 copies its four bytes rather than moving anything. What you cannot do is move p as a whole, because part of it has already left -- the compiler tracks moved-ness per field, which is more precise than most people expect.
Exercise 3 asked you to take ownership of names[0] without removing it from the vector, leaving a valid placeholder behind, using std::mem::replace or std::mem::take:
use std::mem;
fn main() {
let mut names = vec![String::from("ada"), String::from("bit")];
// let first = names[0]; // would NOT compile: cannot move out of index
let first = mem::replace(&mut names[0], String::from("-"));
println!("took {first}, vec now {names:?}"); // took ada, vec now ["-", "bit"]
// mem::take swaps in the type's Default (an empty String) in stead:
let second = mem::take(&mut names[1]);
println!("took {second:?}, vec now {names:?}"); // took "bit", vec now ["-", ""]
}
You cannot move a String out of names[0] by plain indexing, because that would leave a hole -- a slot inside a live Vec that no longer holds a valid value. mem::replace swaps a valid placeholder in at the exact instant it hands you the old value, so the slot is never empty; mem::take does the same but fills the slot with Default::default(). Right, homework cleared -- now let us bend a rule ;-)
Rust's borrowing model, all the way back in episode 3, rests on one iron law: you may have either any number of shared references &T (read-only) or exactly one exclusive reference &mut T (read-write), but never both at once. Aliasing XOR mutability. This is what makes data races impossible, and it is checked entirely at compile time so it costs nothing at runtime.
Interior mutability does not abolish that law -- it relocates the check. In stead of the compiler proving, statically, that a mutation is exclusive, the wrapper type takes on the job of upholding the invariant by some other means. Cell<T> upholds it by never handing out a reference to its interior at all (so there is nothing to alias). RefCell<T> upholds it by tracking borrows at runtime and refusing -- loudly -- to hand out a conflicting one. Both are built on a primitive called UnsafeCell<T>, the only place in the whole language where you are allowed to get a &mut T out of a &T; every safe interior-mutability type is a careful, sound wrapper around it. You will almost never touch UnsafeCell directly, but it is good to know the floor you are standing on.
Cell<T> provides interior mutability the simplest way imaginable: it never gives you a reference to the value inside. You can only get a copy out or set a whole new value in. Because no reference to the interior ever escapes, there is nothing that could alias, no borrow to check, and therefore no runtime bookkeeping and no possibility of a panic:
use std::cell::Cell;
struct Counter {
count: Cell<u32>,
}
impl Counter {
fn increment(&self) { // note: &self, NOT &mut self
self.count.set(self.count.get() + 1);
}
fn value(&self) -> u32 {
self.count.get()
}
}
fn main() {
let c = Counter { count: Cell::new(0) };
c.increment();
c.increment();
c.increment();
println!("count is {}", c.value()); // 3
}
The striking line is fn increment(&self): it mutates the counter through a shared reference. That is the whole point of interior mutability, in one method signature. From the outside, Counter looks immutable -- callers only ever hold &Counter -- yet its state changes. Notice the pattern for a mutation with Cell: get the current value, compute the new one, set it back. There is no way to mutate the value in place, because that would require a &mut into the interior, which Cell deliberately never gives you.
Because it relies on copying values in and out, Cell is ideal for small Copy types: counters, flags, enum states, small numeric fields. For those it is the cheapest interior-mutability tool there is -- essentially free.
Beyond get and set, Cell offers replace, which stores a new value and returns the old one, and take, which leaves the type's Default behind and hands you what was there. These mirror the std::mem functions from episode 31, which is no coincidence -- they solve the same "get the old value out while leaving something valid in place" problem:
use std::cell::Cell;
fn main() {
let cell = Cell::new(10);
cell.set(20);
let old = cell.replace(30); // stores 30, returns the previous 20
println!("{} {}", old, cell.get()); // 20 30
let taken = cell.take(); // returns 30, leaves 0 (i32::default())
println!("{} {}", taken, cell.get()); // 30 0
}
One nice consequence of Cell never lending a reference: get only works when T: Copy, but replace, take, and set work for any T, even a non-Copy one like a String, because they move whole values in and out rather than reading through a borrow. So Cell<String> is legal and occasionally handy -- you just cannot peek at it without swapping something in, which is usually awkward enough that you would rather reach for the next tool.
Cell cannot hand out a reference, which makes it clumsy for anything you want to modify in place, like pushing onto a Vec or editing a String. RefCell<T> fills that gap. It does give you references -- borrow() for shared access and borrow_mut() for exclusive access -- and it enforces the aliasing-XOR-mutability rule by counting live borrows at runtime:
use std::cell::RefCell;
struct Log {
entries: RefCell<Vec<String>>,
}
impl Log {
fn add(&self, message: &str) { // &self, yet it mutates
self.entries.borrow_mut().push(message.to_string());
}
fn count(&self) -> usize {
self.entries.borrow().len()
}
fn dump(&self) -> String {
self.entries.borrow().join(", ")
}
}
fn main() {
let log = Log { entries: RefCell::new(Vec::new()) };
log.add("started");
log.add("working");
log.add("done");
println!("{} entries: {}", log.count(), log.dump());
// 3 entries: started, working, done
}
Every method here takes &self, yet add genuinely mutates the vector in place. RefCell allows it by checking, at each borrow and borrow_mut, that granting the request would not violate the rule. What borrow() returns is not a bare &Vec<String> but a smart-pointer guard of type Ref; borrow_mut() returns a RefMut. These guards deref to the inner value (that is why .push(...) and .len() just work, thanks to the Deref coercion from episode 19), and -- crucially -- they decrement the borrow count when they are dropped. That drop is what makes the whole scheme safe, and it is also where the one gotcha lives.
The trade for RefCell's flexibility is that its checks happen at runtime, and a violated check does not refuse to compile -- it panics. If you already hold a borrow and ask for a conflicting one, the program blows up rather than misbehaving quietly:
use std::cell::RefCell;
fn main() {
let data = RefCell::new(vec![1, 2, 3]);
let reader = data.borrow();
// let writer = data.borrow_mut(); // would panic: already borrowed
println!("length is {}", reader.len()); // 3
} // reader (a Ref guard) is dropped here, releasing the borrow
Uncomment that borrow_mut() line and the code compiles perfectly but panics at runtime with already borrowed: BorrowMutError. The reader guard is still alive on that line (it is not dropped until the end of the block), so a borrow_mut() would create a shared borrow and an exclusive borrow at the same time -- precisely the thing the rule forbids. RefCell catches it, but a beat too late to be a compile error.
The defence is a discipline: keep borrows short. Call borrow_mut(), do the mutation, and let the guard drop before you borrow again -- do not stash a Ref or RefMut in a long-lived variable if you can avoid it. When you must, wrap the borrow in its own little { ... } block so the guard drops at the closing brace. If you ever need the fallible version rather than a panic, try_borrow() and try_borrow_mut() return a Result you can handle. And note the asymmetry with Cell: Cell has no such risk at all, because it never lends a reference, so there is never a live borrow to conflict with. That safety is exactly why you prefer Cell whenever the data is Copy.
The decision is refreshingly mechanical. Ask one question: is the inner value Copy, and are you happy replacing it wholesale?
bool, a small enum -- use Cell. It is cheaper and it can never panic.Copy, like a Vec or a String -- use RefCell, and keep its borrows tight.Real types often want both, one per field, and that is completely idiomatic:
use std::cell::{Cell, RefCell};
struct Widget {
clicks: Cell<u32>, // Copy state -> Cell
labels: RefCell<Vec<String>>, // in-place data -> RefCell
}
impl Widget {
fn click(&self) {
self.clicks.set(self.clicks.get() + 1);
}
fn add_label(&self, s: &str) {
self.labels.borrow_mut().push(s.to_string());
}
}
fn main() {
let w = Widget { clicks: Cell::new(0), labels: RefCell::new(Vec::new()) };
w.click();
w.click();
w.add_label("ok");
println!("{} clicks, {} labels", w.clicks.get(), w.labels.borrow().len());
// 2 clicks, 1 labels
}
Both click and add_label mutate through &self, each field using the tool that fits it. This is the everyday shape of interior mutability in the wild -- not exotic at all, just a struct that quietly updates itself.
Here is where interior mutability earns its keep, and where you will meet it most often in real code. Back in episode 12 we met Rc<T>, the reference-counted pointer that lets several owners share one value. But Rc only ever hands out shared references -- Rc<T> derefs to &T, never &mut T, precisely because there could be other owners around. So how do you mutate a value that is shared by many Rc handles? You put a RefCell inside the Rc:
use std::rc::Rc;
use std::cell::RefCell;
fn main() {
let shared = Rc::new(RefCell::new(vec![1, 2, 3]));
let a = Rc::clone(&shared); // a second owner, same inner Vec
let b = Rc::clone(&shared); // a third owner
a.borrow_mut().push(4); // mutate through one handle...
b.borrow_mut().push(5); // ...and another
println!("{:?}", shared.borrow()); // [1, 2, 3, 4, 5]
println!("owners: {}", Rc::strong_count(&shared)); // 3
}
Rc<RefCell<T>> is the canonical "shared, mutable, single-threaded" building block. The Rc layer gives you shared ownership (multiple handles, freed when the last one goes), and the RefCell layer gives you interior mutability (mutate through the shared handle). It is how you build graphs, trees with back-pointers, observer lists, and other structures where several parts of a program need to see and change the same node. The borrow-at-runtime rule still applies through the RefCell, so the same "keep borrows short" discipline holds.
In the coming episodes we are going to pull Rc apart to see how the reference counting actually works under the hood, then graduate to its thread-safe sibling for when the data has to cross threads -- but the pattern you just saw, shared ownership on the outside and interior mutability on the inside, is one you will reach for constantly.
One more essential point: both Cell and RefCell are single-threaded only. Neither is Sync (the marker trait from episode 25), which means the compiler will flat-out refuse to share them across threads. That is not an oversight -- it is the whole reason they can skip atomic operations and stay cheap. RefCell's borrow counter is a plain integer, not an atomic one; if two threads could touch it at once you would have the exact data race the language exists to prevent. So Rust makes it a compile error to even try.
When you do need interior mutability across threads, you reach for the thread-safe equivalents -- the atomic types like AtomicU32 in place of Cell<u32>, and Mutex<T> or RwLock<T> in place of RefCell<T> -- which we first met alongside Arc in episode 13. They do the same job, but they pay for a lock or an atomic instruction to make it sound across threads. Use the cheap single-threaded tools when you can, and the thread-safe ones only when you must; picking the right one is just a matter of asking whether the value ever leaves its thread.
Since quite some of you came here from the Learn Python Series, a look sideways helps. In Python, everything is interior mutability -- there is no distinction between shared and exclusive access at all. Any name that refers to a mutable object can mutate it, and every other name pointing at that object sees the change immediately:
class Counter:
def __init__(self):
self.count = 0
def increment(self): # no &self vs &mut self -- Python has no such thing
self.count += 1
c = Counter()
c.increment()
c.increment()
print(c.count) # 2 -- and any alias of c would see it too
That is convenient, and it is also exactly the unrestricted aliasing that leads to "why did this change when I only touched the other reference?" bugs -- and, in threaded code, to real data races that Python papers over with a global interpreter lock. Rust's position is the opposite: mutation-through-sharing is off by default, and you opt back into it, explicitly, one field at a time, with a type (Cell or RefCell) that names the trade you are making and keeps the safety guarantees intact. You give up a little convenience and you get, in return, a compiler that still guarantees no data races even in the places where you deliberately bent the rule. I argue that is a very good deal.
&T, by relocating the aliasing-XOR-mutability check away from the compiler and into a wrapper type. UnsafeCell<T> is the primitive underneath; Cell and RefCell are the safe single-threaded wrappers.Cell<T> never hands out a reference -- you get/set/replace/take whole values. No borrow to check, no runtime cost, no panic. Best for small Copy types like counters and flags.RefCell<T> hands out Ref/RefMut guards via borrow() and borrow_mut(), enforcing the borrow rules at runtime. Best for in-place mutation of non-Copy data like a Vec or String.RefCell is a runtime panic (BorrowMutError) on a conflicting borrow. The cure is discipline: keep borrows short, scope guards tightly, and use try_borrow when you want a Result in stead of a panic.Copy and happy to swap whole values -> Cell; need in-place or non-Copy -> RefCell. A struct may use both, one per field.Rc<RefCell<T>> is the canonical single-threaded "shared and mutable" building block: Rc for shared ownership, RefCell for mutation through the shared handle.Sync -- the compiler forbids sharing them across threads. For that you use atomics, Mutex, or RwLock with Arc.That is the whole model. Interior mutability sounds like it should be forbidden after everything episode 31 taught about ownership, but it is really just the ownership rules with the check moved to a place that can uphold them differently -- either by lending nothing (Cell) or by counting borrows as they happen (RefCell). One idea at a time ;-)
Three exercises this time, gentle to chewier. Genuinely try them before the next episode -- typing it yourself is where it sticks.
Config struct with a Cell<bool> "dirty" flag, a mark_dirty(&self) method that sets it to true, and an is_dirty(&self) -> bool reader. Confirm you can flip the flag while only ever holding a &Config.Cache that wraps a RefCell<std::collections::HashMap<u32, u64>> and has one method fib(&self, n: u32) -> u64 that memoises Fibonacci results in the map -- all through &self. Make sure any borrow_mut() guard is dropped before you recurse, so you never trigger a BorrowMutError.BorrowMutError: hold a borrow() in a variable while calling a method that does borrow_mut() on the same RefCell, observe the panic, then fix it by wrapping the first borrow in its own { ... } block so its guard drops in time.Right, that is Cell and RefCell demystified -- thanks for reading, and I'll catch you in the next one! ;-)