Smart Pointers#
- Source:
Rust smart pointers provide different ownership and borrowing patterns beyond what
basic references offer. Unlike C++ where smart pointers are library types that can
be misused (e.g., creating cycles with shared_ptr), Rust’s ownership system
prevents most pointer-related bugs at compile time. Smart pointers in Rust implement
the Deref trait, allowing them to be used like regular references, and the Drop
trait for automatic cleanup.
Smart Pointer Comparison#
The following table maps C++ smart pointers to their Rust equivalents. Note that Rust
splits C++’s shared_ptr into two types: Rc for single-threaded code (faster,
no atomic operations) and Arc for multi-threaded code (thread-safe with atomic
reference counting):
C++ |
Rust |
|---|---|
|
|
|
|
|
|
N/A |
|
|
|
Box (Unique Ownership)#
Box<T> provides heap allocation with single ownership, similar to std::unique_ptr<T>.
When a Box goes out of scope, it automatically frees the heap memory. Unlike C++
where you might accidentally copy a unique_ptr (which is a compile error but easy
to attempt), Rust’s move semantics make ownership transfer explicit and safe. Box
is commonly used for recursive data structures, large data that shouldn’t be copied,
and trait objects.
C++:
#include <memory>
int main() {
auto ptr = std::make_unique<int>(42);
std::cout << *ptr;
// Transfer ownership
auto ptr2 = std::move(ptr);
// ptr is now null
}
Rust:
fn main() {
let boxed = Box::new(42);
println!("{}", *boxed);
// Transfer ownership
let boxed2 = boxed;
// boxed is no longer valid - compile error if used
}
Use Cases for Box#
Box is essential in several scenarios where stack allocation isn’t possible or
desirable. Recursive types like linked lists and trees require Box because the
compiler needs to know the size of types at compile time - without indirection, a
recursive type would have infinite size. Large data benefits from Box to avoid
expensive stack copies. Trait objects require Box (or another pointer type)
because different implementations may have different sizes:
// 1. Recursive types (unknown size at compile time)
enum List {
Cons(i32, Box<List>),
Nil,
}
// 2. Large data on heap
let large = Box::new([0u8; 1_000_000]);
// 3. Trait objects
let drawable: Box<dyn Draw> = Box::new(Circle { radius: 5.0 });
Rc (Reference Counting)#
Rc<T> (Reference Counted) enables shared ownership where multiple parts of your
code need to read the same data. It’s similar to std::shared_ptr<T> but is
explicitly single-threaded - attempting to send an Rc to another thread is a
compile error. This restriction allows Rc to use non-atomic reference counting,
making it faster than Arc when thread safety isn’t needed. The reference count
is incremented when you clone an Rc and decremented when an Rc is dropped.
C++:
#include <memory>
int main() {
auto shared = std::make_shared<int>(42);
auto shared2 = shared; // ref count = 2
std::cout << shared.use_count(); // 2
}
Rust:
use std::rc::Rc;
fn main() {
let shared = Rc::new(42);
let shared2 = Rc::clone(&shared); // ref count = 2
println!("{}", Rc::strong_count(&shared)); // 2
}
Rc with RefCell (Interior Mutability)#
Rc<T> only provides shared (immutable) access to its contents - you cannot get
a mutable reference through an Rc. When you need shared ownership with mutation,
combine Rc with RefCell. RefCell moves Rust’s borrow checking from compile
time to runtime: it tracks borrows dynamically and will panic if you violate the
borrowing rules (e.g., two simultaneous mutable borrows). This pattern is common for
graph structures, observer patterns, and caches:
use std::cell::RefCell;
use std::rc::Rc;
fn main() {
let shared = Rc::new(RefCell::new(42));
// Multiple owners
let shared2 = Rc::clone(&shared);
// Mutate through RefCell
*shared.borrow_mut() += 1;
println!("{}", shared2.borrow()); // 43
}
Arc (Atomic Reference Counting)#
Arc<T> (Atomically Reference Counted) is the thread-safe version of Rc<T>.
It uses atomic operations for reference counting, making it safe to share across
threads. This is equivalent to std::shared_ptr<T> in C++, which also uses atomic
reference counting. The atomic operations add some overhead compared to Rc, so
prefer Rc when you don’t need thread safety. Arc is commonly used with
Mutex or RwLock to provide thread-safe shared mutable state.
C++:
#include <memory>
#include <thread>
int main() {
auto shared = std::make_shared<int>(42);
std::thread t([shared]() {
std::cout << *shared;
});
t.join();
}
Rust:
use std::sync::Arc;
use std::thread;
fn main() {
let shared = Arc::new(42);
let shared2 = Arc::clone(&shared);
let handle = thread::spawn(move || {
println!("{}", *shared2);
});
handle.join().unwrap();
}
Arc with Mutex#
For thread-safe mutable access, combine Arc with Mutex. The Mutex ensures
only one thread can access the data at a time, while Arc allows multiple threads
to hold references to the mutex. This pattern is the Rust equivalent of sharing a
std::shared_ptr<std::mutex<T>> in C++, but Rust’s type system ensures you can’t
forget to lock the mutex - the data is only accessible through the lock guard:
use std::sync::{Arc, Mutex};
use std::thread;
fn main() {
let counter = Arc::new(Mutex::new(0));
let mut handles = vec![];
for _ in 0..10 {
let counter = Arc::clone(&counter);
let handle = thread::spawn(move || {
let mut num = counter.lock().unwrap();
*num += 1;
});
handles.push(handle);
}
for handle in handles {
handle.join().unwrap();
}
println!("{}", *counter.lock().unwrap()); // 10
}
RefCell (Interior Mutability)#
RefCell<T> provides interior mutability - the ability to mutate data even when
there are immutable references to it. This is checked at runtime rather than compile
time: RefCell tracks active borrows and panics if you violate the borrowing rules.
Use RefCell when you know your code follows borrowing rules but the compiler can’t
verify it, such as in graph structures or mock objects for testing. The runtime checks
have a small performance cost, so prefer compile-time borrowing when possible:
use std::cell::RefCell;
fn main() {
let cell = RefCell::new(42);
// Immutable borrow
let r1 = cell.borrow();
println!("{}", *r1);
drop(r1); // must drop before mutable borrow
// Mutable borrow
*cell.borrow_mut() += 1;
// Runtime panic if rules violated:
// let r1 = cell.borrow();
// let r2 = cell.borrow_mut(); // panic!
}
Cell (Copy Types)#
For types that implement Copy (like integers and floats), Cell<T> provides
a simpler alternative to RefCell. Instead of borrowing, Cell copies values
in and out. This avoids the runtime borrow tracking overhead and can never panic,
but only works with Copy types since it needs to copy the entire value:
use std::cell::Cell;
fn main() {
let cell = Cell::new(42);
cell.set(43);
let value = cell.get(); // copies value out
}
Weak References#
Weak<T> prevents reference cycles that would cause memory leaks. A Weak
reference doesn’t contribute to the reference count, so it won’t keep the data alive.
Before using a Weak, you must upgrade it to an Rc or Arc, which returns
None if the data has been dropped. This is essential for parent-child relationships
where children need to reference their parent without creating a cycle:
C++:
#include <memory>
struct Node {
std::shared_ptr<Node> next;
std::weak_ptr<Node> prev; // weak to break cycle
};
Rust:
use std::rc::{Rc, Weak};
use std::cell::RefCell;
struct Node {
next: Option<Rc<RefCell<Node>>>,
prev: Option<Weak<RefCell<Node>>>, // weak to break cycle
}
fn main() {
let node = Rc::new(RefCell::new(Node {
next: None,
prev: None,
}));
// Create weak reference
let weak: Weak<_> = Rc::downgrade(&node);
// Upgrade to Rc (returns Option)
if let Some(strong) = weak.upgrade() {
println!("Node still exists");
}
}
Cow (Clone on Write)#
Cow<T> (Clone on Write) is a smart pointer that can hold either borrowed or owned
data. It delays cloning until mutation is actually needed, which can significantly
improve performance when you often don’t need to modify the data. This is useful for
functions that might need to modify their input but usually don’t, or for caching
scenarios where you want to avoid unnecessary allocations:
use std::borrow::Cow;
fn process(input: &str) -> Cow<str> {
if input.contains("bad") {
// Only allocate if we need to modify
Cow::Owned(input.replace("bad", "good"))
} else {
// No allocation, just borrow
Cow::Borrowed(input)
}
}
fn main() {
let s1 = process("hello"); // Borrowed
let s2 = process("bad word"); // Owned
}