DEV Community

Gregory Chris
Gregory Chris

Posted on

Interior Mutability Explained: When and Why to Use Cell and RefCell

Interior Mutability Explained: When and Why to Use Cell and RefCell

Rust is beloved for its safety guarantees, but these guarantees often come with constraints. One of the most intriguing aspects of Rust’s type system is interior mutability, which lets you mutate data even when you only have an immutable reference to it. If that sounds paradoxical, don’t worry—you’re not alone.

In this blog post, we’ll explore the concept of interior mutability in Rust, focusing on two powerful tools: Cell and RefCell. We’ll dive into what they are, how they work, when to use them, and common pitfalls to avoid. By the end, you’ll know how to wield these tools effectively, and you’ll have a practical example to bring it all together.


Why Interior Mutability Matters

Rust’s borrow checker is famous for enforcing strict rules about ownership and borrowing, ensuring memory safety without garbage collection. These rules generally prevent data races and dangling pointers, but they can feel restrictive in some scenarios.

For example, what if you need to mutate a field of a struct but only have an immutable reference to the struct? This is where interior mutability comes in. Interior mutability allows you to bypass some of Rust’s borrowing rules safely by encapsulating mutable operations inside smart pointer types like Cell and RefCell.

Think of it as a loophole—but not the dangerous kind. This loophole is carefully designed to preserve Rust’s safety guarantees.


Meet Cell and RefCell

Interior mutability in Rust is powered by types from the std::cell module, primarily Cell and RefCell. Both allow you to mutate data through immutable references, but they serve different purposes and come with different trade-offs.

Cell

Cell is the simpler of the two. It provides value-based interior mutability, meaning it works with types that implement the Copy trait. A Cell stores a single value and lets you replace or get the value without requiring a mutable reference.

Key Features

  • Operates on Copy types (like integers, booleans, etc.).
  • No runtime borrowing checks—everything happens at compile time.
  • Cannot be used for non-Copy types like String or Vec.

Example: Using Cell for Counters

use std::cell::Cell;

struct Counter {
    count: Cell<u32>,
}

impl Counter {
    fn new() -> Self {
        Counter { count: Cell::new(0) }
    }

    fn increment(&self) {
        self.count.set(self.count.get() + 1);
    }

    fn get_count(&self) -> u32 {
        self.count.get()
    }
}

fn main() {
    let counter = Counter::new();
    counter.increment();
    counter.increment();

    println!("Count: {}", counter.get_count()); // Output: Count: 2
}
Enter fullscreen mode Exit fullscreen mode

Here, the Cell allows us to increment the counter even though the Counter struct is accessed through an immutable reference.


RefCell

RefCell is more versatile than Cell. It provides reference-based interior mutability, allowing you to mutate non-Copy types like String or Vec. Unlike Cell, it performs runtime borrowing checks to ensure safe access to mutable and immutable borrows.

Key Features

  • Can store any type, including non-Copy types.
  • Enforces borrowing rules at runtime, not compile time.
  • Panics if borrowing rules are violated (e.g., multiple mutable borrows).

Example: Using RefCell for Shared Mutability

use std::cell::RefCell;

struct SharedVec {
    data: RefCell<Vec<i32>>,
}

impl SharedVec {
    fn new() -> Self {
        SharedVec {
            data: RefCell::new(vec![]),
        }
    }

    fn add(&self, value: i32) {
        self.data.borrow_mut().push(value);
    }

    fn get_all(&self) -> Vec<i32> {
        self.data.borrow().clone()
    }
}

fn main() {
    let shared_vec = SharedVec::new();
    shared_vec.add(1);
    shared_vec.add(2);

    println!("{:?}", shared_vec.get_all()); // Output: [1, 2]
}
Enter fullscreen mode Exit fullscreen mode

Here, the RefCell allows us to mutate the Vec inside the SharedVec struct even though we’re accessing it through an immutable reference to SharedVec.


Building a Cache with RefCell and Option

Let’s bring it all together with a practical example: a simple cache system. Imagine we want to store the result of a computation and reuse it later. The cache will start empty (None) and only compute the value when requested for the first time.

Here’s how we can implement this using RefCell and Option:

use std::cell::RefCell;

struct Cache<T> {
    value: RefCell<Option<T>>,
}

impl<T> Cache<T>
where
    T: Fn() -> u32,
{
    fn new() -> Self {
        Cache {
            value: RefCell::new(None),
        }
    }

    fn get_or_compute(&self, compute_fn: T) -> u32 {
        if self.value.borrow().is_none() {
            let computed_value = compute_fn();
            *self.value.borrow_mut() = Some(computed_value);
        }
        self.value.borrow().unwrap()
    }
}

fn main() {
    let cache = Cache::new();

    let expensive_computation = || {
        println!("Computing...");
        42 // pretend this is expensive
    };

    // The computation will only happen once
    println!("Result: {}", cache.get_or_compute(expensive_computation)); // Computing... Result: 42
    println!("Result: {}", cache.get_or_compute(expensive_computation)); // Result: 42
}
Enter fullscreen mode Exit fullscreen mode

In this example, RefCell wraps an Option type, allowing us to mutate the cache’s value even when accessed through an immutable reference. This is a common pattern for lazy initialization.


Common Pitfalls and How to Avoid Them

While Cell and RefCell are powerful tools, they come with caveats:

1. Panics in RefCell

RefCell performs runtime borrowing checks, and violating these rules (e.g., trying to borrow mutably while an immutable borrow is active) will cause a panic.

How to Avoid

Always ensure that your borrowing patterns are correct. Use .borrow() for immutable access and .borrow_mut() for mutable access.

2. Performance Costs

RefCell incurs a slight runtime overhead due to its dynamic checks. If performance is critical, consider alternatives like Rc + Mutex or redesigning your architecture.

3. Misuse of Cell

Cell is limited to Copy types. Trying to store complex types in a Cell will result in a compile-time error.

How to Avoid

Use RefCell for types that don’t implement Copy.


Key Takeaways

  • Interior Mutability lets you mutate data through immutable references, bypassing Rust’s usual borrowing rules in a safe way.
  • Use Cell for simple, Copy types where compile-time checks are sufficient.
  • Use RefCell for more complex types that require runtime borrowing checks.
  • Be mindful of runtime costs and borrowing pitfalls when using RefCell.

Next Steps

Interior mutability is just one piece of the puzzle when it comes to shared mutability in Rust. To deepen your knowledge, explore these topics:

  • Rc and Arc for reference counting and shared ownership.
  • Mutex and RwLock for thread-safe shared mutability.
  • Pinning and UnsafeCell for advanced mutability patterns.

Experiment with Cell and RefCell in your projects, and don’t be afraid to refactor if you find better solutions. Rust’s type system is your ally—it’s there to help you write safe, efficient, and maintainable code.

Happy coding! 🚀

Top comments (0)