Advanced Rust Series Part 7: Smart Pointers and Interior Mutability – Box, Rc, Arc, RefCell, and Mutex

Advanced Rust Series Part 7: Smart Pointers and Interior Mutability – Box, Rc, Arc, RefCell, and Mutex

The ownership system guarantees a single owner per value. But real programs often need patterns that do not fit that model: shared ownership across multiple parts of your code, mutation behind an immutable interface, or ownership that lives until the last user is done. Rust provides a set of smart pointer types to handle these cases safely. This post covers when to use each one and what trade-offs each involves.

The Smart Pointer Landscape

flowchart TD
    A[Need smart pointer?] --> B{Single owner?}
    B -->|Yes, heap allocation needed| C[Box<T>]
    B -->|No, shared ownership| D{Thread safe?}
    D -->|Single thread| E[Rc<T>]
    D -->|Multi thread| F[Arc<T>]
    E --> G{Need interior mutation?}
    F --> H{Need interior mutation?}
    G -->|Yes| I[Rc<RefCell<T>>]
    G -->|No| J[Rc<T> alone]
    H -->|Yes| K[Arc<Mutex<T>> or Arc<RwLock<T>>]
    H -->|No| L[Arc<T> alone]

    style C fill:#2980b9,color:#fff
    style E fill:#8e44ad,color:#fff
    style F fill:#16a085,color:#fff
    style I fill:#c0392b,color:#fff
    style K fill:#c0392b,color:#fff

Box<T>: Heap Allocation With Single Ownership

Box<T> places a value on the heap. The Box itself is on the stack and owns the heap allocation. When the Box is dropped, the heap memory is freed.

fn main() {
    let b = Box::new(5); // 5 is on the heap
    println!("b = {}", b); // Box deref-coerces, prints 5
} // b dropped here, heap memory freed

// Primary use cases for Box:

// 1. Recursive data structures (size unknown at compile time)
#[derive(Debug)]
enum List {
    Cons(i32, Box), // without Box, size would be infinite
    Nil,
}

// 2. Large values you want to avoid copying on the stack
struct LargeConfig {
    data: [u8; 10_000],
}
fn process(config: Box) {
    // only the pointer is moved, not the 10KB
}

// 3. Trait objects
fn make_animal(kind: &str) -> Box {
    match kind {
        "dog" => Box::new("Woof"),
        _ => Box::new("..."),
    }
}

Box<T> has zero runtime overhead beyond the heap allocation itself. It implements Deref, so you can use *box to access the inner value, and Rust’s deref coercion handles most cases automatically.

Rc<T>: Reference-Counted Shared Ownership

Rc<T> (Reference Counted) allows multiple owners. It maintains a count of how many Rc handles point to the same data. When the count reaches zero, the data is dropped.

use std::rc::Rc;

fn main() {
    let data = Rc::new(vec![1, 2, 3]);
    
    let a = Rc::clone(&data); // increment count, not a deep copy
    let b = Rc::clone(&data); // increment again
    
    println!("Count: {}", Rc::strong_count(&data)); // 3
    println!("data: {:?}", data);
    println!("a:    {:?}", a);
    println!("b:    {:?}", b);
    
    drop(a); // count goes to 2
    println!("After drop: {}", Rc::strong_count(&data)); // 2
} // data and b dropped, count to 0, vec freed

Rc is single-threaded only. It uses a non-atomic reference count for performance. The compiler prevents you from sending an Rc across threads – it does not implement Send.

The data inside Rc<T> is immutable – you cannot get a mutable reference to it because there may be multiple owners. For mutation, pair it with RefCell<T>.

Arc<T>: Atomically Reference-Counted Shared Ownership

Arc<T> (Atomic Reference Counted) is the thread-safe version of Rc. The reference count is updated using atomic operations, making it safe to share across threads. This comes with a small performance cost compared to Rc.

use std::sync::Arc;
use std::thread;

fn main() {
    let data = Arc::new(vec![1, 2, 3, 4, 5]);
    
    let mut handles = vec![];
    
    for i in 0..3 {
        let data = Arc::clone(&data); // clone the Arc, not the data
        let handle = thread::spawn(move || {
            println!("Thread {}: {:?}", i, data);
        });
        handles.push(handle);
    }
    
    for handle in handles {
        handle.join().unwrap();
    }
}

Use Arc only when you actually need to share across threads. Single-threaded code should prefer Rc for the lower overhead.

RefCell<T>: Interior Mutability at Runtime

RefCell<T> moves the borrow checker rules from compile time to runtime. You can get a mutable reference to the contents even when the RefCell itself is behind an immutable reference. The borrowing rules still apply – violations panic at runtime instead of failing at compile time.

use std::cell::RefCell;

fn main() {
    let data = RefCell::new(vec![1, 2, 3]);
    
    // Shared borrow
    {
        let r = data.borrow();
        println!("{:?}", *r);
    } // r dropped, borrow released
    
    // Mutable borrow
    {
        let mut w = data.borrow_mut();
        w.push(4);
    } // w dropped, borrow released
    
    println!("{:?}", data.borrow()); // [1, 2, 3, 4]
    
    // Runtime panic example:
    // let r1 = data.borrow();
    // let r2 = data.borrow_mut(); // PANIC: already borrowed
}

Use try_borrow() and try_borrow_mut() for non-panicking versions that return Result. These are safer in production code where a panic would be unacceptable:

use std::cell::RefCell;

fn safe_update(cell: &RefCell>, value: i32) -> bool {
    match cell.try_borrow_mut() {
        Ok(mut v) => {
            v.push(value);
            true
        }
        Err(_) => {
            eprintln!("Could not acquire mutable borrow");
            false
        }
    }
}

Cell<T>: Zero-Cost Interior Mutability for Copy Types

Cell<T> provides interior mutability for Copy types with no runtime borrow checking overhead. Instead of giving out references, it works by copying values in and out:

use std::cell::Cell;

struct Counter {
    value: Cell,
    name: String,
}

impl Counter {
    fn new(name: &str) -> Self {
        Counter {
            value: Cell::new(0),
            name: name.to_string(),
        }
    }

    // &self, not &mut self - mutation through Cell
    fn increment(&self) {
        self.value.set(self.value.get() + 1);
    }

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

fn main() {
    let counter = Counter::new("hits");
    counter.increment();
    counter.increment();
    counter.increment();
    println!("{}: {}", counter.name, counter.count()); // hits: 3
}

Cell is ideal for small flags, counters, and cached computed values that are logically part of an otherwise immutable struct. It has no overhead because it never hands out references – you always copy the value.

Mutex<T> and RwLock<T>: Interior Mutability Across Threads

For shared mutable state across threads, Mutex<T> and RwLock<T> provide interior mutability with OS-level synchronization:

use std::sync::{Arc, Mutex, RwLock};
use std::thread;

fn mutex_example() {
    let counter = Arc::new(Mutex::new(0u32));
    let mut handles = vec![];

    for _ in 0..10 {
        let counter = Arc::clone(&counter);
        handles.push(thread::spawn(move || {
            let mut count = counter.lock().unwrap();
            *count += 1;
        })); // MutexGuard dropped here, lock released
    }

    for h in handles { h.join().unwrap(); }
    println!("Final count: {}", *counter.lock().unwrap()); // 10
}

fn rwlock_example() {
    // RwLock: many readers OR one writer, not both
    let data = Arc::new(RwLock::new(vec![1, 2, 3]));

    // Multiple readers simultaneously
    let r1 = data.read().unwrap();
    let r2 = data.read().unwrap();
    println!("{:?} {:?}", *r1, *r2);
    drop(r1); drop(r2);

    // Exclusive writer
    let mut w = data.write().unwrap();
    w.push(4);
}

A key point about Mutex: the lock is released when the MutexGuard is dropped. Keep lock guards in tightly scoped blocks to avoid holding locks longer than necessary. Long-held locks are a primary source of deadlocks and performance problems in concurrent Rust.

Rc<RefCell<T>> and Arc<Mutex<T>>: The Full Patterns

use std::rc::Rc;
use std::cell::RefCell;
use std::sync::{Arc, Mutex};

// Single-threaded shared mutable state
type SharedList = Rc>>;

fn append(list: &SharedList, value: i32) {
    list.borrow_mut().push(value);
}

fn main() {
    let list: SharedList = Rc::new(RefCell::new(vec![]));
    let list2 = Rc::clone(&list);

    append(&list, 1);
    append(&list2, 2);
    append(&list, 3);

    println!("{:?}", list.borrow()); // [1, 2, 3]
}

// Multi-threaded shared mutable state
type SharedCounter = Arc>;

fn increment(counter: &SharedCounter) {
    *counter.lock().unwrap() += 1;
}

Choosing the Right Type

// Decision guide in code form:

// Single owner, heap allocation needed, or recursive type?
let x: Box = Box::new(value);

// Shared ownership, single thread, immutable?
let x: Rc = Rc::new(value);

// Shared ownership, single thread, needs mutation?
let x: Rc> = Rc::new(RefCell::new(value));

// Shared ownership, multiple threads, immutable?
let x: Arc = Arc::new(value);

// Shared ownership, multiple threads, needs mutation (exclusive)?
let x: Arc> = Arc::new(Mutex::new(value));

// Shared ownership, multiple threads, many readers / few writers?
let x: Arc> = Arc::new(RwLock::new(value));

// Single owner, Copy type, needs interior mutation without borrow overhead?
let x: Cell = Cell::new(value);

// Single owner, non-Copy, needs interior mutation with runtime check?
let x: RefCell = RefCell::new(value);

What Comes Next

Smart pointers give you the tools to handle ownership patterns that do not fit the simple single-owner model. Part 8 tackles one of the most conceptually challenging parts of Rust: Pin and Unpin. These types exist specifically to support self-referential structs and async state machines – and understanding them makes async Rust much less mysterious.

References

Written by:

641 Posts

View All Posts
Follow Me :
How to whitelist website on AdBlocker?

How to whitelist website on AdBlocker?

  1. 1 Click on the AdBlock Plus icon on the top right corner of your browser
  2. 2 Click on "Enabled on this site" from the AdBlock Plus option
  3. 3 Refresh the page and start browsing the site