scipio avatar

Learn Rust Series (#12) - Smart Pointers: Box, Rc & RefCell

scipio

Published: 30 Jul 2026 › Updated: 30 Jul 2026Learn Rust Series (#12) - Smart Pointers: Box, Rc & RefCell

Learn Rust Series (#12) - Smart Pointers: Box, Rc & RefCell

Learn Rust Series (#12) - Smart Pointers: Box, Rc & RefCell

rust-banner.png

What will I learn

  • You will learn what a smart pointer actually is, and how it differs from the plain references we have used since episode 3;
  • how Box<T> puts a value on the heap, and why some recursive types simply cannot exist without it;
  • how Rc<T> gives one value multiple owners through reference counting, and when that is the right tool;
  • what interior mutability means, and how RefCell<T> moves borrow checking from compile time to runtime;
  • how Rc<RefCell<T>> combines the two to share mutable data, and exactly where it can bite you;
  • how all of this maps onto the garbage-collected world you may already know from Python.

Requirements

  • A working modern computer running macOS, Windows or Ubuntu;
  • An installed Rust toolchain (via rustup, from rustup.rs);
  • The previous eleven episodes read, and especially ownership, borrowing and lifetimes fresh in your head;
  • The ambition to learn systems programming from the ground up.

Difficulty

  • Beginner

Curriculum (of the Learn Rust Series):

Learn Rust Series (#12) - Smart Pointers: Box, Rc & RefCell

For eleven episodes ownership has been strict and singular: one value, one owner, end of story. When the owner goes out of scope, the value is dropped, and the borrow checker guarantees no reference outlives the data it points at. That model is wonderful, and it is most of why Rust is safe without a garbage collector. But the real world has shapes that a single, exclusive owner cannot express -- a tree whose nodes are shared between several parents, a graph with cycles, or a value you genuinely need to mutate through a handle you only borrowed. Smart pointers are the sanctioned tools for exactly those shapes.

A smart pointer is a type that behaves like a pointer -- it points at data and lets you reach through to it -- but carries extra capabilities and, crucially, extra bookkeeping. Where a plain &T reference is just an address the borrow checker watches, a smart pointer is a proper struct that owns its data and adds one specific power on top. Box adds heap allocation. Rc adds shared ownership. RefCell adds runtime-checked mutation. Knowing which to reach for, and being able to combine them, is a big part of writing idiomatic Rust ;-) Having said that, we take them one at a time, and as always we start with last episode's homework.

Solutions to Episode 11's exercises

Episode 11 was closures and iterators. There were three exercises, and here is how each one lands with full code.

Exercise 1 asked for evens(upto) returning the even numbers from 0 up to and including upto as a lazy iterator, then collecting them into a Vec<u32> in main:

fn evens(upto: u32) -> impl Iterator<Item = u32> {
    (0..=upto).filter(|n| n % 2 == 0)
}

fn main() {
    let v: Vec<u32> = evens(10).collect();
    println!("{v:?}"); // [0, 2, 4, 6, 8, 10]
}

The key insight is the return type impl Iterator<Item = u32> from last episode -- it hands back the lazy Filter pipeline without you ever having to name its unspeakable concrete type. Nothing runs until collect pulls the items through.

Exercise 2 wanted a single chain over 1..=100 that keeps the multiples of 3, squares each one, and sums the result into an i32, all in one expression with no intermediate Vec:

fn main() {
    let total: i32 = (1..=100).filter(|n| n % 3 == 0).map(|n| n * n).sum();
    println!("{total}"); // 114771
}

Because iterator adapters are lazy, this whole thing makes exactly one pass over the range with zero heap allocations in the middle. filter and map build the plan, and sum is the consumer that finally drives it. It reads top to bottom like plain English: keep the multiples of three, square each survivor, add them all up.

Exercise 3 was the chewy one: implement Iterator for a Fibonacci struct whose next returns the next Fibonacci number each time, then use Fibonacci::new().take(10) to grab the first ten:

struct Fibonacci {
    a: u64,
    b: u64,
}

impl Fibonacci {
    fn new() -> Fibonacci {
        Fibonacci { a: 0, b: 1 }
    }
}

impl Iterator for Fibonacci {
    type Item = u64;
    fn next(&mut self) -> Option<u64> {
        let out = self.a;
        self.a = self.b;
        self.b = out + self.b;
        Some(out)
    }
}

fn main() {
    let fibs: Vec<u64> = Fibonacci::new().take(10).collect();
    println!("{fibs:?}"); // [0, 1, 1, 2, 3, 5, 8, 13, 21, 34]
}

Notice this iterator is infinite -- next never returns None, it just keeps producing numbers forever. That is perfectly fine, because take(10) is the adapter that caps it: it pulls exactly ten items through and then stops asking. This is laziness earning its keep -- an infinite sequence costs you nothing until you decide how much of it you actually want. Implement one method, next, and you inherit take, collect, map and the entire toolbox for free, exactly as we saw last time. Now, on to the smart pointers themselves.

Box: a value on the heap

Box<T> is the simplest smart pointer, and the one everything else builds on. It allocates its contents on the heap and keeps a small pointer to them on the stack. When the box goes out of scope, its Drop implementation frees the heap data automatically -- no manual free, no leak, no dangling. For a single integer that is rarely useful on its own, but it is the foundation for the more interesting pointers below:

fn main() {
    let boxed: Box<i32> = Box::new(42);
    println!("{}", *boxed);     // deref to read the value: 42
    println!("{}", *boxed + 1); // deref explicitly for arithmetic: 43
}

That little * in front of boxed is the dereference operator. A Box<i32> is not itself an i32 -- it is a handle that points at one -- so to reach the value inside you deref it with *, just as you would deref a plain &i32 reference. In many everyday situations Rust inserts the deref for you automatically (a convenience called deref coercion, which gets its own episode further down the line), but it is worth seeing the explicit * at least once so you understand what is really happening underneath.

You reach for Box in three situations. The first is when a type would otherwise have infinite size and the compiler cannot lay it out. The second is when you have a large value and want to move it around cheaply by moving just the pointer in stead of copying all the bytes. The third is when you need a trait object like Box<dyn Trait> to store different concrete types behind one interface -- something we will meet properly in a later episode. The first of those is the classic teaching case, so let us look at it directly.

Recursive types need a Box

Consider a singly linked list, where each node holds a value and the rest of the list after it. Written the naive way, the compiler cannot size it: a List would contain a List, which would contain a List, forever, and a value with no finite size cannot live on the stack. Putting the tail behind a Box breaks that regress, because a box is just a pointer with a known, fixed size (one machine word) regardless of how big the thing it points at is:

#[derive(Debug)]
enum List {
    Cons(i32, Box<List>),
    Nil,
}

use self::List::{Cons, Nil};

fn main() {
    let list = Cons(1, Box::new(Cons(2, Box::new(Cons(3, Box::new(Nil))))));
    println!("{list:?}"); // Cons(1, Cons(2, Cons(3, Nil)))
}

This is the classic cons list, borrowed straight from the Lisp family, and it is the textbook example for a reason: the recursion is right there in the type. Cons means "a value, and the rest of the list"; Nil means "the end". Without the Box, rustc stops you flatly with "recursive type List has infinite size", and helpfully suggests inserting exactly the indirection we used. The box gives each node a fixed size on the stack -- an i32 plus a pointer -- while the actual next node lives on the heap. Having said that, in real code you would almost always reach for a Vec in stead of hand-rolling a linked list (it is faster and friendlier to the CPU cache), but the pattern is essential the moment you build trees, expression grammars for a parser, or any structure that genuinely nests into itself.

Rc: many owners, one value

Ownership in Rust is normally exclusive -- one owner at a time, full stop. But sometimes you genuinely need several owners of the same data, with the value living until the very last owner goes away. Think of a node in a graph that two other nodes both point at: who owns it? The honest answer is "both of them, and it should live as long as either still cares". Rc<T>, the reference-counted pointer, provides exactly that. Cloning an Rc does not copy the underlying data at all; it produces another handle to the same data and bumps an internal counter:

use std::rc::Rc;

fn main() {
    let a = Rc::new(String::from("shared data"));
    println!("count after a: {}", Rc::strong_count(&a)); // 1

    let b = Rc::clone(&a); // cheap: just increments the count
    let c = Rc::clone(&a);
    println!("count after b and c: {}", Rc::strong_count(&a)); // 3

    println!("{a}, {b}, {c}"); // all three see the same string
}

Every time you clone, the strong count goes up; every time one of those handles is dropped, the count goes down. The String itself is freed only at the exact moment the count hits zero -- that is, when the final owner disappears. So a, b, and c all read the same string, and none of them has to worry about who frees it or when; the reference count settles that automatically.

Two conventions are worth internalising here. First, we write Rc::clone(&a) rather than the method-style a.clone(), purely as a signal to human readers: this is a cheap pointer copy that bumps a counter, not an expensive deep clone of the whole string. Both forms do the same thing, but the explicit spelling documents intent. Second -- and this is the bridge for anyone coming from Python -- this reference counting is essentially what CPython does for every object, all the time, under the hood. When you write b = a in Python and both names point at the same list, the interpreter is bumping a refcount exactly like Rc does. The difference is that Rust makes it explicit and opt-in: you pay for reference counting only where you specifically asked for it with Rc, and everything else stays on the zero-overhead single-owner model. Rc is also single-threaded only -- try to send one across threads and the compiler refuses. The thread-safe cousin has a different name that we will meet before long.

RefCell: borrowing checked at runtime

Here is a puzzle that follows naturally. An Rc gives you shared access -- multiple owners -- but everything it hands you is effectively read-only, because Rust's core rule says you cannot have a mutable borrow while shared borrows exist. So how do you ever mutate data that is shared through an Rc? The answer is a pattern called interior mutability: a type that lets you mutate its contents even through a shared reference, while still upholding the borrowing rules -- one writer or many readers -- by checking them at runtime in stead of at compile time.

RefCell<T> is the single-threaded implementation of that idea. You do not reach into it directly; you ask it for a borrow. The borrow method gives you a shared read guard, and borrow_mut gives you an exclusive write guard:

use std::cell::RefCell;

fn main() {
    let cell = RefCell::new(5);

    *cell.borrow_mut() += 10; // exclusive borrow, mutate, release
    *cell.borrow_mut() += 10;

    println!("{}", cell.borrow()); // shared borrow to read: 25
}

The rules being enforced are exactly the same rules from episode 3 -- one exclusive writer, or any number of shared readers, never both at once. The only difference is when they are enforced. A plain &mut is checked by the borrow checker at compile time, and a violation means your program does not build. A RefCell instead keeps a little counter at runtime, hands out guards, and verifies the rules dynamically. That trade buys you flexibility -- you can mutate through a shared handle, which the static checker would never allow -- in exchange for moving a whole class of error from compile time to run time. That is a real cost, so you use RefCell deliberately, where the sharing pattern genuinely needs it, not as a lazy escape hatch from the borrow checker.

The runtime borrow panic

Because those checks are dynamic, it becomes possible to violate them -- and when you do, the program panics rather than silently corrupting memory. That is the important safety guarantee: RefCell will never let you actually alias mutable data, it just detects the mistake at run time instead of refusing to compile. Here is the mistake, with the offending line commented out so the example still builds and you can experiment:

use std::cell::RefCell;

fn main() {
    let cell = RefCell::new(5);
    let writer = cell.borrow_mut();
    // let second = cell.borrow_mut(); // would panic: already mutably borrowed
    println!("{}", *writer); // 5
}

Uncomment that second borrow_mut and the program compiles perfectly fine, then panics at run time with the message "already borrowed: BorrowMutError". The first writer guard is still alive -- it has not been dropped yet -- so asking for a second mutable borrow breaks the one-writer rule, and RefCell catches it. The lesson is to keep borrows short and scoped: pull the guard, do your mutation, and let it drop as soon as possible, ideally by not binding it to a long-lived variable at all. Notice the earlier example never named the guard -- *cell.borrow_mut() += 10; creates the guard, mutates, and drops it all on one line -- which is precisely why it never risked a panic.

Rc plus RefCell: shared and mutable

Now combine the two and you get the genuine workhorse pattern for shared, mutable state in single-threaded Rust: Rc<RefCell<T>>. Read the nesting from the outside in -- the Rc provides multiple owners, and the RefCell inside provides mutation through each of those shared handles. Either capability alone is limited; together they cover the case where several parts of your program both hold and change the same data:

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

fn main() {
    let shared = Rc::new(RefCell::new(vec![1, 2, 3]));

    let handle = Rc::clone(&shared);
    handle.borrow_mut().push(4); // mutate through the clone

    shared.borrow_mut().push(5); // and through the original

    println!("{:?}", shared.borrow());        // [1, 2, 3, 4, 5]
    println!("owners: {}", Rc::strong_count(&shared)); // 2
}

Both shared and handle point at the same underlying vector, and either one can push to it -- the change made through handle is visible through shared, because there is only one vector, not two. This Rc<RefCell<T>> combination is how you build graph-like structures, shared caches, observer patterns, and tree nodes that need to know about their parent, all without threads. It is common enough that experienced Rustaceans recognise the shape on sight.

I have to add the honest warning, though, because this convenience has a sharp edge. Rc<RefCell<T>> opts you out of two of Rust's compile-time guarantees at once. The Rc side makes it possible to build reference cycles -- two nodes that own each other -- which leak memory, because the strong count of each never reaches zero. And the RefCell side means a borrowing mistake surfaces as a runtime panic in stead of a compile error. Neither is catastrophic (a cycle leaks but does not crash, a panic is caught not undefined), but both are real, and they are why this pattern is a considered choice rather than a default. When you do need shared mutable state across threads, by the way, the same idea reappears with two thread-safe names in place of these, and that is a story for very soon.

Mutating an Rc without RefCell

Before we wrap up, one more handy tool that avoids RefCell entirely in a specific case. Rc::get_mut gives you a mutable reference to the inner value, but only when you are the sole owner. The instant there is any other owner, it returns None, because handing out a mutable reference while other handles could read the data would be exactly the aliasing bug Rust exists to prevent:

use std::rc::Rc;

fn main() {
    let mut a = Rc::new(5);
    if let Some(v) = Rc::get_mut(&mut a) {
        *v += 1; // allowed: a is the only owner right now
    }
    println!("{a}"); // 6

    let _b = Rc::clone(&a); // now the data is shared
    println!("can mutate? {}", Rc::get_mut(&mut a).is_some()); // false
}

That None is a compiler-blessed guarantee: you can only ever mutate through get_mut at a moment when you provably hold the only handle, so no other owner can be surprised by the change. It is the perfect tool when you build up a value while you are still the sole owner, then clone it out to share afterwards -- a surprisingly common patern in practice -- no RefCell, no runtime check, no panic risk at all. It will not help you once the data is genuinely shared and still needs mutating -- that is RefCell's job -- but for the build-then-share pattern it is cleaner and cheaper.

So what did we actually cover?

  • A smart pointer is a type that acts like a pointer but owns its data and adds a capability plus some bookkeeping, unlike a plain &T reference which is just a watched address.
  • Box<T> allocates a value on the heap and frees it automatically on drop. It is the tool for recursive types like a cons list (which would otherwise have infinite size), for moving large values cheaply, and for trait objects.
  • Rc<T> gives one value multiple owners via reference counting. Rc::clone bumps a counter cheaply rather than deep-copying, and the data is freed only when the last owner drops. It is single-threaded.
  • Interior mutability is the pattern of mutating data through a shared reference, and RefCell<T> implements it by moving the one-writer-or-many-readers check from compile time to run time -- a violation panics with BorrowMutError rather than failing to compile.
  • Rc<RefCell<T>> is the everyday pattern for shared, mutable, single-threaded state, at the cost of possible reference cycles (leaks) and runtime borrow panics.
  • Rc::get_mut lets you mutate an Rc's contents without a RefCell, but only while you are the sole owner -- perfect for the build-then-share pattern.

The through-line, and the reason I wanted these three in one episode, is that each smart pointer relaxes one specific rule from the ownership model we spent eleven episodes building -- and does it in a controlled, opt-in way that keeps the safety guarantees you actually rely on. Box relaxes "everything lives on the stack". Rc relaxes "exactly one owner". RefCell relaxes "mutation is checked at compile time". You are never throwing safety away; you are trading one static guarantee for a runtime one, on purpose, exactly where the problem demands it.

If you come from Python, notice how much of this you already had for free and never saw: every object was heap-allocated (that is Box), every assignment shared a reference and bumped a count (that is Rc), and you could mutate anything through any name at any time (that is RefCell, minus the safety checks). Rust simply makes each of those a separate, named, opt-in decision, so you pay for exactly the flexibility you use and nothing more. That is the whole philosophy in miniature.

Next time we take the single-threaded Rc<RefCell<T>> idea and ask the obvious follow-up question: what changes when more than one thread is involved? The shapes are familiar but the names and the guarantees shift, and it is where Rust's "fearless" reputation really comes from. But one thing at a time ;-)

Exercises

Three exercises, gentle to chewier as always. Full solutions open the next episode -- play with them first, the compiler is a patient teacher.

  1. Build a Cons list of five integers (reuse the List enum from this episode) and write a recursive function fn sum(list: &List) -> i32 that adds up every value by pattern-matching on Cons and Nil. Print the total from main.
  2. Create an Rc<String>, then inside an inner { } block clone it three more times and print Rc::strong_count from inside the block. After the block ends, print the count again and watch it fall back down as those clones are dropped.
  3. Make an Rc<RefCell<i32>>, clone the handle, increment the value through one handle with *shared.borrow_mut() += 1, then read it through the other handle and confirm both see the new number. This proves the two handles really do point at one shared cell.

Thanks for your time, en de groeten! ;-)

scipio@scipio

Leave Learn Rust Series (#12) - Smart Pointers: Box, Rc & RefCell to:

Written by

Does it matter who's right, or who's left?

Read more #stem posts


Best Posts From scipio

We have not curated any of scipio's posts yet. But you can encourage our curation team to review posts by visiting them regularly and by referring other readers. Because we give priority to frequently read content.

More Posts From scipio