When borrowing isn't enough
You are writing a function that calculates the distance between two points. You pass the points in. You don't want to copy the coordinates, and you definitely don't want to hand over ownership of the points to the function, because you need them back afterward. You pass a reference. The function borrows the data, does the math, and returns. Simple.
Now imagine a scene graph for a 2D game. A tree node has children. A leaf node might be referenced by two different branches. Or a UI component is shared between a menu and a tooltip. A reference can't handle this. References require a single owner. When multiple parts of your program need to point to the same heap data without a clear hierarchy, references hit a wall. That's where smart pointers step in.
References: Zero-cost views
A reference (&T) is a borrowed view of data. It points to memory owned by someone else. The compiler tracks how long that reference lives to ensure it never points to garbage. A reference has zero overhead. It's just a memory address. The compiler optimizes references away completely. There is no runtime cost to creating or using a reference.
References enforce the borrow checker rules. You can have many immutable references or one mutable reference, but not both at the same time. This prevents data races and dangling pointers at compile time. References are the default way to pass data around in Rust. They keep your code fast and safe.
Smart pointers: Ownership with behavior
A smart pointer is a struct that wraps a pointer and adds logic. It owns the data it points to. It implements traits like Deref to let you use it like a reference, and Drop to clean up memory automatically. Common smart pointers include Box<T>, Rc<T>, and Vec<T>.
Vec<T> is a smart pointer to a dynamic array. It manages a contiguous block of memory and resizes as you push elements. Box<T> is a smart pointer to a single value on the heap. It transfers ownership and allocates memory. Rc<T> is a smart pointer that supports multiple owners. It uses reference counting to keep data alive as long as anyone is using it.
Smart pointers solve problems that references cannot. They allow heap allocation, shared ownership, recursive types, and trait objects. They also add runtime overhead. Every smart pointer involves indirection. Accessing data through a smart pointer requires following a pointer, which can hurt cache performance. Use smart pointers only when you need the behavior they provide.
Minimal comparison
fn main() {
// x owns the value 5 on the stack.
let x = 5;
// y is a reference. It borrows x.
// y does not own the data. If x is dropped, y becomes invalid.
// No allocation happens. y is just an address.
let y = &x;
// z is a Box<T>. It allocates memory on the heap.
// z owns the pointer and the data behind it.
// When z goes out of scope, the heap memory is freed.
let z = Box::new(5);
// Access data through the reference.
println!("Reference value: {}", y);
// Access data through the Box.
// Deref coercion lets us read z.value directly.
println!("Box value: {}", z);
}
How smart pointers work under the hood
Smart pointers rely on two key traits: Deref and Drop.
Deref allows a smart pointer to behave like a reference. When you write box.value, the compiler automatically inserts a dereference operation. This is called deref coercion. It makes smart pointers ergonomic. You don't have to write (*box).value everywhere. The compiler handles the indirection for you.
Drop runs code when a value goes out of scope. Box uses Drop to free heap memory. When a Box variable leaves scope, the Drop implementation calls the deallocator. This automatic cleanup is why smart pointers are safe. You don't need free or delete. The compiler guarantees that Drop runs exactly once when the last owner goes away.
You can implement Drop on your own structs to create custom smart pointers. For example, a File wrapper can implement Drop to close the file handle automatically. A database connection pool can implement Drop to return connections to the pool. Smart pointers are a pattern, not just a set of library types.
Real-world patterns
Smart pointers appear in specific patterns where references fall short.
Recursive data structures
References cannot express recursive types. If a struct contains a reference to itself, the size would be infinite. A Box breaks the cycle by providing a fixed-size pointer.
/// A linked list node using Box for recursive structure.
/// References cannot express this because the size would be infinite.
struct Node {
value: i32,
// Box gives a fixed size pointer to the next node.
next: Option<Box<Node>>,
}
fn main() {
// Create the last node.
let tail = Box::new(Node {
value: 3,
next: None,
});
// Chain nodes together. Each Box owns the next node.
let middle = Box::new(Node {
value: 2,
next: Some(tail), // Move tail into the Option.
});
let head = Box::new(Node {
value: 1,
next: Some(middle),
});
// Access data through Deref coercion.
// head is a Box<Node>, but we can read head.value directly.
println!("Head value: {}", head.value);
}
Trait objects
When you want to return a trait from a function, you need a smart pointer. Traits can be implemented by types of different sizes. The compiler doesn't know the size of the concrete type at compile time. A Box<dyn Trait> allocates the concrete type on the heap and returns a pointer to the trait interface.
/// A trait for shapes that can calculate their area.
trait Shape {
fn area(&self) -> f64;
}
struct Circle {
radius: f64,
}
impl Shape for Circle {
fn area(&self) -> f64 {
3.14 * self.radius * self.radius
}
}
/// Returns a trait object.
/// The return type is Box<dyn Shape>, which has a known size.
/// The concrete Circle is allocated on the heap.
fn get_shape() -> Box<dyn Shape> {
Box::new(Circle { radius: 1.0 })
}
fn main() {
// shape is a Box<dyn Shape>.
// We can call trait methods through the box.
let shape = get_shape();
println!("Area: {}", shape.area());
}
In the Rust community, Box<dyn Trait> is the standard way to return a trait object. It allocates the concrete type and returns a pointer to the trait interface. This keeps the API flexible without exposing implementation details. References to trait objects (&dyn Trait) exist, but they require the data to be owned by someone else. Box takes ownership and manages the lifetime.
Shared ownership
When multiple parts of your program need to read the same data, Rc<T> provides shared ownership. It uses reference counting to track how many owners exist. When the count drops to zero, the data is freed.
use std::rc::Rc;
fn main() {
// Create an Rc with a String.
// The String is on the heap. The Rc holds a pointer and a count.
let data = Rc::new(String::from("Shared data"));
// Clone the Rc. This bumps the reference count.
// It does not copy the String.
// Convention: use Rc::clone to signal a cheap clone.
let data2 = Rc::clone(&data);
// Both Rc instances point to the same data.
println!("data: {}", data);
println!("data2: {}", data2);
// When data and data2 go out of scope, the count drops.
// The String is freed when the count hits zero.
}
Convention aside: The community prefers Rc::clone(&data) over data.clone(). Both compile and work identically. The explicit form signals to readers that this is a cheap reference count bump, not a deep copy. It prevents confusion when scanning code for expensive operations.
Pitfalls and compiler errors
Smart pointers solve problems, but they introduce new constraints.
Over-allocation
Smart pointers allocate memory. References do not. If you wrap every integer in a Box, your program slows down and fragments memory. Allocation is expensive. It requires calling the allocator, which can block other threads. Indirection hurts cache locality. The CPU has to fetch data from random addresses instead of contiguous memory.
Use references for temporary access. Use Box only when you need heap allocation, a recursive type, or a trait object. Measure performance before adding smart pointers.
Borrow checker conflicts
Smart pointers participate in the borrow checker. They don't bypass it. If you hold a mutable reference to the data inside a Box, you cannot create another reference while the mutable one is active.
fn main() {
let mut box_val = Box::new(5);
// Create a mutable reference to the data inside the Box.
let mut_ref = &mut *box_val;
// This fails. The mutable borrow is still active.
// The compiler rejects this with E0502.
// let imm_ref = &*box_val; // Error: cannot borrow as immutable
}
The compiler rejects this with E0502 (cannot borrow as mutable because it is also borrowed as immutable). The smart pointer doesn't give you special permissions. You still have to follow the borrowing rules.
Moving out of references
You cannot move data out of a reference. A reference only grants access, not ownership. If you try to dereference a reference and move the value, the compiler stops you.
fn main() {
let x = String::from("Hello");
let y = &x;
// This fails. You cannot move the String out of a reference.
// The compiler rejects this with E0507.
// let z = *y; // Error: cannot move out of borrowed content
}
The compiler rejects this with E0507 (cannot move out of borrowed content). You can copy the value if the type implements Copy, but you cannot take ownership through a reference. Smart pointers own the data, so you can move them freely. A Box<T> can be moved, cloned (if T implements Clone), or dropped.
Memory leaks with Rc
Rc<T> can create memory leaks if you have cycles. If Node A owns Node B, and Node B owns Node A, the reference counts never drop to zero. The memory is never freed. This is a runtime leak, not a compile-time error.
Use Weak<T> to break cycles. Weak references do not increment the reference count. They allow you to observe data without keeping it alive. Graph structures often use Rc for parent-to-child links and Weak for child-to-parent links.
Decision matrix
Use a reference (&T) when you need temporary access to data owned by a variable in the current scope. Use a reference when the data fits on the stack and you don't need to extend its lifetime beyond the current block. Use a reference for function arguments that only read or modify data without taking ownership.
Use Box<T> when you need to allocate data on the heap because the size is too large for the stack or unknown at compile time. Use Box<T> when you need a recursive data structure like a linked list or a tree, where a reference would create an infinite size. Use Box<dyn Trait> when you want to return a trait object from a function or store heterogeneous types in a collection.
Use Rc<T> when multiple parts of your program need to read the same data and there is no clear single owner. Use Rc<T> for graph structures where nodes share children. Use Rc<RefCell<T>> when you need shared ownership plus interior mutability, allowing you to modify data through shared references while maintaining runtime borrow checking.
Use Vec<T> when you need a growable array. Vec is a smart pointer that manages a contiguous block of memory and resizes as you push elements. Use Vec for collections that change size at runtime.