Smart Pointers in Rust: Box, Rc, and Arc
1. Overview
By default, each value in Rust has only one owner, and Rust must know the size of the value at compile time. Many real-world situations don’t satisfy both conditions: recursive data types, collections containing different types, or data that needs multiple owners. This article covers the three most common smart pointers: Box<T>, Rc<T>, and Arc<T>. After reading, you’ll know which one to choose for each situation, how to combine them with Weak, RefCell, and Mutex, and common pitfalls.
2. What is a Smart Pointer?
A smart pointer is a struct that wraps a pointer and implements two important traits:
Deref: allows you to use a smart pointer like an ordinary reference (*ptr, call methods directly).Drop: automatically frees the resource when the smart pointer goes out of scope.
String and Vec<T> are actually smart pointers too. Box, Rc, and Arc differ in how they manage ownership of data on the heap. All three are in the standard library. You don’t need to add any dependencies to Cargo.toml.
3. Box<T>: Single Owner, Data on Heap
3.1. Basic Allocation
fn main() {
let order_total = Box::new(250_000u64);
println!("Total: {}", order_total);
let doubled = *order_total * 2;
println!("Doubled: {}", doubled);
}
Box::new allocates the value 250_000 on the heap. Only the pointer remains on the stack, with fixed size. println! works immediately because Box implements Display when T does. The * operator gets the inner value thanks to Deref. When order_total leaves main, the heap memory is automatically freed.
Total: 250000
Doubled: 500000
Wrapping a u64 in Box like the example above doesn’t provide any benefit. This code merely demonstrates the syntax. Box is truly useful in the two cases below.
3.2. Recursive Data Types
Suppose you model an order processing workflow as a sequence of steps:
#[derive(Debug)]
enum OrderStep {
Done,
Next(String, Box<OrderStep>),
}
use OrderStep::{Done, Next};
fn count(step: &OrderStep) -> usize {
match step {
Done => 0,
Next(_, rest) => 1 + count(rest),
}
}
fn main() {
let flow = Next(
"Place order".into(),
Box::new(Next(
"Payment".into(),
Box::new(Next("Delivery".into(), Box::new(Done))),
)),
);
println!("Step count: {}", count(&flow));
}
If you write Next(String, OrderStep) without Box, the compiler will error:
error[E0072]: recursive type `OrderStep` has infinite size
The reason is that one OrderStep contains another OrderStep, which contains another, and so on, resulting in infinite size. Box<OrderStep> always has the size of a pointer, so the enum’s size becomes finite. In the count function, rest has type &Box<OrderStep>. Rust automatically converts it to &OrderStep thanks to deref coercion.
Step count: 3
3.3. Trait Objects
Box<dyn Trait> lets you store different types in the same collection:
trait PaymentMethod {
fn pay(&self, amount: u64) -> String;
}
struct Cash;
struct Card {
last4: String,
}
impl PaymentMethod for Cash {
fn pay(&self, amount: u64) -> String {
format!("Cash: {amount}")
}
}
impl PaymentMethod for Card {
fn pay(&self, amount: u64) -> String {
format!("Card ****{}: {amount}", self.last4)
}
}
fn main() {
let methods: Vec<Box<dyn PaymentMethod>> = vec![
Box::new(Cash),
Box::new(Card { last4: "4242".into() }),
];
for m in &methods {
println!("{}", m.pay(100_000));
}
}
Cash and Card have different sizes, so they can’t go directly into the same Vec. When wrapped in Box<dyn PaymentMethod>, every element has the same size (a fat pointer: a data pointer plus a vtable pointer). The pay call is resolved at runtime.
Cash: 100000
Card ****4242: 100000
Note: If all elements have the same type, use generics (
impl PaymentMethodor<T: PaymentMethod>) instead ofBox<dyn …>. Generics use static dispatch, avoiding heap allocation and vtable calls.
4. Rc<T>: Multiple Owners in One Thread
4.1. Reference Counting
Suppose multiple Orders belong to one Customer. You don’t want to copy the customer info into each order, and you can’t determine which order will outlive the others to be the owner. This is when you use Rc (Reference Counted):
use std::rc::Rc;
struct Customer {
name: String,
}
struct Order {
id: u32,
customer: Rc<Customer>,
}
fn main() {
let alice = Rc::new(Customer { name: "Alice".into() });
println!("After creating: {}", Rc::strong_count(&alice));
let o1 = Order { id: 1, customer: Rc::clone(&alice) };
{
let o2 = Order { id: 2, customer: Rc::clone(&alice) };
println!("Inside block: {}", Rc::strong_count(&alice));
println!("Order {} by {}", o2.id, o2.customer.name);
}
println!("After block: {}", Rc::strong_count(&alice));
println!("Order {} by {}", o1.id, o1.customer.name);
}
Rc::clone doesn’t copy Customer. It only increments the strong count by 1. When o2 is dropped at the end of the block, the count decreases by 1. Customer is only freed when the count reaches 0.
After creating: 1
Inside block: 3
Order 2 by Alice
After block: 2
Order 1 by Alice
By convention, write Rc::clone(&alice) instead of alice.clone(). This makes it clear to readers that this is a cheap counter increment, not a deep copy.
4.2. Mutating Data with Rc<RefCell<T>>
Rc<T> only gives you immutable references (&T). If multiple owners need to mutate data, combine it with RefCell:
use std::cell::RefCell;
use std::rc::Rc;
struct Cart {
items: Vec<String>,
}
fn main() {
let cart = Rc::new(RefCell::new(Cart { items: vec![] }));
let web = Rc::clone(&cart);
let mobile = Rc::clone(&cart);
web.borrow_mut().items.push("Shirt".into());
mobile.borrow_mut().items.push("Leaf hat".into());
println!("{:?}", cart.borrow().items);
}
RefCell moves borrow rule checking from compile time to runtime. borrow_mut() returns a guard. This guard expires at the end of the statement, so the next call is valid.
["Shirt", "Leaf hat"]
Note: If you call
borrow_mut()while anotherborrow()orborrow_mut()is still active, the program panics withalready borrowed: BorrowMutError. Keep guards alive for as short a time as possible. Usetry_borrow_mut()if you want to handle errors instead of panicking.
4.3. Reference Cycles and Weak<T>
If Customer holds a list of Rc<Order>s, and each Order holds an Rc<Customer>, the two counts will never reach 0. This results in a memory leak. Rust can’t prevent this error at compile time. The solution is to use Weak for one direction of the reference:
use std::cell::RefCell;
use std::rc::{Rc, Weak};
struct Customer {
name: String,
orders: RefCell<Vec<Rc<Order>>>,
}
struct Order {
id: u32,
customer: Weak<Customer>,
}
fn main() {
let bob = Rc::new(Customer {
name: "Bob".into(),
orders: RefCell::new(vec![]),
});
let order = Rc::new(Order { id: 7, customer: Rc::downgrade(&bob) });
bob.orders.borrow_mut().push(Rc::clone(&order));
if let Some(c) = order.customer.upgrade() {
println!("Order {} by {}", order.id, c.name);
}
println!("strong={}, weak={}", Rc::strong_count(&bob), Rc::weak_count(&bob));
drop(bob);
println!("Still have customer? {}", order.customer.upgrade().is_some());
}
Rc::downgrade creates a Weak. Weak only increments the weak count and doesn’t keep data alive. To use it, call upgrade() to get an Option<Rc<T>>. The result is None if the data has been freed. After drop(bob), the Customer’s strong count reaches 0 so Customer is freed, even though the Weak in order remains.
Order 7 by Bob
strong=1, weak=1
Still have customer? false
General rule: use Rc for parent→child references, and Weak for child→parent.
5. Arc<T>: Multiple Owners Across Threads
5.1. Why Rc Doesn’t Work with Threads
Rc updates its count with plain addition, which isn’t safe when multiple threads access it. That’s why Rc doesn’t implement the Send trait. If you pass Rc to thread::spawn, the compiler will reject it:
error[E0277]: `Rc<Customer>` cannot be sent between threads safely
This is a strength of Rust: data race errors are caught at compile time. Arc (Atomically Reference Counted) has the same API as Rc, but uses atomic operations to update the count.
5.2. Sharing Read-Only Data
use std::sync::Arc;
use std::thread;
struct ShopConfig {
currency: String,
vat_percent: u64,
}
fn main() {
let config = Arc::new(ShopConfig { currency: "VND".into(), vat_percent: 10 });
let handles: Vec<_> = [100_000u64, 200_000]
.into_iter()
.map(|price| {
let config = Arc::clone(&config);
thread::spawn(move || {
let vat = price * config.vat_percent / 100;
println!("{} + VAT = {} {}", price, price + vat, config.currency);
})
})
.collect();
for h in handles {
h.join().unwrap();
}
}
Each thread receives its own Arc via move. All copies point to the same ShopConfig. Data is read-only so no locking is needed. The order of the two output lines may vary between runs.
100000 + VAT = 110000 VND
200000 + VAT = 220000 VND
5.3. Mutating Data with Arc<Mutex<T>>
RefCell isn’t thread-safe. The equivalent for multi-threaded code is Mutex (or RwLock):
use std::sync::{Arc, Mutex};
use std::thread;
fn main() {
let revenue = Arc::new(Mutex::new(0u64));
let mut handles = Vec::new();
for amount in [100u64, 250, 400] {
let revenue = Arc::clone(&revenue);
handles.push(thread::spawn(move || {
*revenue.lock().unwrap() += amount;
}));
}
for h in handles {
h.join().unwrap();
}
println!("Revenue: {}", *revenue.lock().unwrap());
}
lock() returns a MutexGuard. The lock is automatically released when the guard is dropped, at the end of the statement here. unwrap() handles the case where the mutex is poisoned, meaning another thread panicked while holding the lock.
Revenue: 750
Note: For a simple integer counter,
Arc<AtomicU64>is faster thanArc<Mutex<u64>>. Also, don’t useArcwhere you only have one thread. Atomic operations are more expensive than plain addition, though the difference isn’t huge.
6. Testing Reference Counting Behavior
Rust has a built-in test framework, no extra crate needed. Place the following tests at the end of src/lib.rs or src/main.rs:
#[cfg(test)]
mod tests {
use std::rc::{Rc, Weak};
use std::sync::{Arc, Mutex};
use std::thread;
struct Customer {
name: String,
}
#[test]
fn rc_clone_increases_strong_count_and_drop_decreases_it() {
let alice = Rc::new(Customer { name: "Alice".into() });
let copy = Rc::clone(&alice);
assert_eq!(Rc::strong_count(&alice), 2);
assert!(Rc::ptr_eq(&alice, ©));
drop(copy);
assert_eq!(Rc::strong_count(&alice), 1);
assert_eq!(alice.name, "Alice");
}
#[test]
fn weak_upgrade_returns_none_after_owner_dropped() {
let bob = Rc::new(Customer { name: "Bob".into() });
let weak: Weak<Customer> = Rc::downgrade(&bob);
assert!(weak.upgrade().is_some());
drop(bob);
assert!(weak.upgrade().is_none());
}
#[test]
fn arc_mutex_sums_correctly_across_threads() {
let revenue = Arc::new(Mutex::new(0u64));
let handles: Vec<_> = (1..=100)
.map(|i| {
let revenue = Arc::clone(&revenue);
thread::spawn(move || *revenue.lock().unwrap() += i)
})
.collect();
handles.into_iter().for_each(|h| h.join().unwrap());
assert_eq!(*revenue.lock().unwrap(), 5050);
assert_eq!(Arc::strong_count(&revenue), 1);
}
}
The first test checks the count and uses Rc::ptr_eq to confirm that both Rcs point to the same memory, meaning no deep copy occurred. The second test confirms that Weak doesn’t keep data alive. The third test spawns 100 threads that sum up; the result must be exactly 5050. After all threads join, only one strong reference remains.
Run the tests:
cargo test
running 3 tests
test tests::rc_clone_increases_strong_count_and_drop_decreases_it ... ok
test tests::weak_upgrade_returns_none_after_owner_dropped ... ok
test tests::arc_mutex_sums_correctly_across_threads ... ok
test result: ok. 3 passed; 0 failed
7. Which One to Choose?
| Need | Choice |
|---|---|
| Single owner, need heap (recursive types, trait objects, large data for cheap moves) | Box<T> |
| Multiple owners, one thread, read-only | Rc<T> |
| Multiple owners, one thread, need to mutate | Rc<RefCell<T>> |
| Multiple owners, multiple threads, read-only | Arc<T> |
| Multiple owners, multiple threads, need to mutate | Arc<Mutex<T>> / Arc<RwLock<T>> |
| Back references, caching, avoid cycles | Weak<T> |
Some common mistakes:
- Using a smart pointer when you only need to borrow. If a function only reads data, accept
&Customerinstead ofRc<Customer>. This makes the function more flexible and avoids incrementing or decrementing the count. - Overusing
Rc<RefCell<T>>. Code thick with this pattern usually signals you’re bringing OOP thinking from Java or C#. Consider reorganizing ownership, or use indices into aVec(arena pattern). - Reference cycles. Both
RcandArccan leak memory when two objects point to each other with strong references. UseWeakfor one direction. - Holding
MutexGuardtoo long. Especially dangerous when holding a guard across.awaitin async code, or when acquiring multiple mutexes in different orders on different threads, leading to deadlock.
8. Conclusion
Box<T> is used when you need to put data on the heap with a single owner, typically for recursive types and trait objects. Rc<T> allows multiple owners in one thread, while Arc<T> does the same across threads with atomic counting. These smart pointers themselves only allow reads. To allow mutation, combine them with RefCell (one thread) or Mutex/RwLock (multiple threads), and use Weak to avoid cycles. Start with ordinary borrows, and only switch to smart pointers when the default ownership rules truly don’t work.