16 - Smart Pointers
📋 Jump to Takeaways🎁 A value that cleans itself up, shares ownership without copying, and even lets multiple parts of your program own the same data. Not with a garbage collector, but with compile-time rules and a small runtime counter.
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:
// A singly linked list in Rust
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.
The same thing works as a struct, which is closer to what you'd write in Go or C:
struct Node {
value: i32,
next: Option<Box<Node>>,
}
fn main() {
let list = Node {
value: 1,
next: Some(Box::new(Node {
value: 2,
next: Some(Box::new(Node { value: 3, next: None })),
})),
};
println!("head: {}", list.value); // head: 1
}Option<Box<Node>> is Rust's version of a nullable pointer. Some(...) means there's a next node, None means end of list.
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 because 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
// alternatively we can do this:
if let Some(parent) = &child.parent {
println!("parent value: {}", parent.value); // parent value: 1
}
println!("root owners: {}", Rc::strong_count(&root)); // root owners: 2
}Weak: Breaking Reference Cycles
The Rc tree pattern above works for child-to-parent pointers. But if you also add a children: Vec<Rc<Node>> field, the parent and child hold Rc references to each other. That's a reference cycle. The strong count never reaches zero and the memory leaks.
The fix is Weak<T>, a non-owning reference that doesn't increment the strong count. You get a Weak by calling Rc::downgrade(&rc), and convert back with .upgrade() which returns Option<Rc<T>> (it's None if the value was already dropped).
This example uses RefCell (covered in the next lesson) to allow mutation through the shared Rc references:
use std::rc::{Rc, Weak};
use std::cell::RefCell;
struct Node {
value: i32,
parent: RefCell<Weak<Node>>,
children: RefCell<Vec<Rc<Node>>>,
}
fn main() {
let root = Rc::new(Node {
value: 1,
parent: RefCell::new(Weak::new()),
children: RefCell::new(vec![]),
});
let child = Rc::new(Node {
value: 2,
parent: RefCell::new(Rc::downgrade(&root)),
children: RefCell::new(vec![]),
});
root.children.borrow_mut().push(Rc::clone(&child));
println!("root strong: {}", Rc::strong_count(&root)); // root strong: 1
println!("root weak: {}", Rc::weak_count(&root)); // root weak: 1
println!("child parent: {}", child.parent.borrow().upgrade().unwrap().value); // child parent: 1
}The parent holds a strong Rc to the child (it owns it), and the child holds a Weak back to the parent (it doesn't own it). When the parent drops, the strong count hits zero and the memory frees. The child's Weak reference becomes invalid, and .upgrade() returns None.
Rc<T> can't cross thread boundaries. The reference count is a plain integer, and two threads incrementing it simultaneously would corrupt it. For multi-threaded shared ownership, use Arc<T> instead (covered in the Shared State lesson).
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 <- 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 countWeak<T>is a non-owning reference created withRc::downgrade(); use it to break reference cycles (e.g., parent pointers in trees)Rc<T>is not thread-safe; useArc<T>across threads (covered in the Shared State lesson)Derefmakes*smart_ptrwork like*referenceand enables automatic deref coercionsDropruns cleanup automatically when a value goes out of scope, in reverse creation order
🎁 You can own data, share it across your program, and clean it up automatically. But what if you need to mutate something that multiple parts of the code can see? Rust's borrow checker says no. Unless you move the check to runtime.