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 abstractionRc<T>enables multiple ownership via reference counting, for single-threaded use onlyRefCell<T>provides interior mutability by moving borrow checks from compile time to runtime- The
Dereftrait enables smart pointers to be used like regular references via automatic coercion - The
Droptrait 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 ofRc<RefCell<T>>
Pro Tip: Reach for
Box<T>first -- it is the simplest and most performant smart pointer. Only move toRc<T>when you truly need shared ownership, and only addRefCell<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.