Skip to Content
Course content

54: Smart Pointers: Box, Rc, and RefCell

Click on the "Edit" button in the top corner of the screen to edit your slide content.

Up until now, we've leaned heavily on Rust's ownership rules: one owner, one mutable reference, or many immutable references. For 90% of your code, that's perfect. But eventually, you'll hit a wall where the compiler tells you that your data structure is "too large" or that you can't have two things owning the same piece of memory. This is where smart pointers come in. They aren't just pointers; they're structs that manage memory for you using the Drop trait.

The problem with recursive sizes and why Box is the escape hatch

Imagine you're building a simple linked list or a tree of categories for a file system. You might try to define a Category struct that contains a list of sub-categories. If you try to put a Category directly inside another Category, the compiler will scream at you. Why? Because it can't calculate the size of the struct at compile time—it's like a Russian nesting doll that never ends.

// This will fail to compile
struct Category {
    name: String,
    sub_category: Option<Category>, 
}

The "naive" mistake is trying to fight the type system by making everything optional or trying to use raw pointers. The right way is to use Box<T>. A Box is the simplest smart pointer; it just puts the data on the heap. Because the Box itself is just a pointer, it has a fixed size regardless of what it's pointing to. By wrapping our sub-category in a Box, we tell Rust: "The category is somewhere else on the heap; just keep the address here."

struct Category {
    name: String,
    sub_category: Option<Box<Category>>, 
}

I usually think of Box as the tool for "indirection." Use it when you have a recursive type or when you have a massive struct that you don't want to keep copying around the stack.

When one owner isn't enough: Entering the world of Rc

Now, let's say you're building a graph of game entities. You have a Player and an Enemy, and both of them need to reference a shared Team object to check for friendly fire. If you use Box, only one of them can own the Team. If you try to pass a reference, you'll run into lifetime nightmares that make you want to quit programming for the day.

This is where Rc<T> (Reference Counted) comes in. Unlike Box, Rc allows multiple ownership. It keeps a counter of how many people are holding a pointer to the data. When the counter hits zero, the data is finally cleaned up. It's a bit like a library book: multiple people can have a record of it, but the book only leaves the library when nobody is using it anymore.

use std::rc::Rc;

struct Team {
    name: String,
}

let shared_team = Rc::new(Team { name: "Red Team".to_string() });
let player_ref = Rc::clone(&shared_team);
let enemy_ref = Rc::clone(&shared_team);
// Now both player_ref and enemy_ref point to the same Team on the heap.

The catch? Rc pointers are immutable. You can have a hundred Rc pointers to that Team, but none of them can change the team's name. If you try to mutate the data inside an Rc, the compiler will shut you down immediately. This is where most developers get stuck.

Breaking the rules with RefCell and Interior Mutability

So, what if you need multiple owners and you need to be able to mutate the data? You can't just wrap Rc in a mut variable because that only makes the pointer mutable, not the data it points to. This is where RefCell<T> comes in. It provides something called "interior mutability."

Basically, RefCell moves the borrowing rules from compile-time to runtime. Instead of the compiler checking if you have too many references, RefCell checks at runtime. If you try to borrow it mutably while someone else is already reading it, your program will panic. It's a trade-off: you lose some compile-time safety in exchange for flexibility.

In practice, we almost always see these two used together: Rc<RefCell<T>>. This pattern is the "gold standard" for shared, mutable state in a single-threaded Rust program.

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

struct Team {
    name: String,
}

let shared_team = Rc::new(RefCell::new(Team { name: "Red Team".to_string() }));

// We can borrow the interior mutably via .borrow_mut()
shared_team.borrow_mut().name = "Blue Team".to_string();

// Everyone holding an Rc now sees the updated name
println!("Team is now: {}", shared_team.borrow().name);

I'll be honest: using RefCell too much is often a sign that your architecture is a bit messy. If you find yourself wrapping every single struct in Rc<RefCell<T>>, you're basically writing Java in Rust. But for graphs, caches, and complex observers, it's an indispensable tool. Just remember: Box for heap/recursion, Rc for sharing, and RefCell for changing that shared data.




📋 Practical Task

Exercise: Implementing a Shared Configuration Registry

You are building a system where multiple components (a DatabaseConnector and an ApiClient) need to share a single AppConfig object. The configuration needs to be updateable at runtime (e.g., changing a timeout value) and those changes must be reflected across all components immediately.

Requirements:

  • Create a struct AppConfig with a field timeout: u32.
  • Create a struct DatabaseConnector and ApiClient that both hold a reference to the AppConfig.
  • Use Rc and RefCell to ensure both components share the same instance and can see updates.
  • In your main function:
    1. Initialize the AppConfig with a timeout of 30.
    2. Create both the connector and the client.
    3. Print the timeout from the DatabaseConnector.
    4. Update the timeout to 60 using the ApiClient.
    5. Print the timeout from the DatabaseConnector again to verify the change was reflected.
Rating
0 0

There are no comments for now.

to be the first to leave a comment.