Skip to lesson

learningrust.org / advanced / 19-smart-pointers · lesson 19 of 26

TL;DR

Understand Box, Rc, RefCell, and interior mutability patterns in Rust

Key concepts

  • Rust smart pointers
  • Rust Box Rc Arc
  • Rust RefCell
  • Rust interior mutability
  • Rust heap allocation

Smart Pointers in Rust

Smart pointers are data structures that act like pointers but also carry additional metadata and capabilities. Unlike regular references (which only borrow data), smart pointers often own the data they point to. You have already used some smart pointers without knowing it: String and Vec<T> are both smart pointers that own heap-allocated data. In this lesson, we explore the key smart pointer types in Rust's standard library: Box, Rc, and RefCell.

Box<T>: Heap Allocation

Box<T> is the simplest smart pointer. It allocates data on the heap and stores a pointer to it on the stack. When the Box goes out of scope, both the pointer and the heap data are cleaned up. Use Box when you need a value with a known size at compile time but the type itself is dynamically sized, or when you want to transfer ownership without copying large amounts of data:

fn main() {
    // Simple heap allocation
    let boxed_number = Box::new(42);
    println!("Boxed number: {}", boxed_number);

    // Box is useful for large data -- moves the pointer, not the data
    let large_array = Box::new([0u8; 1000]);
    println!("Large array first element: {}", large_array[0]);

    // Recursive types require Box because the size would be infinite otherwise
    #[derive(Debug)]
    enum List {
        Cons(i32, Box<List>),
        Nil,
    }

    let list = List::Cons(1,
        Box::new(List::Cons(2,
            Box::new(List::Cons(3,
                Box::new(List::Nil))))));

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

The Deref Trait

The Deref trait lets a smart pointer behave like a regular reference. When you implement Deref, the * operator and automatic deref coercion work with your type. This is what makes Box<T> transparent to use -- you can call methods on the inner value without explicit dereferencing in most cases:

use std::ops::Deref;

struct MyBox<T>(T);

impl<T> MyBox<T> {
    fn new(x: T) -> MyBox<T> {
        MyBox(x)
    }
}

impl<T> Deref for MyBox<T> {
    type Target = T;

    fn deref(&self) -> &T {
        &self.0
    }
}

fn greet(name: &str) {
    println!("Hello, {}!", name);
}

fn main() {
    let x = 5;
    let y = MyBox::new(x);

    assert_eq!(5, *y); // Deref lets us use *
    println!("Value in MyBox: {}", *y);

    // Deref coercion: MyBox<String> -> &String -> &str
    let name = MyBox::new(String::from("Rust"));
    greet(&name); // Automatic deref coercion chain
}

The Drop Trait

The Drop trait lets you customize what happens when a value goes out of scope. This is Rust's equivalent of a destructor. Smart pointers use Drop to clean up heap memory, close files, release locks, and more:

struct DatabaseConnection {
    name: String,
}

impl DatabaseConnection {
    fn new(name: &str) -> Self {
        println!("[{}] Connection opened", name);
        DatabaseConnection {
            name: String::from(name),
        }
    }

    fn query(&self, sql: &str) {
        println!("[{}] Executing: {}", self.name, sql);
    }
}

impl Drop for DatabaseConnection {
    fn drop(&mut self) {
        println!("[{}] Connection closed and resources freed", self.name);
    }
}

fn main() {
    let conn1 = DatabaseConnection::new("primary");
    let conn2 = DatabaseConnection::new("replica");

    conn1.query("SELECT * FROM users");
    conn2.query("SELECT * FROM orders");

    // You can drop a value early with std::mem::drop
    drop(conn2);
    println!("conn2 has been dropped early");

    conn1.query("SELECT count(*) FROM users");
    println!("End of main -- conn1 will be dropped now");
}

Rc<T>: Reference Counted Pointers

Sometimes a value needs to have multiple owners -- for example, in a graph where multiple edges point to the same node. Rc<T> (Reference Counted) enables shared ownership by tracking the number of references. When the last Rc is dropped, the value is cleaned up. Note that Rc<T> is for single-threaded scenarios only:

use std::rc::Rc;

fn main() {
    // Shared ownership with Rc
    let shared_data = Rc::new(vec![1, 2, 3, 4, 5]);

    let reference1 = Rc::clone(&shared_data);
    let reference2 = Rc::clone(&shared_data);

    println!("Original: {:?}", shared_data);
    println!("Ref 1: {:?}", reference1);
    println!("Ref 2: {:?}", reference2);
    println!("Reference count: {}", Rc::strong_count(&shared_data));

    // Drop one reference
    drop(reference1);
    println!("After dropping ref1, count: {}", Rc::strong_count(&shared_data));

    // Practical example: shared configuration
    let config = Rc::new(String::from("production"));

    let service_a = Rc::clone(&config);
    let service_b = Rc::clone(&config);

    println!("Service A config: {}", service_a);
    println!("Service B config: {}", service_b);
    println!("Config ref count: {}", Rc::strong_count(&config));
}

RefCell<T>: Interior Mutability

Rust normally enforces borrowing rules at compile time. RefCell<T> moves these checks to runtime, allowing you to mutate data even when there are immutable references to the RefCell. This pattern is called interior mutability. If you violate the borrowing rules at runtime, the program will panic instead of producing undefined behavior:

use std::cell::RefCell;

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

    // Immutable borrow with .borrow()
    println!("Data: {:?}", data.borrow());

    // Mutable borrow with .borrow_mut()
    data.borrow_mut().push(4);
    data.borrow_mut().push(5);

    println!("After push: {:?}", data.borrow());

    // Multiple immutable borrows are fine
    let borrow1 = data.borrow();
    let borrow2 = data.borrow();
    println!("Two borrows: {:?} and {:?}", borrow1, borrow2);
    // Must drop these before a mutable borrow
    drop(borrow1);
    drop(borrow2);

    data.borrow_mut().retain(|&x| x > 2);
    println!("After filter: {:?}", data.borrow());
}

Combining Rc and RefCell

The most powerful pattern is Rc<RefCell<T>>, which gives you shared ownership with interior mutability. Multiple parts of your code can own the same data and mutate it:

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

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

impl Node {
    fn new(value: i32) -> Rc<RefCell<Node>> {
        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 print_tree(node: &Rc<RefCell<Node>>, depth: usize) {
    let node = node.borrow();
    let indent = "  ".repeat(depth);
    println!("{}Node({})", indent, node.value);
    for child in &node.children {
        print_tree(child, depth + 1);
    }
}

fn main() {
    let root = Node::new(1);
    let child_a = Node::new(2);
    let child_b = Node::new(3);
    let grandchild = Node::new(4);

    Node::add_child(&root, Rc::clone(&child_a));
    Node::add_child(&root, Rc::clone(&child_b));
    Node::add_child(&child_a, Rc::clone(&grandchild));

    // We can still mutate nodes through any reference
    child_b.borrow_mut().value = 30;
    grandchild.borrow_mut().value = 40;

    println!("Tree structure:");
    print_tree(&root, 0);

    // The grandchild is owned by both child_a's children vec and our local variable
    println!(
        "\nGrandchild ref count: {}",
        Rc::strong_count(&grandchild)
    );
}

Choosing the Right Smart Pointer

Each smart pointer serves a different purpose. Here is a practical comparison to help you decide which one to use:

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

fn main() {
    // Box<T>: single owner, heap allocation
    // Use when: recursive types, large data, or trait objects
    let boxed: Box<dyn std::fmt::Display> = Box::new("I'm a trait object");
    println!("Box: {}", boxed);

    // Rc<T>: multiple owners, immutable shared data (single-threaded)
    // Use when: multiple parts of code need to read the same data
    let shared = Rc::new(String::from("shared config"));
    let a = Rc::clone(&shared);
    let b = Rc::clone(&shared);
    println!("Rc: {} (refs: {})", a, Rc::strong_count(&shared));
    drop(a);
    drop(b);

    // RefCell<T>: single owner, runtime-checked mutable borrows
    // Use when: you need to mutate through an immutable reference
    let cell = RefCell::new(vec![1, 2, 3]);
    cell.borrow_mut().push(4);
    println!("RefCell: {:?}", cell.borrow());

    // Rc<RefCell<T>>: multiple owners with mutation (single-threaded)
    // Use when: shared mutable state in a single thread
    let shared_mut = Rc::new(RefCell::new(0));
    let counter1 = Rc::clone(&shared_mut);
    let counter2 = Rc::clone(&shared_mut);
    *counter1.borrow_mut() += 10;
    *counter2.borrow_mut() += 20;
    println!("Rc<RefCell>: {}", shared_mut.borrow());

    // For multi-threaded shared mutable state, use Arc<Mutex<T>> instead
    println!("\nSummary:");
    println!("  Box<T>       -> single owner, heap data");
    println!("  Rc<T>        -> shared owner, immutable data");
    println!("  RefCell<T>   -> single owner, interior mutability");
    println!("  Rc<RefCell>  -> shared owner, interior mutability");
    println!("  Arc<Mutex>   -> shared owner, thread-safe mutation");
}

Try It Yourself

Build a simple event log with shared ownership. Try adding more subscribers or different event types:

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

type EventLog = Rc<RefCell<Vec<String>>>;

struct Publisher {
    name: String,
    log: EventLog,
}

struct Subscriber {
    name: String,
    log: EventLog,
}

impl Publisher {
    fn new(name: &str, log: &EventLog) -> Self {
        Publisher {
            name: String::from(name),
            log: Rc::clone(log),
        }
    }

    fn publish(&self, event: &str) {
        let message = format!("[{}] {}", self.name, event);
        println!("Publishing: {}", message);
        self.log.borrow_mut().push(message);
    }
}

impl Subscriber {
    fn new(name: &str, log: &EventLog) -> Self {
        Subscriber {
            name: String::from(name),
            log: Rc::clone(log),
        }
    }

    fn read_events(&self) {
        let events = self.log.borrow();
        println!("\n{} reading {} events:", self.name, events.len());
        for event in events.iter() {
            println!("  {}", event);
        }
    }
}

fn main() {
    let log: EventLog = Rc::new(RefCell::new(Vec::new()));

    let publisher1 = Publisher::new("System", &log);
    let publisher2 = Publisher::new("User", &log);
    let subscriber1 = Subscriber::new("Dashboard", &log);
    let subscriber2 = Subscriber::new("Logger", &log);

    publisher1.publish("Server started");
    publisher2.publish("Login successful");
    publisher1.publish("Request processed");
    publisher2.publish("Settings updated");

    subscriber1.read_events();
    subscriber2.read_events();

    println!("\nTotal references to log: {}", Rc::strong_count(&log));
}

Key Takeaways

  • Box<T> provides simple heap allocation with single ownership and zero-cost abstraction
  • Rc<T> enables multiple ownership via reference counting, for single-threaded use only
  • RefCell<T> provides interior mutability by moving borrow checks from compile time to runtime
  • The Deref trait enables smart pointers to be used like regular references via automatic coercion
  • The Drop trait lets you customize cleanup behavior when a value goes out of scope
  • Rc<RefCell<T>> combines shared ownership with mutation -- useful for graphs and shared state
  • For multi-threaded scenarios, use Arc<Mutex<T>> instead of Rc<RefCell<T>>

Pro Tip: Reach for Box<T> first -- it is the simplest and most performant smart pointer. Only move to Rc<T> when you truly need shared ownership, and only add RefCell<T> when you need interior mutability. Each layer of indirection adds a small cost, so keep your pointer types as simple as your use case allows.

Next Steps

You now have a solid grasp of Rust's core concepts. Next, you'll learn how to write tests to verify your code works correctly using Rust's built-in testing framework.

Two-tier handoff: this document is the complete reading surface. Continue learning for stateful practice, progress, and real sandbox execution.