Skip to content

Ch.10 — Smart Pointers

TypeScript has only one kind of reference. Every object lives on the heap, and the GC manages it all automatically.

Rust is different. It tracks exactly what owns data and how long references can live at compile time. Smart pointers are types that enable special ownership patterns on top of this system.

There are three situations that a plain reference &T cannot handle:

SituationProblemSolution
Types whose size is unknown at compile time&T requires a known sizeBox<T>
Reading shared data from multiple placesOwnership can only have one ownerRc<T>
Safe sharing across multiple threadsRc<T> is not thread-safeArc<T>
Deciding mutability at runtime rather than compile timeBorrow checker rules apply at compile timeRefCell<T>

Box<T> — Storing a Single Value on the Heap

Section titled “Box<T> — Storing a Single Value on the Heap”

Box<T> is the simplest smart pointer in Rust. It places a value on the heap while the Box itself lives on the stack.

fn main() {
let x = 5; // stack
let y = Box::new(5); // stores 5 on the heap, y is a pointer on the stack
println!("{}", x); // 5
println!("{}", y); // 5 (auto-dereferenced)
println!("{}", *y + 1); // 6
}

In TypeScript, recursive types come naturally:

type List = {
value: number;
next: List | null;
};

Attempting the same in Rust:

// Compile error!
enum List {
Cons(i32, List), // size of List is unknown
Nil,
}

When the compiler tries to calculate the size of List, it finds another List inside, leading to infinite size. Wrapping it in a Box puts it on the heap, fixing the size to a pointer width (8 bytes):

enum List {
Cons(i32, Box<List>), // fixed to pointer size
Nil,
}
fn main() {
let list = List::Cons(1,
Box::new(List::Cons(2,
Box::new(List::Cons(3,
Box::new(List::Nil))))));
}

Another key use of Box is dynamic dispatch. It is similar to TypeScript interfaces, but in Rust, since sizes must be known at compile time, Box is required:

// TypeScript
interface Shape {
area(): number;
}
function printArea(shape: Shape) {
console.log(shape.area());
}
trait Shape {
fn area(&self) -> f64;
}
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 }
}
impl Shape for Rectangle {
fn area(&self) -> f64 { self.width * self.height }
}
// Box<dyn Shape>: can hold any type that implements Shape
fn print_area(shape: &Box<dyn Shape>) {
println!("Area: {:.2}", shape.area());
}
fn main() {
let shapes: Vec<Box<dyn Shape>> = vec![
Box::new(Circle { radius: 3.0 }),
Box::new(Rectangle { width: 4.0, height: 5.0 }),
];
for shape in &shapes {
print_area(shape);
}
}

Rc<T> — Shared Ownership in a Single Thread

Section titled “Rc<T> — Shared Ownership in a Single Thread”

Rc stands for Reference Counting. It works the same way Python and Swift manage memory — it counts references and frees memory when the count reaches zero.

use std::rc::Rc;
fn main() {
let data = Rc::new(vec![1, 2, 3]);
let a = Rc::clone(&data); // reference count: 2
let b = Rc::clone(&data); // reference count: 3
println!("Reference count: {}", Rc::strong_count(&data)); // 3
println!("data: {:?}", data);
println!("a: {:?}", a);
println!("b: {:?}", b);
drop(a); // reference count: 2
drop(b); // reference count: 1
println!("Remaining references: {}", Rc::strong_count(&data)); // 1
} // data dropped → reference count: 0 → memory freed

Rc’s Limitation: Immutable References Only

Section titled “Rc’s Limitation: Immutable References Only”

Data wrapped in Rc<T> is immutable. Because multiple references exist, simultaneous mutation would be unsafe. If mutation is also needed, use RefCell together with Rc.


Rust’s borrow checker operates at compile time. But sometimes you only know at runtime whether a particular borrow is safe.

RefCell<T> defers borrow rules to runtime. Violating the rules causes a runtime panic instead of a compile error.

use std::cell::RefCell;
fn main() {
let data = RefCell::new(vec![1, 2, 3]);
// borrow(): immutable reference (like &)
{
let r1 = data.borrow();
let r2 = data.borrow(); // multiple immutable references at once are OK
println!("{:?}, {:?}", r1, r2);
} // r1, r2 dropped
// borrow_mut(): mutable reference (like &mut)
{
let mut w = data.borrow_mut();
w.push(4);
} // w dropped
println!("{:?}", data.borrow()); // [1, 2, 3, 4]
}
use std::cell::RefCell;
fn main() {
let data = RefCell::new(5);
let r1 = data.borrow(); // immutable reference
let r2 = data.borrow_mut(); // ⚠️ panic! already borrowed immutably
}

Rc<RefCell<T>> — Shared + Mutable Pattern

Section titled “Rc<RefCell<T>> — Shared + Mutable Pattern”

The core pattern for “shared from multiple places while also allowing mutation” in a single thread.

use std::rc::Rc;
use std::cell::RefCell;
#[derive(Debug)]
struct Node {
value: i32,
children: Vec<Rc<RefCell<Node>>>,
}
fn main() {
let root = Rc::new(RefCell::new(Node {
value: 1,
children: vec![],
}));
let child = Rc::new(RefCell::new(Node {
value: 2,
children: vec![],
}));
// shared from two places while mutating
root.borrow_mut().children.push(Rc::clone(&child));
let root_ref = Rc::clone(&root);
println!("root: {:?}", root_ref.borrow());
}

The TypeScript equivalent:

// TypeScript: plain object references handle this naturally
const root = { value: 1, children: [] };
const child = { value: 2, children: [] };
const rootRef = root; // same object reference
root.children.push(child);
console.log(rootRef); // mutation is visible

Because of Rust’s ownership and borrow rules, the explicit Rc<RefCell<T>> pattern is necessary.


The thread-safe version of Rc<T>. While Rc increments its count with plain operations, Arc uses atomic operations.

use std::sync::Arc;
use std::thread;
fn main() {
let data = Arc::new(vec![1, 2, 3, 4, 5]);
let mut handles = vec![];
for i in 0..3 {
let data = Arc::clone(&data); // clone for each thread
let handle = thread::spawn(move || {
println!("Thread {}: {:?}", i, data);
});
handles.push(handle);
}
for handle in handles {
handle.join().unwrap();
}
}

Arc + Mutex: Shared Mutation Across Threads

Section titled “Arc + Mutex: Shared Mutation Across Threads”

RefCell is not thread-safe. When you need shared mutation across threads, use Mutex:

use std::sync::{Arc, Mutex};
use std::thread;
fn main() {
let counter = Arc::new(Mutex::new(0));
let mut handles = vec![];
for _ in 0..5 {
let counter = Arc::clone(&counter);
let handle = thread::spawn(move || {
let mut num = counter.lock().unwrap(); // acquire lock
*num += 1;
}); // lock automatically released
handles.push(handle);
}
for handle in handles {
handle.join().unwrap();
}
println!("Result: {}", *counter.lock().unwrap()); // 5
}

TypeOwnersThread-SafeMutableWhen to Use
T1-Basic ownership
&T-Short-lived read reference
&mut T-Short-lived mutable reference
Box<T>1-Heap allocation, recursive types
Rc<T>ManySingle-thread shared ownership
Rc<RefCell<T>>ManySingle-thread shared + mutable
Arc<T>ManyMultithreaded shared ownership
Arc<Mutex<T>>ManyMultithreaded shared + mutable
Do you need to share data from multiple places?
├── No → Plain ownership or &T reference
└── Yes
├── Across multiple threads?
│ ├── Need mutation? → Arc<Mutex<T>>
│ └── Read only? → Arc<T>
└── Single thread?
├── Need mutation? → Rc<RefCell<T>>
└── Read only? → Rc<T>
  • Box<T>: Put on the heap, recursive types, dyn Trait
  • Rc<T>: Shared read access in a single thread
  • RefCell<T>: Interior mutability with runtime borrow checking
  • Rc<RefCell<T>>: Single-thread shared + mutable
  • Arc<T>: Shared read access across threads
  • Arc<Mutex<T>>: Multithreaded shared + mutable