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 freedRc 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
- The Rust Programming Language – “Smart Pointers” (https://doc.rust-lang.org/book/ch15-00-smart-pointers.html)
- Rust Standard Library – “Rc: Reference Counted” (https://doc.rust-lang.org/std/rc/struct.Rc.html)
- Rust Standard Library – “Arc: Atomically Reference Counted” (https://doc.rust-lang.org/std/sync/struct.Arc.html)
- Rust Standard Library – “Cell Types and Interior Mutability” (https://doc.rust-lang.org/std/cell/index.html)
- Rust Standard Library – “Mutex” (https://doc.rust-lang.org/std/sync/struct.Mutex.html)
