Rust Smart Pointers: Box, Rc, and Arc
Understand when to reach for Box, Rc, and Arc in Rust, how each interacts with the borrow checker, and the cost of shared ownership across threads.
What you'll learn
- ✓What Box, Rc, and Arc each provide
- ✓When heap allocation is necessary
- ✓Reference counting versus single ownership
- ✓How Arc and atomics enable thread-safe sharing
- ✓Interior mutability with RefCell and Mutex
Prerequisites
- •Comfort with Rust ownership and borrowing
What and Why
Rust’s default is single ownership on the stack. Smart pointers extend that with heap allocation and shared ownership while still respecting the borrow checker. Box<T> is the simplest: it owns a heap value. Rc<T> adds non-atomic reference counting for single-threaded sharing. Arc<T> is the thread-safe sibling using atomic counts.
Picking correctly matters: Box is free at runtime beyond the allocation, Rc is cheap but single-threaded, and Arc is the only one safe to share across threads but incurs atomic operations.
Mental Model
Box<T> : unique owner of heap value, no extra runtime cost
Rc<T> : multiple owners, single thread, non-atomic count
Arc<T> : multiple owners, multi thread, atomic count
For mutation through shared pointers:
Rc<RefCell<T>> : single-threaded interior mutability
Arc<Mutex<T>> : multi-threaded interior mutability
Arc<RwLock<T>> : multi-threaded, many readers or one writer The borrow checker still applies. Wrapping in Rc or Arc does not give you mutable aliasing for free; you need an interior mutability primitive (RefCell, Mutex, RwLock).
Hands-on Example
use std::rc::Rc;
use std::sync::Arc;
use std::sync::Mutex;
use std::thread;
// Recursive type: Box gives it a known size
enum List {
Cons(i32, Box<List>),
Nil,
}
fn main() {
let list = List::Cons(1, Box::new(List::Cons(2, Box::new(List::Nil))));
let _ = list;
// Rc: many owners, single thread
let shared = Rc::new(vec![1, 2, 3]);
let a = Rc::clone(&shared);
let b = Rc::clone(&shared);
println!("count = {}", Rc::strong_count(&shared)); // 3
drop(a); drop(b);
// Arc + Mutex: shared mutable state across threads
let counter = Arc::new(Mutex::new(0));
let mut handles = vec![];
for _ in 0..4 {
let c = Arc::clone(&counter);
handles.push(thread::spawn(move || {
let mut n = c.lock().unwrap();
*n += 1;
}));
}
for h in handles { h.join().unwrap(); }
println!("counter = {}", *counter.lock().unwrap()); // 4
}
Three things to notice. Box is needed for the recursive enum because the compiler must know its size. Rc::clone increments a counter rather than copying the contents. Arc<Mutex<T>> is the canonical pattern for shared mutable state across threads.
Common Pitfalls
- Reaching for
Rc/Arctoo soon: many problems do not actually need shared ownership. A reference (&T) or a clone is often clearer. - Reference cycles:
RcandArcare reference counted. Cycles leak memory because the count never reaches zero. Break cycles withWeak<T>. Rcacross threads: the compiler will refuse, sinceRcis notSend. Reach forArconly when threads are actually involved; the atomics are not free.- Locking while holding another lock:
Mutexdeadlocks are easy to write. Define a consistent lock order or usetry_lockwith a timeout. RefCellpanics at runtime: borrow rules are still checked, just at runtime. A panic fromborrow_mutwhile another borrow is alive is a logic bug; restructure to avoid it.
Practical Tips
Start with no smart pointer at all. If you need heap allocation, reach for Box. If you need shared ownership in one thread, Rc. If across threads, Arc. If you need shared mutability, add RefCell or Mutex accordingly.
For trait objects, Box<dyn Trait> is the standard way to store heterogeneous values. Arc<dyn Trait + Send + Sync> is the multithreaded version.
When designing public APIs, prefer to take &T or &mut T so callers can choose their ownership story. Forcing Arc<T> on callers is sometimes necessary but couples your API to a specific threading model.
For caches and graphs that need cycles, the pattern is Rc<RefCell<T>> with Weak<RefCell<T>> for back-edges. Upgrading a Weak returns Option<Rc<T>>, which is exactly the “the parent might be gone” semantic you usually want.
Profile before optimizing away atomics. Arc overhead is rarely the bottleneck; lock contention is.
Wrap-up
Box, Rc, and Arc are not interchangeable. Each encodes a specific ownership story that the borrow checker can verify at compile time. Use Box for unique heap allocation, Rc for single-threaded sharing, and Arc for cross-thread sharing. Combine with RefCell or Mutex when you need shared mutability. Once you can sketch the ownership graph of your data, picking the right pointer becomes mechanical.
Related articles
- Rust Rust Ownership and Borrowing Explained
A practical introduction to Rust's ownership and borrowing rules: moves, references, mutability, and the mental model that makes the borrow checker stop fighting you.
- Rust Rust Deref and AsRef Explained
Demystify Rust's Deref, DerefMut, and AsRef traits with clear examples showing how smart pointers, coercions, and flexible APIs really work.
- Rust Rust Ownership: The Idea That Makes Rust Different
A friendly tour of Rust's ownership system — the three rules, move semantics, Copy vs Clone, borrowing with & and &mut, the borrow checker, and the beginner errors you'll see first.
- C++ C++ Smart Pointers: unique, shared, weak
A practical guide to unique_ptr, shared_ptr, and weak_ptr in modern C++: ownership semantics, when to use each, and the pitfalls that lead to leaks and cycles.