MeowyTheDev · Rust from Zero

Smart Pointers

owning on the heap, sharing a value, and mutating from the inside.

The Rust Programming Language · Chapter 15
EP.10companion guide · watch on the Rust from Zero playlist
10
Chapter

Why pointers

Book · Ch 15

Most of the time the borrow checker is enough. This chapter is about the handful of moments it isn't, and the types you reach for when you need a little more.

Book · Ch 15

Three escape hatches

Smart pointers aren't scary new magic. They're three small tools, and each one hands you a capability the plain borrow checker won't.

Here's the whole family on one card:

Pointer What it gives you
Box<T> a heap-allocated value
Rc<T> shared ownership, many owners of one value
RefCell<T> interior mutability, change a value through a shared reference
  • Box<T> moves a value onto the heap and owns it.
  • Rc<T> lets many parts of your program own the same value at once.
  • RefCell<T> lets you change a value through a shared reference, checked at runtime.

None of these is free. Each one trades a compile-time check for a capability you needed, and the rest of the chapter is just meeting them one at a time.

Remember: each one trades a check for a capability.

Chapter

Box<T>

Book · §15.1

Start with the simplest one. Box puts a single value on the heap and owns it, and it turns out to be the only way to give a recursive type a size.

Book · §15.1 Box<T>

Box puts a value on the heap

Box<T> is the simplest smart pointer. You hand it a value, it moves that value onto the heap, and you get back a pointer that owns it.

// a cat value, moved to the heap
let cat = Box::new(Cat {
    name: String::from("Mittens"), age: 3,
});

println!("{}", cat.name);
  • Box::new(...) takes a value and moves it onto the heap.
  • The box owns it. When cat goes away, the cat is freed. No garbage collector, no manual free.
  • You use it like the value itself. cat.name reaches right through the box, no unwrapping.

That last line is the whole feel of smart pointers. You hold a pointer, but it reads like you're holding the value.

Remember: box puts one value on the heap and owns it.

Book · §15.1 Box<T>

Why Box really exists

The heap is nice, but here's the case where Box stops being optional. Picture a cat's ancestry: each parent has a parent, on and on.

// each parent has a parent
enum Ancestry {
    Parent(String, Box<Ancestry>),
    Unknown,
}

// without Box, the compiler says:
// error: recursive type `Ancestry` has infinite size

Walk through what the compiler is worried about:

  • The type contains itself. A Parent holds another Ancestry, which can be another Parent, forever.
  • Without Box, it has no size. To lay it out in memory the compiler would have to add up an infinite chain, so it gives up: "recursive type has infinite size."
  • Box is a pointer of known size. It points at the next link instead of containing it, so the whole type gets a fixed, finite size.
Size the compiler sees
Parent(String, Ancestry) infinite, won't compile
Parent(String, Box<Ancestry>) fixed, a string plus one pointer

Remember: box gives a recursive type a known size.

Book · §15.2 Deref

Deref, and the quiet coercion

You've seen cat.name work straight through a box. Here's the machinery that makes that happen, and one piece of magic that's easy to miss.

let boxed = Box::new(Cat { name: "Mittens".into() });

let cat: Cat = *boxed;

// &Box<Cat> is accepted where &Cat is wanted
fn greet(c: &Cat) { println!("hi {}", c.name); }
greet(&boxed)
  • *boxed follows the pointer to the value behind it. The star gives you the Cat.
  • Deref coercion is the quiet part: pass &boxed (a &Box<Cat>) where a &Cat is wanted, and Rust converts it for you. You never wrote a conversion.
  • That's why everything just works. Methods and references flow right through Box, Rc, and every other smart pointer, because they all deref to the value inside.

Remember: * follows the pointer; coercion does it for you.

Chapter

Rc<T>

Book · §15.4

Ownership has been one-owner-at-a-time all series. Rc breaks that rule on purpose: many parts of your program can own the same value at once.

Book · §15.4 Rc<T>

Rc shares one value

Sometimes a single value genuinely belongs to several places. Two kittens, one shared ancestor. Rc<T> lets them all own it, and counts how many do.

use std::rc::Rc;

let ancestor = Rc::new(Cat { name: "Tom".into() }); // count: 1
let kitten_a = Rc::clone(&ancestor); // count: 2
let kitten_b = Rc::clone(&ancestor); // count: 3

// three owners, one cat, freed when the last drops
  • Rc::new(...) wraps the value and starts a reference count. Right now one owner holds this ancestor.
  • Rc::clone(&ancestor) does not copy the cat. It hands out another owner and bumps the count, to two, then three.
  • The count is the cleanup rule. When the last owner goes away and the count hits zero, the cat is freed. Shared, with no copies.

A quick note on clone here: it's cheap. You're cloning the pointer and bumping a counter, not duplicating the Cat.

Remember: rc gives many owners; the count frees the value at zero.

Chapter

RefCell<T>

Book · §15.5

Next, mutation. RefCell lets you change a value through a shared reference, by moving the borrow rules from compile time to runtime.

Book · §15.5 RefCell<T>

RefCell moves the rules to runtime

The borrow checker normally forbids changing a value through a shared reference. RefCell<T> lets you do exactly that, by checking the same rules later instead of refusing earlier.

use std::cell::RefCell;

struct Colony { roster: RefCell<Vec<String>> }
let colony = Colony { roster: RefCell::new(vec![]) };

colony.roster.borrow_mut().push("Mittens".into());

// two mutable borrows at once
let a = colony.roster.borrow_mut();
let b = colony.roster.borrow_mut(); // PANIC
  • RefCell<Vec<String>> wraps the roster so you can mutate it through &self.
  • borrow_mut() hands you a mutable reference. The borrow rules are still enforced, just at runtime now.
  • Break them and it panics. Ask for two mutable borrows at once and the program panics, instead of failing to compile. Still safe, just later.
Borrow rules checked Breaking them
normal &mut at compile time won't compile
RefCell<T> at runtime panics

Remember: refcell moves the borrow rules to runtime; break them and it panics.

Chapter

Combine and drop

Book · §15.5 + §15.3

The last two pieces. First the famous combo that stacks sharing and mutation, then Drop, which ties cleanup to the moment a value leaves scope.

Book · §15.5 RefCell<T>

Rc<RefCell<T>>, shared and mutable

You have a tool for many owners and a tool for mutating through a share. Stack them and you get the combo you'll reach for most.

use std::rc::Rc;
use std::cell::RefCell;

let roster = Rc::new(RefCell::new(vec!["Tom"]));

let a = Rc::clone(&roster); // shared owner
let b = Rc::clone(&roster);

a.borrow_mut().push("Mittens");
println!("{:?}", b.borrow()); // ["Tom", "Mittens"]
  • Rc on the outside gives many owners of one roster.
  • RefCell on the inside lets every one of those owners change it through a shared reference.
  • Together: one cat pushes a name through a, and b sees it. Shared and mutable, single-threaded. This is where graphs and trees live.

Remember: rc<refcell<t>> is shared plus mutable, single-threaded.

Book · §15.3 Drop

Drop puts cleanup in the type

Smart pointers free their value automatically. Drop is the trait that makes that happen, and you can use it on your own types too.

struct Carrier { cat: String }

impl Drop for Carrier {
    fn drop(&mut self) {
        println!("{} released", self.cat);
    }
}

{
    let c = Carrier { cat: "Mittens".into() };
} // c leaves scope, drop runs here
  • The Drop trait lets a type say what should happen when it's cleaned up.
  • You never call drop yourself. Rust runs it for you the moment the value leaves scope.
  • So cleanup is in the type. The carrier releases its cat automatically. No try/finally, no defer, the cleanup is tied to scope. (This is the idea other languages call RAII.)

Remember: drop runs at scope exit; you never call it yourself.

Book · Ch 15

When to reach for each

Here's the whole chapter on one card. You don't memorize these, you match the need to the pointer.

When you need Reach for
a value on the heap, or a recursive type Box<T>
many parts to own the same value Rc<T>
to mutate through a shared reference RefCell<T>
both sharing and mutation at once Rc<RefCell<T>>
  • Box<T> for the heap and for recursive types.
  • Rc<T> when many owners need the same value.
  • RefCell<T> when you need to change something through a shared reference.
  • Rc<RefCell<T>> when you need both, the combo that does the most work.

Remember: match the need to the pointer.

The whole episode in one line:

Not one pointer. The right pointer. Box, Rc, RefCell, and knowing when to reach for each.

Cheatsheet recap

One line per idea, in order. Skim this when you just need the reminder.

IdeaRemember
Three escape hatcheseach one trades a check for a capability.
Box puts a value on the heapbox puts one value on the heap and owns it.
Why Box really existsbox gives a recursive type a known size.
Deref, and the quiet coercion* follows the pointer; coercion does it for you.
Rc shares one valuerc gives many owners; the count frees the value at zero.
RefCell moves the rules to runtimerefcell moves the borrow rules to runtime; break them and it panics.
Rc<RefCell<T>>, shared and mutablerc<refcell<t>> is shared plus mutable, single-threaded.
Drop puts cleanup in the typedrop runs at scope exit; you never call it yourself.
When to reach for eachmatch the need to the pointer.
Maps to: The Rust Programming Language, Chapter 15.
Practice: Rustlings 19_smart_pointers.
100%