Skip to main content
Rust intermediate Lesson 23 of 30

Smart Pointers in Rust

Understand Box<T>, Rc<T>, Arc<T>, RefCell<T>, and interior mutability patterns in Rust.

Box<T> — Heap Allocation

By default, Rust allocates values on the stack, which is fast but requires knowing the size at compile time. Box<T> moves a value to the heap and stores a fixed-size pointer on the stack. The three main reasons to reach for Box are: you need a value on the heap, you are working with trait objects (dyn Trait) whose size varies, or you are building a recursive data structure where a type would otherwise be infinite in size.

fn main() {
    // Allocate an integer on the heap — useful when you need a stable address
    let b = Box::new(5);
    println!("b = {}", b); // Box<T> implements Deref, so it behaves like &T

    // Box is dropped automatically when it goes out of scope — no manual free()
}

Recursive Types with Box

Without Box, a recursive type like a linked list or tree would have infinite size — the compiler cannot determine how much stack space to allocate. Wrapping the recursive field in Box gives it a known, fixed size (one pointer).

// Without Box, this would be an error: "recursive type has infinite size"
#[derive(Debug)]
enum List {
    Cons(i32, Box<List>),  // Box gives the recursive field a known size
    Nil,
}

fn main() {
    // Build a linked list: 1 -> 2 -> 3 -> Nil
    let list = List::Cons(1,
        Box::new(List::Cons(2,
            Box::new(List::Cons(3,
                Box::new(List::Nil))))));

    println!("{:?}", list);
}

Trait Objects with Box

Box<dyn Trait> is how you store values of different concrete types that share a common interface. The concrete type is heap-allocated and the Box holds a fat pointer (data pointer + vtable pointer). This enables heterogeneous collections and runtime polymorphism.

trait Shape {
    fn area(&self) -> f64;
    fn name(&self) -> &str;
}

struct Circle { radius: f64 }
struct Rectangle { width: f64, height: f64 }

impl Shape for Circle {
    fn area(&self) -> f64 { std::f64::consts::PI * self.radius * self.radius }
    fn name(&self) -> &str { "circle" }
}

impl Shape for Rectangle {
    fn area(&self) -> f64 { self.width * self.height }
    fn name(&self) -> &str { "rectangle" }
}

fn main() {
    // Different concrete types stored in the same Vec via trait objects
    let shapes: Vec<Box<dyn Shape>> = vec![
        Box::new(Circle { radius: 3.0 }),
        Box::new(Rectangle { width: 4.0, height: 5.0 }),
        Box::new(Circle { radius: 1.5 }),
    ];

    for shape in &shapes {
        println!("{}: area = {:.2}", shape.name(), shape.area());
    }
}

Rc<T> — Shared Ownership (Single-Threaded)

Rust’s ownership model normally allows only one owner per value. Rc<T> (Reference Counted) breaks that rule by maintaining a count of how many Rc handles point to the same heap allocation. The value is dropped only when the last handle is dropped. This is useful in graph-like data structures, caches, and event systems where multiple parts of the code need to share a value’s lifetime.

use std::rc::Rc;

fn main() {
    let value = Rc::new(String::from("shared data"));

    // Rc::clone creates another handle — does not clone the String, just increments the count
    let handle1 = Rc::clone(&value);
    let handle2 = Rc::clone(&value);

    println!("reference count: {}", Rc::strong_count(&value)); // 3

    println!("{}", value);   // "shared data"
    println!("{}", handle1); // same allocation
    println!("{}", handle2); // same allocation

    drop(handle1);
    println!("after drop: {}", Rc::strong_count(&value)); // 2
    // value is dropped when the last Rc handle goes out of scope
}

Rc<T> is not Send — it cannot be shared across threads. Use Arc<T> for multi-threaded scenarios.

Arc<T> — Shared Ownership (Multi-Threaded)

Arc<T> (Atomically Reference Counted) is the thread-safe version of Rc<T>. The reference count is updated with atomic CPU instructions rather than plain integer operations, which makes it safe to clone and drop Arc handles from multiple threads simultaneously. The slight overhead of atomic operations is the price for thread safety.

use std::sync::Arc;
use std::thread;

fn main() {
    let data = Arc::new(vec![1, 2, 3, 4, 5]);
    let mut handles = vec![];

    for _ in 0..3 {
        // Clone the Arc to give each thread its own handle to the same data
        let data = Arc::clone(&data);
        handles.push(thread::spawn(move || {
            println!("sum: {}", data.iter().sum::<i32>());
        }));
    }

    for h in handles { h.join().unwrap(); }
    // data is dropped here — the Vec is freed when the last Arc handle is gone
}

RefCell<T> — Interior Mutability

Rust’s borrow checker enforces at compile time that you cannot have a mutable reference when shared references exist. RefCell<T> defers this check to runtime, enabling mutation through a shared reference. This is called interior mutability and is useful when the compiler cannot statically verify that borrows are safe but you know at runtime they will be.

The trade-off: if you violate the rules (two mutable borrows, or a mutable borrow while a shared borrow exists), RefCell panics at runtime instead of giving a compile error.

use std::cell::RefCell;

fn main() {
    // Shared reference (&T) to the RefCell — but we can still mutate through it
    let data = RefCell::new(vec![1, 2, 3]);

    // borrow() gives a shared reference — like &T
    println!("{:?}", data.borrow());

    // borrow_mut() gives a mutable reference — like &mut T
    data.borrow_mut().push(4);

    println!("{:?}", data.borrow()); // [1, 2, 3, 4]

    // RUNTIME PANIC if you try to borrow mutably while a borrow is active:
    // let _shared = data.borrow();
    // let _mutable = data.borrow_mut(); // panics: already borrowed
}

Rc<RefCell<T>> — Shared Mutable State

Combining Rc and RefCell is the standard single-threaded pattern for shared mutable state. Rc provides shared ownership (multiple owners), and RefCell provides interior mutability (mutation through shared references). Together they let multiple parts of a program share and mutate the same data without fighting the borrow checker.

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

#[derive(Debug)]
struct Node {
    value: i32,
    children: Vec<Rc<RefCell<Node>>>,
}

impl Node {
    fn new(value: i32) -> Rc<RefCell<Self>> {
        Rc::new(RefCell::new(Node { value, children: vec![] }))
    }

    fn add_child(parent: &Rc<RefCell<Node>>, child: Rc<RefCell<Node>>) {
        parent.borrow_mut().children.push(child);
    }
}

fn main() {
    let root = Node::new(1);
    let child1 = Node::new(2);
    let child2 = Node::new(3);

    Node::add_child(&root, Rc::clone(&child1));
    Node::add_child(&root, Rc::clone(&child2));

    println!("root value: {}", root.borrow().value);
    println!("children: {}", root.borrow().children.len()); // 2

    // child1 can still be accessed independently even after being added to root
    child1.borrow_mut().value = 20;
    println!("child1 value via root: {}", root.borrow().children[0].borrow().value); // 20
}

Cell<T> — Copy-Type Interior Mutability

Cell<T> is a lighter alternative to RefCell that works only with Copy types. Instead of giving out references (which would require borrow tracking), Cell copies values in and out. There is no runtime borrow check and no possibility of panic — the restriction to Copy types is what makes it safe.

use std::cell::Cell;

struct Counter {
    count: Cell<u32>,  // mutable even through a shared reference
}

impl Counter {
    fn new() -> Self { Self { count: Cell::new(0) } }

    // Takes &self (shared reference) but still mutates count — no &mut self needed
    fn increment(&self) {
        self.count.set(self.count.get() + 1);
    }

    fn get(&self) -> u32 {
        self.count.get()
    }
}

fn main() {
    let counter = Counter::new();
    counter.increment();
    counter.increment();
    counter.increment();
    println!("count: {}", counter.get()); // 3
}

Smart Pointer Comparison

TypeOwnershipThread-safeMutationUse case
Box<T>UniqueYesNormal &mutHeap alloc, trait objects, recursive types
Rc<T>SharedNoImmutable by defaultSingle-threaded shared ownership
Arc<T>SharedYesImmutable by defaultMulti-threaded shared ownership
RefCell<T>UniqueNoInterior (runtime check)Mutation through &T, single-threaded
Cell<T>UniqueNoInterior (Copy only)Simple counters/flags through &T
Rc<RefCell<T>>SharedNoInterior (runtime check)Single-threaded shared mutable state
Arc<Mutex<T>>SharedYesInterior (blocking)Multi-threaded shared mutable state

Frequently Asked Questions

When should I use Box<T>?
Use Box<T> when you need to allocate a value on the heap, store a trait object (dyn Trait), or build a recursive data structure where the size cannot be known at compile time.
What is the difference between Rc<T> and Arc<T>?
Both provide shared ownership via reference counting. Rc<T> uses non-atomic counting and is faster but not thread-safe. Arc<T> uses atomic operations and can be sent across threads. Prefer Rc<T> in single-threaded code and Arc<T> in multi-threaded code.
What is interior mutability?
Interior mutability is a pattern that allows mutation through a shared reference (&T). RefCell<T> enables this by moving borrow checking from compile time to runtime. It panics if the rules are violated at runtime.