Telex logo Telex

Rust Patterns That Matter #4: Interior Mutability

Post 4 of 22 in Rust Patterns That Matter. Companion series: Building a Chat Server in Rust.

Previous: #3: Error Handling | Next: #5: Shared Ownership

You have a struct with a cache. The struct is passed around by shared reference. You need to insert into the cache inside a method that takes &self. The compiler says no. This is the first wall.

The motivation

Imagine a configuration loader that parses expensive files on demand and caches the results. Callers only need a shared reference - they're reading configuration, not modifying the loader itself. But the loader needs to write to its internal cache.

use std::collections::HashMap;

struct ConfigLoader {
    cache: HashMap<String, String>,
}

impl ConfigLoader {
    fn get(&self, key: &str) -> Option<&String> {
        if !self.cache.contains_key(key) {
            let value = self.load_from_disk(key);
            self.cache.insert(key.to_string(), value); // ERROR
        }
        self.cache.get(key)
    }
}

The compiler rejects this:

error[E0596]: cannot borrow `self.cache` as mutable,
              as it is behind a `&` reference

The method takes &self - a shared, immutable reference. Shared references are read-only. That's not a suggestion; it's a guarantee the entire borrow system depends on. You cannot mutate through &self.

Why the compiler does this

Rust's core safety guarantee: if you have a &T, nobody is mutating T right now. If you have a &mut T, nobody else is looking at T right now. These two rules eliminate data races, iterator invalidation, and use-after-free - at compile time. No runtime cost.

The problem is that the cache is a legitimate mutation behind a logically immutable interface. From the caller's perspective, get() is a read operation. The cache is an implementation detail. But the type system doesn't know that - it sees a write through a shared reference and says no.

The pattern: RefCell<T>

RefCell<T> moves borrow checking from compile time to runtime. It wraps a value and tracks borrows dynamically: you call .borrow() for shared access and .borrow_mut() for exclusive access. The rules are identical - many readers or one writer, never both - but they're enforced when the code runs instead of when it compiles.

use std::cell::RefCell;
use std::collections::HashMap;

struct ConfigLoader {
    cache: RefCell<HashMap<String, String>>,
}

impl ConfigLoader {
    fn get(&self, key: &str) -> Option<String> {
        if !self.cache.borrow().contains_key(key) {
            let value = self.load_from_disk(key);
            self.cache.borrow_mut().insert(key.to_string(), value);
        }
        self.cache.borrow().get(key).cloned()
    }
}

The method still takes &self. The cache is wrapped in RefCell, which allows mutation through a shared reference. The compiler is satisfied because RefCell promises to enforce the borrow rules itself.

What happens at runtime

RefCell maintains a counter. Each .borrow() increments it; each dropped borrow decrements it. .borrow_mut() checks that the counter is zero (no outstanding borrows) and sets a flag. If you violate the rules - say, calling .borrow_mut() while a .borrow() is still alive - it panics.

let cell = RefCell::new(42);

let a = cell.borrow();     // shared borrow — fine
let b = cell.borrow();     // second shared borrow — fine
let c = cell.borrow_mut(); // PANIC: already borrowed

The panic is not a bug in your program's logic (though it might indicate one). It's RefCell enforcing the same rule the compiler would have enforced at compile time: you cannot have a mutable borrow while shared borrows exist.

The tradeoff

With RefCell, you trade a compile-time guarantee for a runtime check. The compiler no longer catches double-mutable-borrows - you'll find out at runtime via a panic. This is a conscious, local relaxation of Rust's rules, not a hole in the safety model. The borrow rules are still enforced; the enforcement just moved.

The runtime cost is small - an integer check on each borrow. In practice, the real cost is the possibility of panics. If you can structure your code so the compiler checks borrows statically (by taking &mut self instead), do that. Reach for RefCell when the API genuinely requires shared access with internal mutation.

When to use it

Good uses:

When not to use it:

What comes next

RefCell solves the mutation problem, but only for a single owner. When multiple parts of your program need to share the same data, you need Rc - which is the next post. And when you need shared ownership and mutation, #6 puts the two together.

See it in practice: Building a Chat Server #2: Rooms and Users uses this pattern for runtime-checked mutable room membership.