Skip to main content
Rust · lesson 23 of 30
Rust intermediate Lesson 23 of 30

Smart Pointers in Rust

Understand Rust smart pointers — Box, Rc, Arc, RefCell and Cell — with real output, including the RefCell double-borrow panic and Rc's Send error.

By SkillByExample · Updated

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()
}
$ rustc smart_pointers_box_t_heap_allocation.rs && ./smart_pointers_box_t_heap_allocation
b = 5

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:

enum List {
    Cons(i32, List),
    Nil,
}

fn main() {
    let _list = List::Nil;
}
$ rustc smart_pointers_recursive_types_with_box.rs
error[E0072]: recursive type `List` has infinite size
 --> smart_pointers_recursive_types_with_box.rs:1:1
  |
1 | enum List {
  | ^^^^^^^^^
2 |     Cons(i32, List),
  |               ---- recursive without indirection
  |
help: insert some indirection (e.g., a `Box`, `Rc`, or `&`) to break the cycle
  |
2 |     Cons(i32, Box<List>),
  |               ++++    +

The compiler’s own suggestion is the fix: wrap the recursive field in Box, which gives it a known, fixed size (one pointer).

#[derive(Debug)]
enum List {
    Cons(i32, Box<List>),  // Box gives the recursive field a known size
    Nil,
}

fn sum(list: &List) -> i32 {
    match list {
        List::Cons(value, rest) => value + sum(rest),
        List::Nil => 0,
    }
}

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);
    println!("sum = {}", sum(&list));
    println!("size_of::<List>() = {} bytes", std::mem::size_of::<List>());
}
$ rustc smart_pointers_recursive_types_with_box_2.rs && ./smart_pointers_recursive_types_with_box_2
Cons(1, Cons(2, Cons(3, Nil)))
sum = 6
size_of::<List>() = 16 bytes

{:?} shows the nesting the Boxes hide, and sum walks it recursively — rest is a &Box<List>, which derefs to &List automatically. The whole enum is 16 bytes regardless of how long the list is: an i32, a pointer, and the tag that tells Cons from Nil.

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());
    }
}
$ rustc smart_pointers_trait_objects_with_box.rs && ./smart_pointers_trait_objects_with_box
circle: area = 28.27
rectangle: area = 20.00
circle: area = 7.07

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));

    println!("{}", value);
    println!("{}", handle1); // same allocation
    println!("{}", handle2); // same allocation
    println!("same allocation? {}", Rc::ptr_eq(&value, &handle2));

    drop(handle1);
    println!("after drop: {}", Rc::strong_count(&value));
    // value is dropped when the last Rc handle goes out of scope
}
$ rustc smart_pointers_rc_t_shared_ownership_single_threaded.rs && ./smart_pointers_rc_t_shared_ownership_single_threaded
reference count: 3
shared data
shared data
shared data
same allocation? true
after drop: 2

Three handles, one String: Rc::ptr_eq confirms value and handle2 point at the same allocation, and dropping one handle only decrements the count.

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
}
$ rustc smart_pointers_arc_t_shared_ownership_multi_threaded.rs && ./smart_pointers_arc_t_shared_ownership_multi_threaded
sum: 15
sum: 15
sum: 15

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());
}
$ rustc smart_pointers_refcell_t_interior_mutability.rs && ./smart_pointers_refcell_t_interior_mutability
[1, 2, 3]
[1, 2, 3, 4]

Break the rule and the program still compiles — the check happens at runtime:

use std::cell::RefCell;

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

    let shared = data.borrow();
    println!("shared borrow active: {:?}", shared);

    let mut mutable = data.borrow_mut(); // a shared borrow is still alive
    mutable.push(4);
}
$ rustc smart_pointers_refcell_t_interior_mutability_2.rs && ./smart_pointers_refcell_t_interior_mutability_2
shared borrow active: [1, 2, 3]

thread 'main' (15340) panicked at smart_pointers_refcell_t_interior_mutability_2.rs:9:28:
RefCell already borrowed
note: run with `RUST_BACKTRACE=1` environment variable to display a backtrace

The first println! runs, then borrow_mut panics because shared is still alive. (The number after 'main' is the OS thread ID — it changes on every run.) The same two lines on a plain Vec (&v then &mut v) would be a compile error; RefCell trades that error for a panic. Use try_borrow_mut() when you’d rather get a Result than a crash.

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());

    // 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);
    println!("child1 strong count: {}", Rc::strong_count(&child1));
}
$ rustc smart_pointers_rc_refcell_t_shared_mutable_state.rs && ./smart_pointers_rc_refcell_t_shared_mutable_state
root value: 1
children: 2
child1 value via root: 20
child1 strong count: 2

The write went through child1, the read went through root — and they agree, because both handles lead to the same RefCell<Node>. The strong count of 2 is child1 itself plus the clone stored in root.children.

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());
}
$ rustc smart_pointers_cell_t_copy_type_interior_mutability.rs && ./smart_pointers_cell_t_copy_type_interior_mutability
count: 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

Practice

  1. In the Arc example, replace Arc with Rc (use std::rc::Rc;, Rc::new, Rc::clone). What does the compiler say, and which trait is missing?

    Solution
    error[E0277]: `Rc<Vec<i32>>` cannot be sent between threads safely
    ...
      = help: within `{closure@...}`, the trait `Send` is not implemented for `Rc<Vec<i32>>`

    thread::spawn requires its closure to be Send. Rc’s count is a plain integer, so two threads cloning at once could corrupt it; the compiler refuses before that can happen.

  2. Rewrite the panicking RefCell example to use data.try_borrow_mut() and print the error instead of crashing. Then drop(shared) and push 4 successfully.

    Solution
    use std::cell::RefCell;
    
    fn main() {
        let data = RefCell::new(vec![1, 2, 3]);
    
        let shared = data.borrow();
        match data.try_borrow_mut() {
            Ok(mut v) => v.push(4),
            Err(e) => println!("could not borrow mutably: {}", e),
        }
        drop(shared);
    
        data.borrow_mut().push(4);
        println!("{:?}", data.borrow());
    }
    could not borrow mutably: RefCell already borrowed
    [1, 2, 3, 4]
  3. Predict both counts, then run it:

    use std::rc::Rc;
    
    fn main() {
        let a = Rc::new(5);
        {
            let b = Rc::clone(&a);
            let c = Rc::clone(&b);
            println!("inside: {}", Rc::strong_count(&c));
        }
        println!("outside: {}", Rc::strong_count(&a));
    }
    Solution

    inside: 3, then outside: 1. Cloning b instead of a makes no difference — every handle points at the same allocation and shares one counter. b and c are dropped at the inner }.

  4. Add a len(list: &List) -> usize function to the recursive List example, alongside sum.

    Solution
    fn len(list: &List) -> usize {
        match list {
            List::Cons(_, rest) => 1 + len(rest),
            List::Nil => 0,
        }
    }

    len(&list) returns 3 for the 1 → 2 → 3 list.

  5. Try Cell::new(String::from("a")) and call .get() on it. Why doesn’t it work?

    Solution
    error[E0599]: the method `get` exists for struct `Cell<String>`, but its trait bounds were not satisfied
    ...
              `String: Copy`

    Cell::get copies the value out, so it only exists when T: Copy. For non-Copy data use RefCell, or Cell::replace/Cell::take, which move the value out instead of copying it.

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.