Telex logo Telex

Rust's Smart Pointer Patterns — Part 1: The Ownership and Sharing System

Part 1 of 2 in Rust's Smart Pointer Patterns. Companion series: Rust Patterns That Matter.

Next: Part 2: The Specialists

Rust has a handful of wrapper types that sit between you and your data. Box, Rc, Arc, Mutex, RefCell -- you've seen them in tutorials and you know roughly what each one does. But when you're staring at a real problem, the question isn't "what does Rc do?" It's "should I use Rc here, or Arc, or neither?"

This post is the decision guide. For each type, I'll tell you what it is in one line, then spend the rest of the section on when you'd actually reach for it and why the alternatives don't work. The types build on each other, so they're in dependency order -- each one solves a problem the previous one can't.

If you're coming from C, think of this as "what replaces malloc, free, reference counting, and pthread_mutex_t -- and why Rust splits these into separate types instead of giving you one raw pointer and a prayer."

This is Part 1 of two. This post covers the core ownership, sharing, and mutation system. Part 2 covers the specialists: Cow, OnceCell, Pin, and friends.

I. The Types

1. Box<T> -- "Put it on the heap"

Box<T> is a pointer to a heap-allocated value with a single owner. When the Box goes out of scope, the value is freed. That's it. No reference counting, no locking, no shared access. It's malloc plus automatic free.

You reach for Box in three situations.

Recursive types. A struct that contains itself has infinite size. The compiler needs to know how big things are at compile time, and "infinitely big" doesn't work. A Box is always one pointer wide, so it breaks the recursion.

enum List {
    Cons(i32, Box<List>),
    Nil,
}

Without the Box, the compiler would try to compute the size of List, find that it contains a List, which contains a List, and give up. The Box makes the inner List a fixed-size pointer instead of an inline value.

Large values you don't want on the stack. If you have a [u8; 1_000_000] and you're passing it between functions, that's a megabyte of stack pressure -- and while the compiler can often elide copies on move, it's not guaranteed. Box it and you're passing eight bytes with a predictable layout.

let buffer = Box::new([0u8; 1_000_000]);
// Moving `buffer` copies 8 bytes (the pointer), not 1MB

Trait objects. When you need a value whose concrete type isn't known at compile time -- a plugin, a strategy, a callback -- you need dynamic dispatch. Box<dyn Trait> puts the value on the heap and carries a vtable pointer alongside it.

fn create_logger(verbose: bool) -> Box<dyn Logger> {
    if verbose {
        Box::new(DetailedLogger::new())
    } else {
        Box::new(QuietLogger::new())
    }
}

The C equivalent would be returning a void* with a struct of function pointers. Box<dyn Trait> is the same idea, except the compiler generates the vtable and the memory is freed automatically.

If you don't need any of these three things -- recursion, large values, or dynamic dispatch -- you probably don't need Box. Rust values live on the stack by default, and that's usually fine.

2. Rc<T> -- "Shared ownership, single thread"

Rc<T> is a reference-counted pointer. Multiple Rcs can point to the same heap-allocated value. Each time you clone an Rc, the reference count goes up. Each time one is dropped, the count goes down. When it hits zero, the value is freed.

You reach for Rc when you have a data structure where multiple parts need to own the same data. Trees where nodes share children. A graph where multiple edges point to the same node. A UI framework where multiple widgets reference the same state object.

use std::rc::Rc;

let shared_config = Rc::new(Config::load());

let widget_a = Widget::new(Rc::clone(&shared_config));
let widget_b = Widget::new(Rc::clone(&shared_config));
let widget_c = Widget::new(Rc::clone(&shared_config));
// All three widgets read the same Config. Ref count is 4.

Two things Rc cannot do. First, it cannot cross thread boundaries. Rc is not Send -- the reference count uses non-atomic operations, so concurrent increments would be a data race. If you need shared ownership across threads, that's Arc (next section).

Second, Rc alone gives you read-only access. You can't get a &mut T out of an Rc<T> because other Rcs might be reading the same data. If you need mutation, you combine Rc with RefCell (covered later in this post).

A note on the convention: use Rc::clone(&x) instead of x.clone(). They do the same thing, but Rc::clone makes it visually obvious that you're bumping a counter, not deep-copying data. In code review, .clone() is a signal for "expensive allocation might be happening here." Rc::clone says "this is cheap, relax."

3. Arc<T> -- "Rc but thread-safe"

Arc<T> is Rc<T> with atomic reference counting. The reference count is incremented and decremented using atomic CPU instructions, so it's safe to share across threads. That's the only difference.

use std::sync::Arc;
use std::thread;

let data = Arc::new(vec![1, 2, 3, 4, 5]);

let handles: Vec<_> = (0..3).map(|i| {
    let data = Arc::clone(&data);
    thread::spawn(move || {
        println!("Thread {i}: sum = {}", data.iter().sum::<i32>());
    })
}).collect();

for h in handles { h.join().unwrap(); }

The atomic operations have a small cost compared to Rc. On modern CPUs, an atomic increment is roughly 5-10x slower than a non-atomic one. In practice, this almost never matters -- you're not incrementing reference counts in a tight loop. But it's the reason both types exist: Rc is for when you know you're single-threaded and want to avoid paying for atomics you don't need.

Like Rc, Arc alone gives you read-only access. You can't get &mut T from an Arc<T>. For shared mutable state across threads, you combine Arc with Mutex or RwLock.

The decision is simple: single-threaded shared ownership? Rc. Multi-threaded shared ownership? Arc. If you guess wrong, the compiler tells you -- trying to send an Rc across a thread boundary gives you a clear error about Send.

4. Weak<T> -- "Breaking cycles"

Weak<T> is a non-owning reference that works with both Rc and Arc. It doesn't keep the value alive. To actually use the data, you call upgrade(), which gives you an Option<Rc<T>> (or Option<Arc<T>>). If the value has already been dropped, you get None.

The problem Weak solves is reference cycles. If node A owns an Rc to node B, and node B owns an Rc back to node A, neither reference count will ever reach zero. The data leaks. This is the fundamental weakness of reference counting -- and it's the same problem you'd have in C++ with shared_ptr cycles.

use std::rc::{Rc, Weak};
use std::cell::RefCell;

struct Node {
    value: i32,
    parent: RefCell<Weak<Node>>,       // doesn't keep parent alive
    children: RefCell<Vec<Rc<Node>>>,  // owns children
}

let parent = Rc::new(Node {
    value: 1,
    parent: RefCell::new(Weak::new()),
    children: RefCell::new(vec![]),
});

let child = Rc::new(Node {
    value: 2,
    parent: RefCell::new(Rc::downgrade(&parent)),  // Weak ref to parent
    children: RefCell::new(vec![]),
});

parent.children.borrow_mut().push(Rc::clone(&child));

The rule of thumb: in any parent-child relationship with reference counting, the parent owns (strong Rc/Arc) the children, and the children hold Weak references back to the parent. Same pattern for caches (the cache holds Weak refs so entries can be evicted) and observer patterns (the subject holds Weak refs to observers so they can be dropped without the subject knowing).

The upgrade() call returning Option is the key design choice. It forces you to handle the case where the data is gone. There's no dangling pointer, no use-after-free -- just a None you have to deal with.

5. Cell<T> -- "Interior mutability without borrowing"

Cell<T> lets you mutate a value through a shared (&) reference -- which looks like it should violate Rust's borrow rules. It doesn't, because it never hands out a reference to the inner value. set replaces the value, get copies it out. No reference to the inner value, no aliasing, no problem.

use std::cell::Cell;

struct Counter {
    count: Cell<u32>,
}

impl Counter {
    fn increment(&self) {
        self.count.set(self.count.get() + 1);
    }
}

let c = Counter { count: Cell::new(0) };
c.increment();  // mutates through &self, not &mut self
c.increment();
assert_eq!(c.count.get(), 2);

Cell<T> itself works with any T, but get requires T: Copy -- it returns a copy of the value. For non-Copy types like String or Vec, you use set, take (which swaps in the default), or replace (which swaps in a value you provide). In practice, get is what you'll use most, so Cell is mostly seen with small Copy types: integers, booleans, enums, character types.

Cell has zero runtime overhead. No dynamic borrow tracking, no atomic operations, no locks. It compiles down to the same machine code as direct mutation. The "interior mutability" is purely a type-system concept that tells the compiler "trust me, I'm only copying small values in and out."

When you need it: counters behind shared references, dirty flags, caches of small computed values. Anywhere you need a mutable local that's reachable through shared references.

6. RefCell<T> -- "Interior mutability with runtime borrow checking"

RefCell<T> is the big sibling of Cell. It lets you mutate any type through a shared reference -- not just Copy types. The tradeoff: borrow rules are enforced at runtime instead of compile time. If you violate them, the program panics.

use std::cell::RefCell;

let data = RefCell::new(vec![1, 2, 3]);

// Borrow immutably -- like &T
{
    let r = data.borrow();
    println!("Length: {}", r.len());
}

// Borrow mutably -- like &mut T
{
    let mut w = data.borrow_mut();
    w.push(4);
}

// This would panic at runtime:
// let r = data.borrow();
// let w = data.borrow_mut(); // PANIC: already borrowed

The rules are the same as the compile-time borrow checker: any number of shared borrows (borrow()), or exactly one mutable borrow (borrow_mut()), but not both at the same time. The difference is that violations are caught when the code runs, not when it compiles. A RefCell tracks its borrow state internally.

When you reach for it: you have a data structure behind a shared reference and you need to mutate it. The most common case is Rc<RefCell<T>> -- shared ownership plus mutation (covered in the combos section). But you also see RefCell on struct fields when a method takes &self but needs to update internal caches or bookkeeping.

RefCell is single-threaded only. It is not Sync, so you can't share it across threads. For the multi-threaded equivalent, use Mutex.

The mental model: RefCell moves borrow checking from the compiler's job to your job. The compiler can't prove your borrows are safe, so you're telling it "I'll manage this, and if I'm wrong, crash rather than corrupt memory." That's a reasonable tradeoff when the alternative is fighting the borrow checker into contortions -- but it should feel like a deliberate choice, not a default.

7. Mutex<T> -- "RefCell for threads"

Mutex<T> protects data with a lock. Only one thread can access the data at a time. You call lock() to acquire it, which blocks until the lock is available and returns a MutexGuard -- an RAII handle that automatically releases the lock when dropped.

use std::sync::{Arc, Mutex};
use std::thread;

let counter = Arc::new(Mutex::new(0));

let handles: Vec<_> = (0..10).map(|_| {
    let counter = Arc::clone(&counter);
    thread::spawn(move || {
        let mut num = counter.lock().unwrap();
        *num += 1;
        // MutexGuard dropped here, lock released
    })
}).collect();

for h in handles { h.join().unwrap(); }
assert_eq!(*counter.lock().unwrap(), 10);

If you're coming from C, this is pthread_mutex_t except the data it protects is inside the mutex, not next to it. In C, you have a mutex and some data, and you rely on conventions to remember to lock the mutex before touching the data. In Rust, the only way to reach the data is through the lock. The type system enforces the protocol.

Poisoning. If a thread panics while holding a MutexGuard, the mutex becomes "poisoned." Future calls to lock() return Err containing the guard, because the data might be in an inconsistent state. Most of the time, you handle this with .unwrap() (propagating the panic) or .lock().unwrap_or_else(|e| e.into_inner()) if you're confident the data is still valid.

// If you don't care about poisoning:
let mut data = lock.lock().unwrap();

// If you want to recover from a poisoned mutex:
let mut data = lock.lock().unwrap_or_else(|e| e.into_inner());

The key design insight: hold the lock for as short a time as possible. The MutexGuard holds the lock until it's dropped, so scope it tightly. Clone data out, drop the guard, then do the expensive work with the cloned data.

8. RwLock<T> -- "Mutex with a fast path for readers"

RwLock<T> allows multiple simultaneous readers or one exclusive writer. It's the same idea as Mutex, but if most of your accesses are reads, multiple threads can proceed in parallel instead of taking turns.

use std::sync::{Arc, RwLock};
use std::thread;

let config = Arc::new(RwLock::new(Config::default()));

// Many threads can read simultaneously
let readers: Vec<_> = (0..10).map(|_| {
    let config = Arc::clone(&config);
    thread::spawn(move || {
        let c = config.read().unwrap();
        println!("timeout: {}", c.timeout);
    })
}).collect();

// But writing blocks everyone
{
    let mut c = config.write().unwrap();
    c.timeout = 30;
}

The tradeoff: RwLock has more overhead than Mutex because it needs to track reader counts. For short critical sections or write-heavy workloads, Mutex is often faster because its lock/unlock is simpler. RwLock wins when you have many readers and rare writers -- a configuration object read by every request but updated once a minute, for example.

The default advice: start with Mutex. Switch to RwLock if profiling shows lock contention on reads. Don't reach for RwLock preemptively -- you'll pay the complexity cost without knowing whether you're getting the performance benefit.

II. The Combos

The individual types above are building blocks. In practice, you almost never use Rc without RefCell, or Arc without Mutex. The real types you work with are combinations, and they follow a consistent pattern: the outer wrapper handles ownership (who can access the data), and the inner wrapper handles mutation (how the data can change).

Rc<RefCell<T>> -- shared mutable state, single thread

This is the workhorse for single-threaded programs that need shared mutable state. Rc lets multiple parts of your code own the data. RefCell lets them mutate it. Together, they do what a shared_ptr to a mutable object does in C++ -- except the mutation rules are enforced at runtime.

use std::rc::Rc;
use std::cell::RefCell;

let state = Rc::new(RefCell::new(AppState::default()));

// Handler A can read and write
let s = Rc::clone(&state);
let on_click = move || {
    s.borrow_mut().click_count += 1;
};

// Handler B can also read and write
let s = Rc::clone(&state);
let on_reset = move || {
    *s.borrow_mut() = AppState::default();
};

// Renderer can read
println!("Clicks: {}", state.borrow().click_count);

You'll see this in GUI frameworks, event systems, and anywhere a callback graph needs to mutate shared state. It's Rust's answer to the global mutable variable -- scoped, reference-counted, and runtime-checked.

Arc<Mutex<T>> -- shared mutable state, multi-thread

The multi-threaded equivalent of Rc<RefCell<T>>. Arc handles the sharing (with atomic reference counting), and Mutex handles the mutation (with locking).

use std::sync::{Arc, Mutex};
use std::thread;

let db = Arc::new(Mutex::new(Database::connect()));

let handles: Vec<_> = requests.into_iter().map(|req| {
    let db = Arc::clone(&db);
    thread::spawn(move || {
        let mut conn = db.lock().unwrap();
        conn.execute(&req);
    })
}).collect();

This is the most common concurrent data pattern in Rust. If you're coming from Go, it's roughly what a sync.Mutex-protected struct does. From Java, it's a synchronized block. The Rust version is harder to get wrong because the compiler won't let you access the data without going through the lock.

Arc<RwLock<T>> -- shared mutable state, read-heavy multi-thread

Swap Mutex for RwLock when reads vastly outnumber writes. The shape is the same -- Arc for ownership, RwLock for mutation -- but reads don't block each other.

use std::sync::{Arc, RwLock};

let cache = Arc::new(RwLock::new(HashMap::new()));

// Hot path: many concurrent reads
let val = cache.read().unwrap().get("key").cloned();

// Cold path: occasional writes
cache.write().unwrap().insert("key".into(), value);

The decision flowchart

Three questions determine which combo you need:

Do you need shared ownership? (Multiple parts of your code own the same data)
├── No  → Just use T, &T, or &mut T. You don't need any of this.
└── Yes
    ├── Single-threaded?
    │   ├── Read-only  → Rc<T>
    │   └── Mutable    → Rc<RefCell<T>>
    └── Multi-threaded?
        ├── Read-only  → Arc<T>
        ├── Mutable (most cases)       → Arc<Mutex<T>>
        └── Mutable (read-heavy)       → Arc<RwLock<T>>

The pattern is always the same: outer wrapper for ownership, inner wrapper for mutation. Once you see it that way, you stop memorizing combinations and start assembling them from the two questions: "who needs access?" and "do they need to write?"

A few things that don't need any of this: if you have one owner and want to mutate, just use &mut T. If you're passing data into a thread and don't need it back, move it -- no Arc needed. The smart pointer system exists for when ownership is genuinely shared, not for every heap allocation.

III. The Six Supporting Method Pairs

The types above are the architecture. But day-to-day, you spend most of your time calling methods on the values inside them -- unwrapping Options that come back from Weak::upgrade(), bridging Results from Mutex::lock(), transforming data inside wrappers without taking ownership. These six method pairs are the toolkit that makes the whole system livable.

1. The Error Control Flow Pair: ok_or / ok

You'll constantly cross between Option and Result when working with these types. Mutex::lock() returns a Result. Weak::upgrade() returns an Option. HashMap lookups return Option. Your function probably returns Result. You need to bridge the two.

// Option → Result: "None is an error"
let parent = node.parent.upgrade()
    .ok_or(Error::ParentDropped)?;

// Result → Option: "I don't care about the error"
let value = cache.lock().ok()
    .and_then(|guard| guard.get(key).cloned());

ok_or converts None into a specific error so you can use ? to propagate it. ok() goes the other direction: it discards the error and gives you an Option. You'll use ok_or when the absence is a real failure, and ok() when it's not -- a parse that might not work, or a cache lookup where failure is tolerable. Be careful with lock().ok() on a poisoned mutex: silently ignoring poisoning means you might read inconsistent data, which is exactly what poisoning warns you about.

2. The Iterator Gatekeepers: filter / filter_map

When you're working with collections of wrapped values -- Vec<Weak<T>>, Vec<Option<T>>, results from batch operations -- you need to clean them up. filter removes entries that fail a test. filter_map removes entries and transforms them in one step.

// Upgrade all Weak refs, dropping the dead ones
let live_observers: Vec<Rc<Observer>> = observers.iter()
    .filter_map(|weak| weak.upgrade())
    .collect();

// Keep only the connections that are still healthy
let active: Vec<_> = connections.iter()
    .filter(|conn| conn.is_alive())
    .collect();

filter_map is especially natural with Weak references. upgrade() already returns Option, so filter_map keeps the Somes and drops the Nones in one pass. No intermediate collection, no explicit matching.

3. The Borrowing Bridges: as_ref / as_deref

You're holding an Option<String> inside a MutexGuard or behind an Rc, and you want to peek at it without cloning. as_ref gives you Option<&String>. as_deref goes further and gives you Option<&str>.

let data = Mutex::new(Some("hello".to_string()));
let guard = data.lock().unwrap();

// Peek at the string without cloning
match guard.as_deref() {
    Some("hello") => println!("greeting found"),
    Some(other) => println!("found: {other}"),
    None => println!("empty"),
}

as_deref() is the gold standard for peeking. It works on Option<String> (gives Option<&str>), Option<Vec<T>> (gives Option<&[T]>), and Option<Box<T>> (gives Option<&T>). Anywhere you have an owned value inside an Option and want a borrowed view of its contents, as_deref is the answer.

4. The Value Swappers: take / replace

When data is behind a MutexGuard or RefMut, you can't just move it out -- the guard is a reference, not an owner. But you can swap it out, leaving a valid value behind. take swaps with the default (None for Option). replace swaps with a value you provide.

use std::mem;

let state = Mutex::new(State::Running);

// Transition state, get the old value
let mut guard = state.lock().unwrap();
let previous = mem::replace(&mut *guard, State::Paused);
// guard now holds Paused, previous holds Running

// Or with Option: take the value out
let pending = Mutex::new(Some(Message::new("hello")));
let mut guard = pending.lock().unwrap();
let msg = guard.take();  // Option::take — no mem import needed
// guard now holds None, msg holds Some(Message)

This pattern is essential for state machines behind locks. You can't do a match-and-move because the guard only gives you a mutable reference. But mem::replace works on any mutable reference -- it puts the new value in and hands you the old one, all through &mut. No Clone required, no intermediate invalid state.

5. The Searchers: find_map / position

When you're searching through a collection of wrapped values -- finding the first valid entry, locating an item in a Vec protected by a lock -- you want to combine the search and the transformation.

// Find the first observer that's still alive and return its ID
let first_live_id = observers.iter()
    .find_map(|weak| weak.upgrade().map(|rc| rc.id));

// Find where a specific connection is in the list
let idx = connections.iter()
    .position(|c| c.id == target_id);

find_map searches and transforms in one pass: return Some(value) to say "found it, here's what I want" or None to say "keep looking." position returns the index instead of the element -- useful when you need to remove or replace an item in a Vec.

6. The Cost Signalers: copied / cloned

When iterating over references to values inside smart pointers, you often need owned copies. copied() and cloned() both do this, but they signal different costs to the reader.

let counts = vec![1, 2, 3, 4];
let names = vec!["Alice".to_string(), "Bob".to_string()];

// copied: cheap, just memcpy of small values
let doubled: Vec<i32> = counts.iter().copied().map(|n| n * 2).collect();

// cloned: potentially expensive, heap allocations
let owned_names: Vec<String> = names.iter().cloned().collect();

The distinction matters for code review. When you see copied(), you know it's trivial -- a few bytes of stack copying. When you see cloned(), you know heap allocation might be involved. And Arc::clone() is a special case: it looks like cloned but it's cheap (just bumps a counter).

let x = Arc::new(42);

// These two do the same thing, but one communicates cost:
let a = x.clone();          // Could be expensive. Reader has to check the type.
let b = Arc::clone(&x);    // Cheap. Just a reference count bump.

What Comes Next

This post covered the core system: the types that handle ownership, sharing, and mutation, plus the method pairs you use to work with them daily. These are the types you'll use in almost every Rust program that does more than compute and exit.

Part 2 covers the specialists -- types that solve narrower problems but solve them extremely well: Cow (borrow or own, decided at runtime), OnceCell and LazyLock (initialize once, use forever), Pin (don't move this value in memory), and a few more. Where Part 1 is the type system you use every day, Part 2 is the toolkit you reach for when a specific problem demands it.