Smart Pointers 📦🧠
Ownership's rules are strict on purpose, but some programs genuinely need more flexibility. Smart pointers bend the rules safely, each in one specific, well-labeled way.
What's a smart pointer?
A regular reference (&T) just points at data. A smart
pointer is a small struct that points at data and manages it:
freeing it at the right moment, counting who's using it, or policing access.
You've secretly used two already: String and Vec both
own heap data and clean it up on scope exit. Today: the three famous ones.
Box<T>: a simple crate for the warehouse 📦
Box moves a value to the heap and owns it there. One owner, freed at
scope end; ownership rules unchanged, just heap-flavored:
fn main() {
let boxed_num = Box::new(5);
println!("{}", *boxed_num + 1); // * reaches into the box
} // box dropped → heap memory freed
Why would you? The killer use case is recursive types. This doesn't compile:
enum TodoList {
Item(String, TodoList), // ❌ a list containing a list containing...
Done,
}
The compiler must know how much memory a type needs, but this type contains
itself, so the math never ends ("infinite size", it will say). A Box
breaks the spiral, because a box is always just one pointer-size, no matter
what's inside:
enum TodoList {
Item(String, Box<TodoList>), // ✅ known size: text + one pointer
Done,
}
use TodoList::{Item, Done};
fn main() {
let list = Item(String::from("learn Rust"),
Box::new(Item(String::from("feed Rusty"),
Box::new(Done))));
let mut current = &list;
while let Item(task, next) = current {
println!("todo: {task}");
current = next;
}
}
Rc<T>: shared ownership, counted 🔢
Ownership rule #1 says one owner per value. But consider a song in three
playlists: who owns it? All of them, really; it should live until the
last playlist is gone. Rc (reference counted) does exactly
that: every clone increments a counter, every drop decrements it,
and the value is freed when the count hits zero.
use std::rc::Rc;
fn main() {
let song = Rc::new(String::from("🎵 Crab Rave"));
let playlist_a = Rc::clone(&song); // cheap: counts, doesn't copy
let playlist_b = Rc::clone(&song);
println!("owners: {}", Rc::strong_count(&song)); // 3
drop(playlist_a);
drop(playlist_b);
println!("owners: {}", Rc::strong_count(&song)); // 1, still alive
}
Note: Rc hands out read-only access; many owners who could
all write would be chaos (Lesson 7's law). And it's single-threaded only; its
thread-safe twin Arc stars in the next lesson.
RefCell<T>: the rules, checked at runtime 🕵️
Sometimes you genuinely need to mutate shared data, and you (not the compiler)
know it's safe. RefCell moves the borrow checking from compile time
to runtime: the same "many readers or one writer" law, enforced
while the program runs. Break it and the program panics, pointing at the crime.
use std::cell::RefCell;
fn main() {
let diary = RefCell::new(String::from("day 1."));
diary.borrow_mut().push_str(" found kelp."); // runtime-checked write
println!("{}", diary.borrow()); // runtime-checked read
// But two writers at once?
let _a = diary.borrow_mut();
// let _b = diary.borrow_mut(); // 💥 PANIC: already borrowed
}
The classic combo is Rc<RefCell<T>>: many owners
(Rc) who can each mutate (RefCell), a favorite for graphs
and shared game state in single-threaded programs.
Box.
Several owners, read-only? Rc.
Several owners who mutate (one thread)? Rc<RefCell<T>>.
Across threads? Next lesson. 😉
- Using
Box"for performance" everywhere: it adds indirection; use it when the type system needs it, not as a lucky charm. - Holding a
borrow_mut()guard too long, then borrowing again: runtime panic. Keep RefCell borrows short and scoped. song.clone()vsRc::clone(&song): both work, but Rustaceans write the second to signal "just counting, not deep-copying."
Break the infinite loop
Type the boxless TodoList into the Playground and read the
"infinite size" error, including the compiler's suggested fix. Then apply it
and build a three-item list.
Reveal answer
The compiler suggests exactly what this lesson did: "insert some indirection
(e.g., a Box, Rc, or &) to break the
cycle." Even at the advanced level, error messages remain the best teacher;
that never stops being true.
Count the owners
Create an Rc<Vec<i32>>, clone it twice inside an inner
{ } scope, printing Rc::strong_count before, during, and
after the scope. Predict the three numbers first, then run it.
Reveal solution
use std::rc::Rc;
fn main() {
let data = Rc::new(vec![1, 2, 3]);
println!("before: {}", Rc::strong_count(&data)); // 1
{
let _a = Rc::clone(&data);
let _b = Rc::clone(&data);
println!("inside: {}", Rc::strong_count(&data)); // 3
}
println!("after: {}", Rc::strong_count(&data)); // 1
}
Scope ends → clones dropped → count falls. Deterministic, visible, no garbage collector in sight.
The shared scoreboard
Two "views" of one scoreboard: create
Rc<RefCell<Vec<i32>>>, clone it into
view_a and view_b, push a score through each view, then
print the vector through the original handle. One value, three names, everybody
sees everything.
Reveal solution
use std::rc::Rc;
use std::cell::RefCell;
fn main() {
let scores = Rc::new(RefCell::new(vec![100]));
let view_a = Rc::clone(&scores);
let view_b = Rc::clone(&scores);
view_a.borrow_mut().push(85);
view_b.borrow_mut().push(92);
println!("{:?}", scores.borrow()); // [100, 85, 92]
}