Understanding Cell and RefCell in Rust: Interior Mutability for Real Work

 


Rust’s ownership and borrowing rules keep your programs safe by pushing many errors to compile time. Sometimes, though, code needs to mutate internal state even when it is behind a shared reference. Think of caches, lazy initialization, graph structures with shared ownership, and objects that must expose a read only API while quietly tracking metrics inside. The standard tool for this is interior mutability. In practice, two core types power this pattern in single threaded code: Cell and RefCell. This post explains what they are, how they differ, when to use each one, and how to apply them in realistic examples. We will go step by step, show common pitfalls, and close with a quick comparison to other options like Mutex, RwLock, and atomic types.

What interior mutability means

Normally, if you have &T you cannot change T. Only &mut T permits mutation. Interior mutability flips this by allowing controlled mutation through a shared reference, while still preserving high level safety. The underlying primitive is UnsafeCell, which tells the compiler that a value may be mutated through shared references as long as you uphold the rules. Cell and RefCell build safe APIs on top of UnsafeCell.

The short version

Cell is the simplest. It works best for Copy types like u32, bool, char, and small Copy structs. You do not get a reference to the inner value. Instead you move values in and out using get, set, replace, take, and swap. There is no runtime borrow checking. Every operation is O(1) and extremely fast. The type is not Sync, so it is for single threaded use.

RefCell is for values that need borrowing by reference. It permits &T and &mut T access at runtime through borrow and borrow_mut. It enforces Rust’s aliasing rules dynamically using a borrow counter. If you violate the rules, you get a panic at runtime. That is the tradeoff for flexibility. It is also not Sync, so it is for single threaded use. In practice, RefCell is often seen with Rc as Rc<RefCell> to enable shared ownership and mutation.

When to reach for Cell

Use Cell when the inner type is Copy or when you can move it in and out without needing references. Good examples include counters, flags, cached numeric results, or quick swaps of small values.

Example 1. A cheap request counter hidden behind an immutable API

use std::cell::Cell;

struct RequestStats {
    // Visible as read only to callers, but we still track counts
    total: Cell<u64>,
    errors: Cell<u64>,
}

impl RequestStats {
    fn new() -> Self {
        Self { total: Cell::new(0), errors: Cell::new(0) }
    }

    // Immutable method, mutates internals via Cell
    fn record_ok(&self) {
        let t = self.total.get();
        self.total.set(t + 1);
    }

    fn record_err(&self) {
        let t = self.total.get();
        self.total.set(t + 1);
        let e = self.errors.get();
        self.errors.set(e + 1);
    }

    fn totals(&self) -> (u64, u64) {
        (self.total.get(), self.errors.get())
    }
}

fn main() {
    let stats = RequestStats::new();
    stats.record_ok();
    stats.record_ok();
    stats.record_err();
    assert_eq!(stats.totals(), (3, 1));
}

This works because u64 is Copy. No borrowing is needed. The API remains simple and fast.

Example 2. Swapping small values without mutable references

use std::cell::Cell;

#[derive(Copy, Clone, Debug, PartialEq)]
struct Point {
    x: i32,
    y: i32,
}

fn main() {
    let p = Cell::new(Point { x: 3, y: 5 });
    let old = p.replace(Point { x: 10, y: 20 });
    assert_eq!(old, Point { x: 3, y: 5 });
    assert_eq!(p.get(), Point { x: 10, y: 20 });
}

Here Point is Copy so get works. If Point were not Copy you could still call replace or into_inner, but you could not call get.

Useful Cell methods to know

get copies out the value. Requires T: Copy.
set moves a new value in.
replace moves in a new value and returns the old one.
take replaces the current value with T::default and returns the old one. Requires T: Default.
swap exchanges the values of two Cells.
get_mut returns a mutable reference to the inner value, but only if you have &mut Cell. That does not use interior mutability.

When to reach for RefCell

Use RefCell when you need to lend references to the inner value at runtime. Typical cases include shared graphs and trees built with Rc, implementing patterns like observer lists, and building caches that hold non Copy data where callers need a reference without taking ownership.

The key rule is the same as with normal borrowing. At any time you may have many immutable borrows or one mutable borrow. The difference is that checks happen at runtime, not compile time. Violating the rule will panic.

Example 3. A simple object that caches a derived value lazily

Suppose we want to compute a heavy formatted representation once and reuse it. The formatted string is not Copy. Cell is not a fit because we need to store a String and later return a reference to it. RefCell solves that.

use std::cell::RefCell;

struct User {
    first: String,
    last: String,
    // None until first use, then Some with cached value
    display_cache: RefCell<Option<String>>,
}

impl User {
    fn new(first: impl Into<String>, last: impl Into<String>) -> Self {
        Self {
            first: first.into(),
            last: last.into(),
            display_cache: RefCell::new(None),
        }
    }

    // Immutable method with interior mutability
    fn display(&self) -> String {
        if let Some(cached) = self.display_cache.borrow().as_ref() {
            return cached.clone();
        }
        let formatted = format!("{}, {}", self.last, self.first);
        *self.display_cache.borrow_mut() = Some(formatted.clone());
        formatted
    }
}

fn main() {
    let u = User::new("Ada", "Lovelace");
    let a = u.display();
    let b = u.display();
    assert_eq!(a, "Lovelace, Ada");
    assert_eq!(a, b);
}

We borrow immutably to check the cache. If empty, we take a mutable borrow, fill it, and return a clone. We chose to clone here to avoid handing out a reference that ties to a Ref lifetime. You can also return a Ref by storing String in RefCell and avoiding the Option, but that complicates the interface.

Example 4. Rc<RefCell> for a tree with parent pointers

Many data structures want shared ownership of nodes and the ability to mutate children after sharing. Rc<RefCell> is the standard pattern in single threaded code. Use Weak to break cycles between parent and child.

use std::cell::RefCell;
use std::rc::{Rc, Weak};

type NodeRef = Rc<RefCell<Node>>;

struct Node {
    value: i32,
    parent: Weak<RefCell<Node>>,
    children: Vec<NodeRef>,
}

impl Node {
    fn new(value: i32) -> NodeRef {
        Rc::new(RefCell::new(Node {
            value,
            parent: Weak::new(),
            children: Vec::new(),
        }))
    }

    fn add_child(parent: &NodeRef, value: i32) -> NodeRef {
        let child = Node::new(value);
        child.borrow_mut().parent = Rc::downgrade(parent);
        parent.borrow_mut().children.push(child.clone());
        child
    }

    fn root_values_sum(start: &NodeRef) -> i32 {
        // Walk up using Weak to find the root, then sum a subtree
        let mut cur = Rc::clone(start);
        while let Some(p) = cur.borrow().parent.upgrade() {
            cur = p;
        }
        Self::sum(&cur)
    }

    fn sum(node: &NodeRef) -> i32 {
        let n = node.borrow();
        let mut total = n.value;
        for c in &n.children {
            total += Self::sum(c);
        }
        total
    }
}

fn main() {
    let root = Node::new(1);
    let a = Node::add_child(&root, 2);
    let b = Node::add_child(&root, 3);
    Node::add_child(&a, 4);
    Node::add_child(&b, 5);
    assert_eq!(Node::root_values_sum(&a), 1 + 2 + 4 + 3 + 5);
}

Here Rc provides shared ownership, RefCell provides interior mutability, and Weak breaks the parent child reference cycle. This pattern is common in interpreters, UI trees, and similar structures.

Runtime checking and how to avoid panics

RefCell enforces borrowing rules at runtime. borrow returns Ref and increments an immutable borrow counter. borrow_mut returns RefMut and sets a flag that blocks any other borrow until it drops. If you try to borrow mutably while an immutable borrow is alive, or borrow immutably while a mutable borrow is alive, your program will panic.

A common surprise is that borrows hold until the end of the scope, not until the last use. Create inner blocks to shorten lifetimes deliberately.

use std::cell::RefCell;

fn main() {
    let v = RefCell::new(vec![1, 2, 3]);

    {
        let r = v.borrow();      // immutable borrow lives until end of this block
        assert_eq!(r.len(), 3);
    } // r drops here

    {
        let mut w = v.borrow_mut(); // now OK
        w.push(4);
    } // w drops here

    assert_eq!(v.into_inner(), vec![1, 2, 3, 4]);
}

If you forget to end the scope, you will see a panic like already borrowed. Treat that as a design hint. Keep borrow windows small and obvious.

Choosing between Cell and RefCell

Pick Cell if all of the following are true.
You can work with values by move or by Copy, not by reference.
You do not need to hand out references to the inner value.
The value is small or simple enough that copying or swapping is cheap.

Pick RefCell if any of the following are true.
You need to hand out temporary references to the inner value.
You must store non Copy data that would be painful to move in and out.
You are building shared data structures with Rc that need mutation.

Common mistakes and how to fix them

Holding borrows too long. Use scopes to release Ref and RefMut early. Do not keep them as struct fields unless you know exactly what you are doing.

Creating reference cycles with Rc<RefCell>. A parent Vec<Rc<RefCell>> plus each child holding Rc to the parent will leak. Break the cycle by storing Weak in the parent or child where appropriate.

Using RefCell across threads. Neither Cell nor RefCell is Sync or Send by default. For multi threaded mutation use Mutex or RwLock. If your workload is read heavy, RwLock can help. If you only need atomics over simple numbers, prefer AtomicUsize or similar.

Returning references tied to Ref lifetimes. The reference you get from borrow is tied to the temporary Ref guard. If you return plain &T from a method you must keep the guard alive. Usually it is simpler to return an owned clone or to design an API that accepts a closure and runs it while holding the borrow.

Forgetting that Cell cannot give you &T. You cannot do let r = cell.get_ref(). The whole point of Cell is to avoid references. If you need a reference, you probably want RefCell.

Advanced Cell techniques

Interior swap for small state machines. You can use Cell<Option> to pop or push a value without allocations.

use std::cell::Cell;

struct Slot<T> {
    inner: Cell<Option<T>>,
}

impl<T> Slot<T> {
    fn new() -> Self { Self { inner: Cell::new(None) } }
    fn put(&self, v: T) {
        assert!(self.inner.replace(Some(v)).is_none(), "slot already filled");
    }
    fn take(&self) -> Option<T> {
        self.inner.take()
    }
}

fn main() {
    let s = Slot::new();
    s.put("hello".to_string());
    let v = s.take().unwrap();
    assert_eq!(v, "hello");
    assert!(s.take().is_none());
}

Note that Option is not Copy, but Cell<Option> still works because replace and take move values instead of borrowing them.

Exposing counters without &mut self. API designers often want methods like fn get(&self) -> &Item that do not require exclusive access, but still want to instrument counts. Put the counts in Cell and keep the API immutable.

Advanced RefCell techniques

Map borrowed references with Ref::map and RefMut::map. These methods transform a borrow of a container into a borrow of a field without extra borrowing steps. This is useful when you want to expose a subfield while holding one borrow.

use std::cell::{RefCell, Ref};

struct Outer { inner: String }

fn first_char<'a>(o: &'a RefCell<Outer>) -> Option<char> {
    let r = o.borrow();              // Ref<Outer>
    let inner: Ref<'a, String> = std::cell::Ref::map(r, |o| &o.inner);
    inner.chars().next()
}

Replace long critical sections with closures. Another pattern is to design methods that accept a closure which runs while holding a borrow. That keeps lifetimes clear and avoids accidental long borrows.

use std::cell::RefCell;

struct Buffer(RefCell<Vec<u8>>);

impl Buffer {
    fn with<R>(&self, f: impl FnOnce(&[u8]) -> R) -> R {
        let r = self.0.borrow();
        f(&r)
    }

    fn with_mut<R>(&self, f: impl FnOnce(&mut Vec<u8>) -> R) -> R {
        let mut w = self.0.borrow_mut();
        f(&mut w)
    }
}

Performance notes

Cell is basically a thin wrapper. Reads and writes are as fast as direct memory access for Copy types, and moves for non Copy types. If you can use Cell, do it. It has no reference counting or borrow bookkeeping.

RefCell adds a small runtime check on every borrow and borrow_mut. In tight loops that take many small borrows, this can matter. Where possible, hold a single borrow across the loop body instead of borrowing per iteration. If profiling shows a bottleneck and your usage is single threaded, consider restructuring code to use Cell or normal &mut T in fewer places.

How this relates to multi threaded code

Cell and RefCell are not Sync, so they cannot be shared across threads. For interior mutability across threads reach for Mutex or RwLock from std::sync. The mental model is similar to RefCell in that locking is a runtime check and misuse can deadlock or poison, but the compiler enforces Send and Sync bounds. For counters and flags use atomic types like AtomicUsize or AtomicBool. If you need lazy one time initialization without locks, look at OnceCell and LazyLock.

Alternatives worth knowing

OnceCell and LazyLock provide thread safe or single threaded lazy initialization. They are ideal for initialize once patterns.

RwLock gives many readers or one writer across threads. Use when most operations are reads.

Mutex gives exclusive access across threads. It is simple and widely applicable.

Atomic types provide lock free updates to integers, pointers, and flags. They are great for counters and cheap state transitions, but do not replace general interior mutability.

Putting it all together

Interior mutability solves real problems. Use Cell for small Copy like state and very cheap in place updates without borrowing. Use RefCell when you must hand out references or store non Copy data and mutate it behind a shared reference. Combine Rc<RefCell> to share and mutate single threaded data structures, and use Weak to avoid cycles. Keep borrow windows short, treat runtime panics as design feedback, and profile when RefCell sits on hot paths. When you move to multi threaded code, switch to Mutex, RwLock, and atomic types.

One last example showing Cell and RefCell side by side

use std::cell::{Cell, RefCell};
use std::rc::Rc;

// A read only cache wrapper with a hit counter
struct Cache<T> {
    value: RefCell<Option<T>>,
    hits: Cell<u64>,
    misses: Cell<u64>,
}

impl<T> Cache<T> {
    fn new() -> Self {
        Self {
            value: RefCell::new(None),
            hits: Cell::new(0),
            misses: Cell::new(0),
        }
    }

    fn get_or_init(&self, init: impl FnOnce() -> T) -> Rc<T> {
        if let Some(existing) = self.value.borrow().as_ref() {
            self.hits.set(self.hits.get() + 1);
            // store Rc<T> to share cheaply
            if let Some(rc) = existing_downcast_rc(existing) {
                return rc;
            }
        }
        self.misses.set(self.misses.get() + 1);
        let rc = Rc::new(init());
        *self.value.borrow_mut() = Some(unsafe_upcast_rc(rc.clone()));
        rc
    }

    fn stats(&self) -> (u64, u64) {
        (self.hits.get(), self.misses.get())
    }
}

// For illustration only. You would usually store Rc<T> directly.
// The helpers below pretend Option<T> is holding Rc<T>.
fn unsafe_upcast_rc<T>(rc: Rc<T>) -> T where T: Clone {
    // This is a placeholder to show the shape of an API decision.
    // In a real implementation, store Option<Rc<T>> and return Rc<T> directly.
    rc.as_ref().clone()
}

fn existing_downcast_rc<T>(_t: &T) -> Option<Rc<T>> { None }

fn main() {
    // Real world code should store Option<Rc<T>> in the cache.
    // The point is how Cell and RefCell combine: counts via Cell, data via RefCell.
    let cache = Cache::<String>::new();
    let first = cache.get_or_init(|| "value".to_string());
    let second = cache.get_or_init(|| "ignored".to_string());
    assert_eq!(&*first, &*second);
    assert_eq!(cache.stats(), (1, 1));
}

The helpers above are stubs to emphasize a design choice. In production you would define value: RefCell<Option<Rc>> and simplify get_or_init to avoid any unsafe tricks. The important part is the pattern. Counts use Cell. Data lives in RefCell, with Rc for cheap sharing.

If you keep these principles in mind you will know when Cell is enough, when RefCell is necessary, and when to move to synchronization primitives for multi threaded code. That is the heart of interior mutability in Rust, and it lets you design clean APIs that remain fast and safe.


Comments

Popular posts from this blog

Mastering Prompt Engineering: How to Think Like an AI and Write Prompts That Never Fail

Is Docker Still Relevant in 2025? A Practical Guide to Modern Containerization

Going In With Rust: The Interview Prep Guide for the Brave (or the Mad)