Updated Jul 30, 2026

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 ListNode 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 vtable

Static 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 owner

That 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 immutable

Use 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, or Box<dyn Trait> trait objects
  • Box<T> has a single owner; when it drops, the heap memory is freed
  • Rc<T> enables shared ownership in single-threaded code via reference counting
  • Rc::clone(&rc) increments the reference count without copying data; Rc::strong_count(&rc) reads the current count
  • Rc<T> is not thread-safe; use Arc<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 panics
  • Cell<T> is interior mutability for Copy types — moves values in/out with .get()/.set(), no borrow check or panic possible
  • Cell<T> and RefCell<T> are single-threaded only; equivalents across threads are atomics and Mutex<T>
  • Rc<RefCell<T>> combines shared ownership with interior mutability for single-threaded code
  • The multi-threaded equivalent is Arc<Mutex<T>>
  • Deref makes *smart_ptr work like *reference and enables automatic deref coercions
  • Drop runs 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.

📝 Ready to test your knowledge?

Answer the quiz below to mark this lesson complete.

Spot something off? Report an issue
© 2026 ByteLearn.dev. Free courses for developers. · Privacy