13. Smart Pointers
📋 Jump to Takeaways🎁 A value that cleans itself up, shares ownership without copying, and bypasses Rust's borrow checker at compile time — not with unsafe code, but with a standard library type that defers the check to runtime. That's what smart pointers do.
Box: Heap Allocation
Box<T> puts a value on the heap and gives you a pointer to it. Most values in Rust live on the stack. The stack is fast but limited: values must have a known size at compile time, and each stack frame is fixed.
fn main() {
let x = 5; // stack
let y = Box::new(5); // heap
println!("{}", x); // 5
println!("{}", y); // 5 — Box<T> auto-derefs in println!
println!("{}", *y); // 5 — explicit dereference
}A practical use case for Box<T> is recursive types. A linked list node holds a value and a pointer to the next node, but the compiler can't size a type that contains itself directly.
Node and End are enum variants defined inside List — Node is a tuple variant that holds an i32 and a boxed pointer to the next element:
enum List {
Node(i32, Box<List>), // Box breaks the infinite-size cycle
End,
}
fn main() {
let list = List::Node(1, Box::new(List::Node(2, Box::new(List::End))));
// Represents: 1 -> 2 -> End
println!("List created"); // List created
}Without Box, Rust would reject this because List would have infinite size. With Box, the enum holds a fixed-size pointer (8 bytes on 64-bit) and the actual node lives on the heap.
Box<T> also works as a trait object. When you have multiple types that implement the same trait and need to store them in a collection, use Box<dyn Trait>.
trait Animal {
fn speak(&self);
}
struct Dog;
struct Cat;
impl Animal for Dog {
fn speak(&self) { println!("Woof!"); }
}
impl Animal for Cat {
fn speak(&self) { println!("Meow!"); }
}
fn main() {
let animals: Vec<Box<dyn Animal>> = vec![Box::new(Dog), Box::new(Cat)];
for animal in &animals {
animal.speak();
}
// Woof!
// Meow!
}Each Box<dyn Animal> stores two pointers: one to the data (the Dog or Cat on the heap) and one to a vtable — a small compiler-generated table of function pointers for that concrete type's trait methods. At runtime, calling animal.speak() looks up speak in the vtable and jumps to the right function. This is called dynamic dispatch.
This is the trade-off vs. generics:
fn make_sound<T: Animal>(a: &T) { a.speak(); } // ✅ static dispatch — resolved at compile time, can inline
fn make_sound(a: &dyn Animal) { a.speak(); } // ✅ dynamic dispatch — resolved at runtime via vtableStatic dispatch is faster — the compiler knows the exact function and can inline it. Dynamic dispatch costs one extra pointer lookup per call, but lets you mix types in a single collection (Vec<Box<dyn Animal>>), which generics can't do.
Rc: Shared Ownership
You might wonder: why not just use &data for shared access?
let data = vec![1, 2, 3];
let a = &data;
let b = &data; // fine — data is the clear single ownerThat works when there's one owner that outlives all borrowers. But in a tree or graph, nodes can outlive each other in orders the compiler can't predict:
struct Node {
value: i32,
parent: Option<&Node>, // ❌ needs a lifetime — who owns the parent? for how long?
}You'd need lifetime annotations, and the compiler would require the parent to outlive every child. For arbitrary graphs that's often impossible to express.
Rc<T> shifts the question from compile-time lifetimes to a runtime refcount. The data lives as long as anyone holds an Rc to it, regardless of which scope created it. Rc stands for Reference Counted — it tracks how many handles exist and frees the data when the last one drops.
use std::rc::Rc;
fn main() {
let data = Rc::new(vec![1, 2, 3]);
let a = Rc::clone(&data);
let b = Rc::clone(&data);
println!("{:?} {:?} {:?}", data, a, b); // [1, 2, 3] [1, 2, 3] [1, 2, 3]
println!("owners: {}", Rc::strong_count(&data)); // owners: 3
}Rc::clone doesn't copy the data. It increments the reference count and gives you another pointer to the same allocation. When the last Rc drops, the data is freed.
This is different from calling .clone() on a Box<T>, which allocates new memory and deep-copies the inner value. Box<T> has a single owner by design — two boxes can't point to the same memory because both would try to free it when dropped, causing a double-free. So cloning a Box must produce a separate allocation. Rc avoids this by tracking owners at runtime and only freeing when the count hits zero.
A tree where multiple nodes reference the same parent is a typical example. Each child can hold an Rc to its parent without either node claiming exclusive ownership.
use std::rc::Rc;
struct Node {
value: i32,
parent: Option<Rc<Node>>,
}
fn main() {
let root = Rc::new(Node { value: 1, parent: None });
let child = Node { value: 2, parent: Some(Rc::clone(&root)) };
println!("parent value: {}", child.parent.as_ref().unwrap().value); // parent value: 1
println!("root owners: {}", Rc::strong_count(&root)); // root owners: 2
}Rc<T> can't cross thread boundaries. The reference count is a plain integer — two threads incrementing it simultaneously would corrupt it. For multi-threaded shared ownership, use Arc<T> instead (covered in the Shared State lesson).
RefCell: Interior Mutability
The borrow checker enforces one rule: if you have &self (shared reference), you can't mutate. But sometimes you genuinely need to mutate something inside a struct through &self — a cache that fills itself on first read, a counter that tracks how many times a method was called, a logger that appends to a buffer.
The compiler can't prove these are safe at compile time, so it rejects them. RefCell<T> is the escape hatch: it moves the borrow check to runtime. You get to mutate through &self, and if you accidentally violate the rules, the program panics instead of failing to compile.
use std::cell::RefCell;
struct Cache {
data: RefCell<Vec<i32>>,
}
impl Cache {
fn add(&self, val: i32) { // &self, not &mut self
self.data.borrow_mut().push(val);
}
fn get(&self) -> Vec<i32> {
self.data.borrow().clone()
}
}
fn main() {
let cache = Cache { data: RefCell::new(vec![]) };
cache.add(1);
cache.add(2);
println!("{:?}", cache.get()); // [1, 2]
}.borrow() gives a shared reference, .borrow_mut() gives a mutable one — the same rules as always, just checked at runtime. Holding both at the same time panics:
let data = RefCell::new(vec![1, 2]);
let _a = data.borrow();
let _b = data.borrow_mut(); // panics: already borrowed as immutableUse RefCell sparingly. If you find yourself reaching for it often, it usually means the struct's design can be improved to make the mutability explicit.
Cell: Copy-Based Interior Mutability
Cell<T> solves the same problem as RefCell<T> — mutating through &self — but for simple Copy types like integers and booleans. Instead of issuing references that need runtime borrow tracking, it just copies the value in and out with .get() and .set(). No borrow check, no panic possible.
use std::cell::Cell;
fn main() {
let counter = Cell::new(0);
counter.set(counter.get() + 1);
counter.set(counter.get() + 1);
println!("count: {}", counter.get()); // count: 2
}A practical use is a struct that tracks internal state through &self methods — something the borrow checker would normally forbid:
use std::cell::Cell;
struct Button {
label: String,
click_count: Cell<u32>,
}
impl Button {
fn click(&self) { // &self, not &mut self
self.click_count.set(self.click_count.get() + 1);
}
fn clicks(&self) -> u32 {
self.click_count.get()
}
}
fn main() {
let btn = Button { label: "Submit".to_string(), click_count: Cell::new(0) };
btn.click();
btn.click();
println!("{} clicked {} times", btn.label, btn.clicks()); // Submit clicked 2 times
}Cell<T> only works with Copy types (integers, booleans, char). For String, Vec, or anything heap-allocated, use RefCell<T> instead — it issues references rather than copying.
Cell<T> is not thread-safe — it doesn't implement Sync, so the compiler rejects it at thread boundaries. Think of it as the single-threaded equivalent of an atomic variable. When you cross a thread boundary, swap it for the real thing:
| Need | Single-threaded | Multi-threaded |
|---|---|---|
Mutate an integer through &self |
Cell<i32> |
AtomicI32 |
Mutate a bool through &self |
Cell<bool> |
AtomicBool |
Mutate a Vec or String through &self |
RefCell<T> |
Mutex<T> |
Both use atomic CPU instructions under the hood — Cell opts for no overhead at all (single-threaded only), atomics use CPU-level synchronization so multiple threads can safely read and write without a lock.
Rc<RefCell>: Shared Mutable Data
Rc<T> gives you multiple owners. RefCell<T> gives you interior mutability. Combine them and you get data that multiple parts of the program can own and mutate.
use std::rc::Rc;
use std::cell::RefCell;
fn main() {
let shared = Rc::new(RefCell::new(0));
let a = Rc::clone(&shared);
let b = Rc::clone(&shared);
*a.borrow_mut() += 10;
*b.borrow_mut() += 5;
println!("shared: {}", shared.borrow()); // shared: 15
}This pattern has a direct multi-threaded equivalent: Arc<Mutex<T>>. Arc is the thread-safe version of Rc, and Mutex is the thread-safe version of RefCell. For single-threaded code, Rc<RefCell<T>> is cheaper because neither type uses atomic operations or OS locks.
Deref and Drop
Two traits explain why smart pointers behave like regular references.
Deref lets you write *ptr to reach the value inside. Box<T>, Rc<T>, and RefCell's borrow guards all implement Deref, so you can dereference them the same way you dereference a &T.
fn main() {
let boxed = Box::new(42);
let raw = &42;
println!("{}", *boxed); // 42 — same dereference syntax as a raw reference
println!("{}", *raw); // 42
println!("{}", boxed); // 42 — Deref coercion lets println! use it directly
}Rust also applies deref coercions automatically: a Box<String> can be passed where &str is expected because Box<String> derefs to String, which derefs to str. You get this chain without writing any extra code.
Drop runs cleanup when a value goes out of scope. All smart pointers implement it: Box<T> frees the heap allocation, Rc<T> decrements the count and frees when it hits zero. You can implement Drop for your own types too.
struct Resource {
name: String,
}
impl Drop for Resource {
fn drop(&mut self) {
println!("Dropping {}", self.name);
}
}
fn main() {
let _a = Resource { name: "A".to_string() };
let _b = Resource { name: "B".to_string() };
println!("Created A and B");
// Created A and B
// Dropping B <- dropped in reverse order
// Dropping A
}Values drop in reverse creation order: last in, first out. You almost never need to implement Deref or Drop yourself, but knowing they exist explains why *box_val works and why resources clean up automatically.
Key Takeaways
Box<T>allocates a value on the heap; use it for large data, recursive types, orBox<dyn Trait>trait objectsBox<T>has a single owner; when it drops, the heap memory is freedRc<T>enables shared ownership in single-threaded code via reference countingRc::clone(&rc)increments the reference count without copying data;Rc::strong_count(&rc)reads the current countRc<T>is not thread-safe; useArc<T>across threads (covered in the Shared State lesson)RefCell<T>defers borrow checks to runtime; violations panic instead of failing to compile.borrow()gives a shared reference,.borrow_mut()gives a mutable one; overlapping them panicsCell<T>is interior mutability forCopytypes — moves values in/out with.get()/.set(), no borrow check or panic possibleCell<T>andRefCell<T>are single-threaded only; equivalents across threads are atomics andMutex<T>Rc<RefCell<T>>combines shared ownership with interior mutability for single-threaded code- The multi-threaded equivalent is
Arc<Mutex<T>> Derefmakes*smart_ptrwork like*referenceand enables automatic deref coercionsDropruns cleanup automatically when a value goes out of scope, in reverse creation order
🎁 Your data is owned, shared, and cleaned up automatically. But how do you split a growing codebase across multiple files and share it with the world? Next up: Rust's module system, crates, and how Cargo wires them all together.