Rust · lesson 23 of 30
Beginner
15- 1 Introduction to Rust
- 2 Setting Up Rust
- 3 Variables in Rust
- 4 Data Types in Rust
- 5 Operators in Rust
- 6 Control Flow in Rust
- 7 Functions in Rust
- 8 Ownership in Rust
- 9 Borrowing and References
- 11 Structs in Rust
- 12 Enums in Rust
- 13 Pattern Matching in Rust
- 17 Collections in Rust
- 25 Testing in Rust
- 26 File I/O in Rust
Intermediate
9Advanced
6Smart 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
| Type | Ownership | Thread-safe | Mutation | Use case |
|---|---|---|---|---|
Box<T> | Unique | Yes | Normal &mut | Heap alloc, trait objects, recursive types |
Rc<T> | Shared | No | Immutable by default | Single-threaded shared ownership |
Arc<T> | Shared | Yes | Immutable by default | Multi-threaded shared ownership |
RefCell<T> | Unique | No | Interior (runtime check) | Mutation through &T, single-threaded |
Cell<T> | Unique | No | Interior (Copy only) | Simple counters/flags through &T |
Rc<RefCell<T>> | Shared | No | Interior (runtime check) | Single-threaded shared mutable state |
Arc<Mutex<T>> | Shared | Yes | Interior (blocking) | Multi-threaded shared mutable state |
Practice
-
In the
Arcexample, replaceArcwithRc(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::spawnrequires its closure to beSend.Rc’s count is a plain integer, so two threads cloning at once could corrupt it; the compiler refuses before that can happen. -
Rewrite the panicking
RefCellexample to usedata.try_borrow_mut()and print the error instead of crashing. Thendrop(shared)and push4successfully.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] -
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, thenoutside: 1. Cloningbinstead ofamakes no difference — every handle points at the same allocation and shares one counter.bandcare dropped at the inner}. -
Add a
len(list: &List) -> usizefunction to the recursiveListexample, alongsidesum.Solution
fn len(list: &List) -> usize { match list { List::Cons(_, rest) => 1 + len(rest), List::Nil => 0, } }len(&list)returns3for the 1 → 2 → 3 list. -
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::getcopies the value out, so it only exists whenT: Copy. For non-Copydata useRefCell, orCell::replace/Cell::take, which move the value out instead of copying it.