Smart Pointers (Box, Deref, Arc/Mutex)
Ideal learning position: read this module right after 14_lifetimes. By then you
understand ownership (05), borrowing (06), slices (07), and lifetimes (14) — which is
exactly the toolkit you need to understand why smart pointers exist. Smart pointers are
the escape hatches for the cases plain ownership and &/&mut references can't express:
heap allocation of recursively-sized types, type-erased values, and shared ownership.
A smart pointer is a struct that owns some data, points to it (usually on the heap),
and adds behavior on top of a raw pointer — most importantly the Deref trait (so *p
and p.method() work) and the Drop trait (so the data is freed automatically when the
pointer goes out of scope). You have already been using smart pointers without naming
them: String and Vec<T> are smart pointers over a heap buffer. This module names the
pattern and adds the ones you'll meet in real code.
This file covers Tier 1, the three you'll reach for constantly:
Box<T>— own one value on the heap.Deref& deref coercion — why&Stringworks where&stris expected.Arc<T>+Mutex/RwLock— shared, mutable state across threads.
Rc, RefCell, Cell, and Weak are Tier 2 and live in
02_interior_mutability.md.
In C#: the runtime hands you all of this invisibly. Every class instance is a
heap object behind a reference, the GC frees it, and any number of variables can share it.
Rust makes each of those decisions explicit and gives each one a type. The payoff is no GC
and no hidden allocations; the cost is that you choose the pointer.
Box<T> — a heap object you uniquely own
Box<T> is the simplest smart pointer: it allocates space for a T on the heap and owns
it. When the Box is dropped, the heap allocation is freed. Ownership is unique —
there is exactly one Box for that value (you can move it, but not share it).
let boxed: Box<i32> = Box::new(5);
println!("{}", *boxed); // 5 — deref to read the value
let n = *boxed + 1; // Box<T> derefs to TIn C#: Box<T> is the closest thing to "a single heap object you uniquely own" — like
new SomeClass() stored in exactly one field, except Rust guarantees the uniqueness and
frees it deterministically (no GC). For a single i32 you'd rarely box in Rust; Box
earns its keep in the three situations below.
1. Recursive types (the canonical use)
A type cannot contain itself by value — the compiler can't compute its size:
// enum List { Cons(i32, List), Nil } // ERROR: recursive type has infinite sizeBox<T> is a fixed-size pointer regardless of how big T is, so boxing the recursive
field breaks the cycle. This is the classic cons list:
enum List {
Cons(i32, Box<List>),
Nil,
}
use List::{Cons, Nil};
fn sum(list: &List) -> i32 {
match list {
Cons(value, rest) => value + sum(rest), // recurse through the Box
Nil => 0,
}
}
let list = Cons(1, Box::new(Cons(2, Box::new(Cons(3, Box::new(Nil))))));
println!("{}", sum(&list)); // 6The same trick powers trees and graphs: a binary tree node holds
Option<Box<Node>> for each child. (Task 1 has you build and traverse one of these.)
In C#: a recursive class Node { int Value; Node Next; } "just works" because every
class field is already a reference (a pointer). In Rust, a struct field is stored inline
by value by default, so you opt into the indirection with Box — Box<List> is the
explicit version of what C# does implicitly for every class field.
2. Box<dyn Trait> — a trait object (type erasure)
Generics (fn f<T: Trait>) are monomorphized: one concrete type per call site. When you
need different concrete types behind one type — e.g. a heterogeneous list — you use a
trait object, dyn Trait, behind a pointer. dyn Trait is unsized (the concrete type
varies), so it must live behind a pointer; Box<dyn Trait> is the owning form.
trait Shape {
fn area(&self) -> f64;
}
struct Circle { r: f64 }
struct Square { s: f64 }
impl Shape for Circle { fn area(&self) -> f64 { std::f64::consts::PI * self.r * self.r } }
impl Shape for Square { fn area(&self) -> f64 { self.s * self.s } }
// One Vec holding two different concrete types — impossible without type erasure.
let shapes: Vec<Box<dyn Shape>> = vec![
Box::new(Circle { r: 1.0 }),
Box::new(Square { s: 2.0 }),
];
let total: f64 = shapes.iter().map(|s| s.area()).sum();Calls through dyn Trait use a vtable (dynamic dispatch), exactly like a C# interface call.
In C#: Box<dyn Shape> ≈ IShape used polymorphically — List<IShape> holding a
Circle and a Square. C# dispatches every interface/virtual call dynamically by default;
Rust defaults to static dispatch with generics and makes you write dyn to opt into the
vtable.
3. Box<dyn Error> — and the Result<_, Box<dyn Error>> you've already seen
This is the one that demystifies code from earlier modules. Throughout 10_error_handling, 19_csv, 25_json, 26_http_client, and 31_redis_caching you saw signatures like:
fn run() -> Result<(), Box<dyn std::error::Error>> {
let text = std::fs::read_to_string("data.csv")?; // io::Error
let n: i32 = text.trim().parse()?; // ParseIntError
Ok(())
}Box<dyn Error> means "an owning, heap-allocated pointer to some value that implements
the Error trait — I don't care which concrete error type." That's what lets the ?
operator mix different error types in one function: each error gets converted into a
Box<dyn Error> on the way out (via a blanket From impl). It is the lazy, zero-ceremony
error type — perfect for main, examples, and small programs — versus defining your own
enum AppError (the approach in 27_error_handling_advanced).
In C#: Box<dyn Error> ≈ catching/returning the base Exception type instead of a
specific subclass. -> Result<T, Box<dyn Error>> is the Rust spelling of "this method can
fail with any exception," and ? is the early-return-on-error that throw/propagation
gives you for free in C#.
Deref & deref coercion — why &String works where &str is wanted
The Deref trait is what makes a smart pointer feel like the value it wraps. Implementing
Deref for a type P with type Target = T makes *p produce a &T, and — crucially —
turns on deref coercion: the compiler will automatically convert a &P into a &T
(and &mut P into &mut T) when a function or method expects the target type.
You've relied on this since module 07 without knowing its name:
fn greet(name: &str) { // wants &str
println!("hello {name}");
}
let owned: String = String::from("world");
greet(&owned); // pass &String — coerced to &str automaticallyString implements Deref<Target = str>, so &String coerces to &str. The same rule
gives you &Vec<T> → &[T] (tie this back to 07_slices: a &Vec<i32> works anywhere
a &[i32] slice is expected) and &Box<T> → &T.
fn first(items: &[i32]) -> Option<&i32> { items.first() } // wants a slice
let v: Vec<i32> = vec![10, 20, 30];
first(&v); // &Vec<i32> coerces to &[i32]Method resolution through Box/Rc/Arc
Deref coercion is also why you can call the inner value's methods directly on a smart
pointer — you never write (*boxed).method():
let s: Box<String> = Box::new(String::from("Rust"));
println!("{}", s.len()); // auto-deref Box -> String: finds String::len (inherent)
println!("{}", s.to_uppercase()); // String has no to_uppercase, so it derefs on to str::to_uppercaseThe compiler keeps applying Deref (Box<String> → String → str) until it finds a
method that matches. The same is true for Rc<T> and Arc<T>: Arc<Mutex<T>> lets you
call Mutex methods directly. This is the whole reason smart pointers are ergonomic — they
are transparent to method calls.
In C#: there's no direct analogue because C# references are already transparent — you
write obj.Method() and never think about indirection. Deref coercion is how Rust gets
that same "just call the method" ergonomics while keeping the pointer type explicit. (It is
not a general implicit conversion like a C# implicit operator; it only fires for &/
&mut at method-call and argument-passing sites, which keeps it predictable.)
Arc<T> + Mutex/RwLock — shared mutable state across threads
Box owns one value uniquely. Often you need many owners — several threads all holding
the same configuration, cache, or counter. Arc<T> (Atomically Reference
Counted) is the shared-ownership pointer for threads: cloning an Arc doesn't copy the
data, it bumps an atomic reference count and hands back another handle to the same value.
The value is freed when the last Arc is dropped.
use std::sync::Arc;
use std::thread;
let config = Arc::new(vec![1, 2, 3]);
let mut handles = vec![];
for _ in 0..3 {
let config = Arc::clone(&config); // cheap: just an atomic increment
handles.push(thread::spawn(move || {
// each thread owns its own Arc handle to the same Vec
config.iter().sum::<i32>()
}));
}
let results: Vec<i32> = handles.into_iter().map(|h| h.join().unwrap()).collect();But Arc<T> only gives shared, read-only access — you can't get a &mut out of it,
because that would let two threads mutate at once (a data race). To mutate shared data you
wrap it in a lock that grants exclusive access at runtime:
Mutex<T>— one lock;lock()blocks until you hold it, returns a guard that derefs to&mut T, and unlocks when the guard drops.RwLock<T>— many readers or one writer;read()for shared access,write()for exclusive. Better when reads vastly outnumber writes.
The combined idiom is Arc<Mutex<T>> (share the lock; lock to mutate):
use std::sync::{Arc, Mutex};
use std::thread;
let counter = Arc::new(Mutex::new(0)); // shared, mutable, thread-safe
let mut handles = vec![];
for _ in 0..10 {
let counter = Arc::clone(&counter);
handles.push(thread::spawn(move || {
let mut n = counter.lock().unwrap(); // guard: &mut i32, auto-unlocks on drop
*n += 1;
}));
}
for h in handles { h.join().unwrap(); }
println!("{}", *counter.lock().unwrap()); // 10 — deterministic regardless of thread orderYou met a real instance of this in 28_axum_state_middleware: the shared AppState
counter was an Arc<AtomicU32>. That's the same shape, with an AtomicU32 instead of
Mutex<u32> — atomics are lock-free integers, the right tool when the shared state is a
single number. Cross-reference 16_concurrency, which uses channels (mpsc) for message
passing; Arc<Mutex> is the complementary tool for when threads genuinely need to share
one piece of state rather than pass ownership of messages.
Why two separate types (Arc and Mutex) instead of one? Separation of concerns: Arc
handles sharing (who owns it / when it's freed), Mutex handles synchronization (who
may touch it right now). You compose exactly what you need — Arc<T> for shared read-only,
Mutex<T> for single-thread-but-mutable, Arc<Mutex<T>> for both.
In C#: Arc<T> ≈ an ordinary GC reference shared between threads — multiple variables
pointing at one heap object — except the "free it" decision is the atomic refcount instead
of the GC. Mutex<T> ≈ lock (obj) { ... } / SemaphoreSlim, and RwLock<T> ≈
ReaderWriterLockSlim. The big difference: in C# nothing forces you to take the lock — you
can touch shared state without lock and get a race. In Rust the data lives inside the
Mutex, so the only way to reach it is to lock first. The type system makes the data race
unrepresentable, which is the core of "fearless concurrency."
Arc vs Rc: Arc uses atomic refcount updates (thread-safe, slightly slower); Rc
(Tier 2) uses plain non-atomic updates (single-thread only, faster). Use Arc the moment
a value crosses a thread boundary — the compiler enforces this: Rc is not Send.
Choosing a pointer (quick reference)
| You need... | Use | C# analogue |
|---|---|---|
| One heap value, unique owner | Box<T> | a heap object held in one field |
| A recursive/self-referential type | Box<T> on the recursive field | a class field (reference) |
| Type-erased value / heterogeneous collection | Box<dyn Trait> | IFoo / List<IFoo> |
| "Any error" return type | Box<dyn Error> | returning/throwing base Exception |
| Shared ownership across threads | Arc<T> | shared GC reference |
| Shared mutable across threads | Arc<Mutex<T>> / Arc<RwLock<T>> | lock/ReaderWriterLockSlim |
| Shared ownership, single thread | Rc<T> (Tier 2) | shared GC reference |
Mutate through a shared/& handle | RefCell<T> / Cell<T> (Tier 2) | (no analogue; it's the GC default) |
| Break a reference cycle | Weak<T> (Tier 2) | WeakReference |
Key takeaway
Smart pointers are structs that own heap data and add Deref/Drop behavior, making the
allocation, sharing, and cleanup decisions that C#'s GC hides into explicit, typed choices.
Reach for Box<T> to put one value on the heap — it's what makes recursive types
(cons lists, trees) compile, what Box<dyn Trait> uses for type-erased/heterogeneous
collections, and what Box<dyn Error> is in every Result<_, Box<dyn Error>> you've seen.
Deref + deref coercion is the quiet machinery behind &String → &str,
&Vec<T> → &[T], and calling inner methods straight through a Box/Rc/Arc. For
state shared across threads, use Arc<T> (atomic shared ownership) plus a
Mutex/RwLock when that state must be mutated — the data lives inside the lock, so a
data race won't even compile. The single-threaded sharing-and-mutation tools (Rc,
RefCell, Cell, Weak) are in 02_interior_mutability.md.