Smart Pointers Demystified

Smart pointers are Rust's answer to situations where plain ownership rules are not enough. Each one solves a specific problem: heap allocation with single ownership, shared ownership between multiple readers, or shared ownership across threads. Knowing which to reach for makes the difference between fighting the compiler and working with it.

This tutorial covers Box<T>, Rc<T>, Arc<T>, and their interior-mutability companions Cell<T>, RefCell<T>, and Mutex<T> through the scenarios where each one earns its place.


Box<T>: Heap allocation with single ownership

A Box<T> allocates its value on the heap and owns it. When the Box is dropped, the value is freed. Ownership rules are unchanged. There is exactly one owner.

When to use it

Recursive types. A type that contains itself has infinite size on the stack, which the compiler rejects. Box breaks the cycle by making the inner value a pointer.

// ERROR: recursive type has infinite size
enum List {
    Cons(i32, List),
    Nil,
}

// OK: the inner List is behind a pointer (known size)
enum List {
    Cons(i32, Box<List>),
    Nil,
}

Large values you want to move cheaply. Moving a large struct copies every byte. Boxing it means you move a pointer (8 bytes) instead.

struct LargeMatrix([[f64; 1000]; 1000]);

fn process(m: Box<LargeMatrix>) { /* ... */ }

let m = Box::new(LargeMatrix([[0.0; 1000]; 1000]));
process(m);  // moves a pointer, not a megabyte of stack data

Trait objects. When you need to store a value whose concrete type is unknown at compile time, Box<dyn Trait> is the standard pattern.

trait Render {
    fn draw(&self);
}

struct Button;
struct Label;

impl Render for Button { fn draw(&self) { println!("button"); } }
impl Render for Label  { fn draw(&self) { println!("label");  } }

let widgets: Vec<Box<dyn Render>> = vec![
    Box::new(Button),
    Box::new(Label),
];

for w in &widgets {
    w.draw();
}

The Vec holds pointers to heap-allocated values of different concrete types. This works because a Box is always pointer-sized.


Rc<T>: Shared ownership, single thread

Rc<T> (Reference Counted) allows multiple owners of the same heap value. It tracks the number of owners using a counter. When the last owner is dropped, the value is freed.

Use it when you need multiple parts of your program to read the same value, and you cannot (or do not want to) restructure ownership to give one part clear ownership.

use std::rc::Rc;

let config = Rc::new(AppConfig { debug: true, port: 8080 });

let server = Server::new(Rc::clone(&config));
let logger = Logger::new(Rc::clone(&config));

// Both server and logger hold a reference to the same config
// The config is freed when both are dropped

Rc::clone increments the reference count. It does not deep-copy the value. The name clone is misleading here, think of it as "create another owner."

Rc is not thread-safe. Its counter is not atomic. If you send an Rc to another thread, the compiler rejects it.


Arc<T>: Shared ownership across threads

Arc<T> (Atomically Reference Counted) is Rc<T> with an atomic counter. It is safe to send across thread boundaries, at the cost of slightly more overhead per clone and drop.

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

let data = Arc::new(vec![1, 2, 3, 4, 5]);

let handles: Vec<_> = (0..4).map(|_| {
    let data = Arc::clone(&data);
    thread::spawn(move || {
        println!("{:?}", data);
    })
}).collect();

for h in handles {
    h.join().unwrap();
}

Each thread gets its own Arc pointing to the same allocation. The data is freed once all threads have finished and all Arc instances are dropped.

Rc vs Arc: the only question is threads. If your code is single-threaded, use Rc (cheaper). If the value crosses a thread boundary, use Arc. The compiler enforces this: Rc does not implement Send.


Interior Mutability: Mutating through shared references

Both Rc and Arc give you shared immutable access. When you need to mutate through a shared reference, you need an interior mutability wrapper. The right one depends on the context.

RefCell<T>: Runtime borrow checking (single thread)

RefCell<T> moves borrow checking from compile time to runtime. You call .borrow() for a shared reference or .borrow_mut() for an exclusive one. Violating the rules panics at runtime instead of failing at compile time.

use std::cell::RefCell;
use std::rc::Rc;

let shared = Rc::new(RefCell::new(vec![1, 2, 3]));

let a = Rc::clone(&shared);
let b = Rc::clone(&shared);

a.borrow_mut().push(4);  // mutate through one owner
println!("{:?}", b.borrow());  // read through the other: [1, 2, 3, 4]

Rc<RefCell<T>> is the standard pattern for shared mutable state in single-threaded code.

Use RefCell when:

  • You need mutation but multiple parts of the code hold Rc references
  • You are certain borrows will not overlap at runtime (they are checked dynamically)

Avoid RefCell when:

  • The borrow patterns are complex enough that you cannot reason about runtime overlap. A panic in production is worse than a compile error.

Mutex<T>: Exclusive access across threads

For multithreaded mutation, Mutex<T> is the standard tool. Only one thread can hold the lock at a time. Others block until the lock is released.

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

let counter = Arc::new(Mutex::new(0));

let handles: Vec<_> = (0..8).map(|_| {
    let counter = Arc::clone(&counter);
    thread::spawn(move || {
        let mut val = counter.lock().unwrap();
        *val += 1;
    })
}).collect();

for h in handles { h.join().unwrap(); }

println!("final count: {}", *counter.lock().unwrap());  // 8

Arc<Mutex<T>> is the multithreaded equivalent of Rc<RefCell<T>>.

.lock() returns a MutexGuard<T> that releases the lock when it is dropped. Keep guards short-lived. Holding a lock across an await point or a long computation blocks other threads.

Cell<T>: Cheap single-value mutation (single thread)

Cell<T> works like RefCell<T> but only for Copy types. It avoids the borrow-tracking overhead by always copying the value in and out.

use std::cell::Cell;

struct HitCounter {
    count: Cell<u32>,
}

impl HitCounter {
    fn record(&self) {
        self.count.set(self.count.get() + 1);
    }

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

Cell lets you mutate a field through &self (shared reference) without needing &mut self. This is useful when a struct is logically immutable to its callers but needs to track internal state like counts or caches.


Choosing the Right Pointer

SituationUse
Single owner, heap-allocatedBox<T>
Recursive typeBox<T>
Trait object (dyn Trait)Box<dyn Trait>
Shared ownership, single threadRc<T>
Shared ownership, multiple threadsArc<T>
Shared + mutable, single threadRc<RefCell<T>>
Shared + mutable, multiple threadsArc<Mutex<T>>
Cheap interior mutation of Copy typesCell<T>

Key Takeaways

  • Box<T> is single ownership on the heap. Use it for recursive types, large values, and trait objects.
  • Rc<T> allows multiple owners in single-threaded code. Rc::clone creates a new owner, not a copy of the data.
  • Arc<T> is Rc<T> with atomic reference counting. Use it when ownership crosses thread boundaries.
  • Interior mutability (RefCell, Mutex, Cell) allows mutation through shared references by deferring the exclusivity check to runtime or a lock.
  • Rc<RefCell<T>> and Arc<Mutex<T>> are the two patterns for shared mutable state. Pick based on whether threads are involved.
  • Keep Mutex locks short-lived: acquire, mutate, release. Never hold a lock across an await or a long computation.