Box vs Rc vs Arc: Rust Smart Pointers and When to Use Which

4 min read

The basics course closed by deferring smart pointers to The Book; this article collects that debt. The ownership rules — one owner, freed at end of scope — suffice for most code, but occasionally a structure appears that the rules alone cannot express: multiple owners are needed, the size cannot be known at compile time, or mutation is needed through a shared handle. The standard library’s smart pointers are the escape hatches, and four of them cover essentially everything.

Box: put the value on the heap #

Box is the simplest smart pointer: a pointer that places a value on the heap and owns it. The owner is still exactly one, so the ownership rules are unchanged — only the value’s location moves. Three situations call for it.

src/main.rs
// 1. Recursive types: to make the size finite
enum Tree {
    Leaf(i32),
    Node(Box<Tree>, Box<Tree>), // without Box: infinite size → compile error E0072
}

// 2. dyn traits: differently sized types as one type
let handlers: Vec<Box<dyn Fn(&str)>> = vec![/* ... */];

// 3. Moving large values: move a pointer instead of copying
let big = Box::new([0u8; 1_000_000]);

Recursive types solve “a struct containing itself has infinite size” by fixing it at pointer size, and Box<dyn Trait> is the dynamic-dispatch shape from basics Part 8. What all three share: still one owner. When sharing is needed, move on.

Rc: multiple owners (single thread) #

src/main.rs
use std::rc::Rc;

let shared = Rc::new(String::from("shared config"));
let a = Rc::clone(&shared); // not a data copy — count +1
let b = Rc::clone(&shared);
println!("owners: {}", Rc::strong_count(&shared)); // 3

Rc (reference counting) counts “how many owners reference this value” and frees it the moment the count reaches zero. It is the official exception to the one-owner rule, for sharing where the last user cannot be determined at compile time — like a node in a graph pointed at by several parents. Rc::clone copies no data, only bumps the count, so it is cheap. Two limits to remember. First, what you get is shared immutable access (mutation comes below, combined with RefCell). Second, cycles — values pointing at each other — keep the count from reaching zero, so memory never frees. Where a design creates cycles (child-to-parent backlinks), use Weak, which does not count.

Arc: Rc that crosses threads #

Rc’s count updates are not atomic, so the compiler refuses to let it cross threads at all. For multi-thread sharing, use Arc (atomic Rc), which handles the count with atomic operations.

src/main.rs
use std::sync::Arc;
use std::thread;

let config = Arc::new(String::from("shared config"));
let for_worker = Arc::clone(&config);
thread::spawn(move || println!("{for_worker}"));

Atomic operations cost something, so the criterion is exactly the split: Rc within one thread, Arc across threads. Choose wrong and the compiler blocks the thread crossing anyway — this is a distinction to understand rather than memorize.

RefCell: defer the borrow check to runtime #

Rc gave shared immutable access. When sharing plus mutation is needed, RefCell appears — it moves the borrow-rule check from compile time to runtime.

src/main.rs
use std::cell::RefCell;

let counter = RefCell::new(0);
*counter.borrow_mut() += 1;   // mutable borrow checked at runtime
let now = counter.borrow();   // immutable borrow checked at runtime

The rule itself (one mutable or many immutable) is unchanged; violating it becomes a runtime panic instead of a compile error. It is a door the developer opens on their own responsibility for patterns the compiler cannot prove — so the healthy order is not “RefCell everything first” but last resort when design cannot solve it.

Working idioms: two combinations #

Combining the four yields the two shapes seen constantly in practice.

  • Rc<RefCell<T>> — single-threaded “owned by many, mutated too.” The standard form for GUI widget trees, an interpreter’s environment object, and similar structures.
  • Arc<Mutex<T>> — the multi-threaded edition, with the lock (Mutex) taking RefCell’s seat. But as practice Part 5 showed, using it for problems that can divide and merge — like parallel aggregation — gets slow through lock contention. If structure can eliminate the sharing, that comes first; Arc<Mutex<T>> is for when shared state is genuinely essential.

The selection sequence #

  1. Is one owner enough? → a plain value. Box only when heap placement, recursion, or dyn is needed.
  2. Are multiple owners needed? → Rc single-threaded, Arc multi-threaded.
  3. Sharing plus mutation? → Rc<RefCell<T>> / Arc<Mutex<T>> — but first check whether restructuring can remove the sharing.

Summary #

  • Box is sole ownership with only the location moved to the heap. Recursive types, dyn traits, and moving large values are its three uses.
  • Rc makes multiple owners via reference counting, single-threaded. The sharing is immutable, and cycles are broken with Weak.
  • Arc is the multi-threaded edition with atomic counting. Choose wrong and the compiler guards the boundary anyway.
  • RefCell defers borrow checking to runtime panics — a last resort when design cannot solve it.
  • The combined idioms are Rc<RefCell<T>> (single thread) and Arc<Mutex<T>> (multi-thread) — but a structure that eliminates sharing (fold and reduce) comes first when possible.
X