SuperAPI AI Directory

rust-syntax-smart-pointers

Coding & development

View repository

Updated 10-15 22:18

About this skill

rust-syntax-smart-pointers

Owning pointers and shared-ownership primitives: Box<T>, Rc<T>, Arc<T>, RefCell<T>, Cell<T>, OnceCell<T>, OnceLock<T>, LazyLock<T>, Weak<T>, and a Pin<T> overview. Each picks a different combination of: heap allocation, single-vs-multi-thread refcounting, interior mutability, and lazy initialization. Choosing wrongly leaks, deadlocks, panics, or fails to compile.

Cross-references: [[rust-core-memory-model]] (stack vs heap, UnsafeCell), [[rust-syntax-borrowing]] (the borrow rules RefCell enforces at runtime), [[rust-syntax-async-await]] (Pin<&mut Self> in Future::poll; deep pinning reference under that skill), [[rust-impl-concurrency]] (Arc<Mutex<T>> vs atomics in real workloads).


When to use this skill

  • User reaches for Box<T> for a recursive type (tree, linked list) or a dyn Trait return.
  • User wants to share read-only data across a single thread (Rc<T>) or across threads (Arc<T>).
  • User asks "how do I mutate through a shared reference" and needs to choose Cell / RefCell / Mutex / RwLock / Atomic*.
  • User wants a lazily-initialized global and is tempted to use static mut or lazy_static!.
  • User builds a graph / observer / parent-child tree and hits a reference cycle (memory leak with Rc<RefCell<T>>).
  • User sees Future::poll(self: Pin<&mut Self>, ...) and asks "what is Pin".

The decision in one table

You need... Pick Why
Heap allocation of a single owned value Box<T> Cheapest owned heap pointer.
Recursive type (Node { next: ??? }) Box<Node> Breaks the infinite size.
Owned trait object Box<dyn Trait> Heap-stored vtable pointer.
Shared ownership, single thread Rc<T> Non-atomic refcount, no Send/Sync cost.
Shared ownership, multi thread Arc<T> Atomic refcount. T must be Send + Sync for Arc<T>: Send + Sync.
Shared + Copy interior Cell<T> (single thread) / Atomic* (multi thread) Zero or near-zero cost.
Shared + runtime-checked borrow RefCell<T> (single thread) / Mutex<T>/RwLock<T> (multi thread) Panic / block on violation.
Init-once value OnceCell<T> (single thread) / OnceLock<T> (multi thread, 1.70+) No lock after init.
Lazy global (init on first access) LazyLock<T> (1.80+) static; LazyCell<T> (1.80+) thread-local Replaces lazy_static!.
Cycle-breaking child pointer Weak<T> (from Rc::downgrade / Arc::downgrade) Does not count toward strong refs.
Self-referential struct (async future state) Pin<P> See Pin overview below and [[rust-syntax-async-await]].

ALWAYS pick the lowest-overhead primitive that satisfies the requirement. NEVER reach for Arc<Mutex<T>> as a first instinct: in many cases AtomicUsize, RwLock, or Arc<T> alone is correct.


Box<T>: owned heap allocation

Box<T> is the simplest smart pointer: it owns a single T on the heap. Drops the value when the Box drops.

Source: https://doc.rust-lang.org/std/boxed/index.html

fn box_basic() {
    let b: Box<i32> = Box::new(5);
    println!("{}", *b); // Deref to i32
}

When Box is the right tool

  1. Recursive types. The compiler cannot size enum List { Cons(i32, List), Nil }; Box adds a fixed-size indirection.

    enum List {
        Cons(i32, Box<List>),
        Nil,
    }
    
  2. Trait objects. Box<dyn Error>, Box<dyn Fn() + Send> give owned, dyn-dispatched values.

  3. Large stack values you want to move cheaply. Moving a Box<Huge> copies one pointer; moving a Huge by value copies all bytes.

  4. Box::leak for 'static references. Promotes the heap allocation to live for the program's lifetime, returning &'static mut T. Used for one-time-initialized configuration that the rest of the program borrows.

    let cfg: &'static str = Box::leak(Box::new(String::from("hi"))).as_str();
    

    NEVER call Box::leak in a hot path or per-request. The memory is genuinely leaked.

NEVER Box<T> a small Copy type that fits in a register (Box<u32>, Box<bool>). The heap round-trip is pure cost with no benefit.


Rc<T>: single-thread shared ownership

Rc<T> ("reference counted") gives shared, immutable access to a T on the heap. Cloning increments a non-atomic counter. The last Rc drops the value.

Source: https://doc.rust-lang.org/std/rc/index.html

use std::rc::Rc;

fn rc_basic() {
    let a = Rc::new(String::from("hi"));
    let b = Rc::clone(&a);          // refcount = 2
    println!("{} {} count={}", a, b, Rc::strong_count(&a));
}
  • Rc<T>: !Send and Rc<T>: !Sync. Compiler error on cross-thread move.
  • Rc::clone(&a) is preferred over a.clone() to make the intent explicit; both do the same thing.
  • Rc<T> is read-only. Combine with RefCell<T> for shared+mutable single-thread: Rc<RefCell<T>>.

ALWAYS use Rc::clone(&a), not a.clone(), to communicate "I am bumping a refcount, not deep-copying".

NEVER hold an Rc<T> in a struct that will be sent to another thread. Switch to Arc<T> from the start; retrofitting is invasive.

Weak<T>: break cycles

Rc<RefCell<Node>> graphs can leak when two nodes point at each other (parent <-> child). Weak<T> is a non-owning pointer: it does not contribute to the strong count and must be upgrade()d to access the value (returns Option<Rc<T>> because the value may have been dropped).

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

struct Node {
    parent: RefCell<Weak<Node>>,    // parent: non-owning
    children: RefCell<Vec<Rc<Node>>>, // children: owning
}

ALWAYS make the back-edge (parent, observer-of) Weak. The forward-edge (child, owner) stays Rc/Arc. This is the canonical fix for cycles.

NEVER make BOTH directions Rc/Arc. Drop order will never run; memory leaks silently.


Arc<T>: multi-thread shared ownership

Arc<T> ("atomically reference counted") is the multi-thread analogue of Rc<T>. Cloning uses atomic increment.

Source: https://doc.rust-lang.org/std/sync/struct.Arc.html

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

fn arc_basic() {
    let data = Arc::new(vec![1, 2, 3]);
    let h: Vec<_> = (0..3).map(|_| {
        let d = Arc::clone(&data);
        thread::spawn(move || println!("{:?}", d))
    }).collect();
    for j in h { j.join().unwrap(); }
}
  • Arc<T>: Send + Sync iff T: Send + Sync. The compiler enforces this.
  • Atomic operations are more expensive than Rc's non-atomic ones, but still cheap (typically a single CAS).
  • For mutation across threads, wrap in Mutex, RwLock, or an atomic type: Arc<Mutex<T>>, Arc<RwLock<T>>, Arc<AtomicUsize>.

ALWAYS reach for Arc<T> only when you actually need cross-thread shared ownership. If only one thread reads, Rc<T> is faster.

NEVER use Arc<Mutex<T>> when an atomic suffices. For counters, flags, and primitive ints/bools, Arc<AtomicUsize> (or even a plain &'static AtomicUsize) is faster and harder to misuse.

Arc<Mutex<T>> vs Arc<RwLock<T>> vs atomics

Mutates a single integer/bool/pointer?
|
+-- Yes: AtomicX (AtomicUsize, AtomicBool, etc.). No Arc<Mutex> needed for primitives.
|
+-- No: many readers, occasional writer?
    |
    +-- Yes: Arc<RwLock<T>>. Readers parallel, writers exclusive.
    |
    +-- No (mixed read/write, contention low): Arc<Mutex<T>>. Simplest.

ALWAYS measure before assuming Arc<RwLock<T>> is faster than Arc<Mutex<T>>. RwLock is more complex and not always a win under contention.

NEVER hold a std::sync::Mutex guard across .await. The runtime can move the task to another thread. Use tokio::sync::Mutex in async code. See [[rust-impl-concurrency]] and [[rust-syntax-async-await]].


Cell<T> and RefCell<T>: interior mutability (single thread)

The borrow rules ([[rust-syntax-borrowing]]) say: many &T OR one &mut T, never both. Sometimes you genuinely need to mutate through &T. Cell and RefCell are the sound escape hatches, both built on UnsafeCell<T>.

Cell<T>: zero-cost, get/set only

use std::cell::Cell;

let c = Cell::new(0_i32);
c.set(c.get() + 1);         // T: Copy fast path
  • Cell<T> never hands out &T or &mut T to the contents. Only get() (returns T by copy) and set(T) (replace).
  • For T: !Copy, use replace(new), take(), swap(other).
  • !Sync (single thread only).
  • Zero runtime overhead.

ALWAYS prefer Cell<T> over RefCell<T> when T: Copy or you only need swap semantics.

NEVER try to read &T out of a Cell. The API does not allow it by design.

RefCell<T>: runtime-checked & / &mut

use std::cell::RefCell;

let r = RefCell::new(vec![1, 2, 3]);
{
    let v = r.borrow();
    println!("{}", v.len());
}                                // borrow drops
{
    let mut v = r.borrow_mut();
    v.push(4);
}
  • borrow() returns Ref<'_, T>, borrow_mut() returns RefMut<'_, T>. Both are smart pointers that release the borrow on drop.
  • Violating the borrow rules at runtime panics: "already borrowed" / "already mutably borrowed".
  • RefCell<T>: !Sync.
  • Use try_borrow() / try_borrow_mut() to handle the failure without panic in re-entrant code.

ALWAYS limit the scope of Ref / RefMut to the smallest possible block. The borrow lives until the guard drops, exactly like MutexGuard.

NEVER hold a RefCell guard across a function call that might re-enter the same cell. Runtime panic.

NEVER call borrow_mut() while a borrow() from the same cell is alive in any code path. Same panic.


OnceCell / OnceLock / LazyLock: lazy initialization

For "initialize this exactly once, then read it forever" patterns. Pre-1.70 / pre-1.80 code used lazy_static! or once_cell crate. Std now ships these natively.

OnceCell<T> (single thread)

Source: https://doc.rust-lang.org/std/cell/struct.OnceCell.html

use std::cell::OnceCell;

let cell: OnceCell<String> = OnceCell::new();
let value: &String = cell.get_or_init(|| "hi".to_string());
  • !Sync.
  • get_or_init(|| ...) runs the closure exactly once (on first call).

OnceLock<T> (multi thread, stable in 1.70)

Source: https://doc.rust-lang.org/std/sync/struct.OnceLock.html

use std::sync::OnceLock;

static CFG: OnceLock<String> = OnceLock::new();

fn config() -> &'static String {
    CFG.get_or_init(|| std::env::var("CFG").unwrap_or_default())
}
  • Sync (safe to use as static).
  • After init, reads are lock-free.
  • Replaces lazy_static! for one-time initialization.

ALWAYS use OnceLock<T> for thread-safe lazy static initialization on Rust 1.70+.

NEVER use static mut for lazy globals. Reading static mut in safe code is forbidden in edition 2024 (static_mut_refs is hard error). OnceLock is the safe replacement.

LazyLock<T> (multi thread, stable in 1.80)

Source: https://doc.rust-lang.org/std/sync/struct.LazyLock.html

LazyLock<T, F> wraps an init closure into the static itself, so the call site looks like a plain &T.

use std::sync::LazyLock;

static GREETING: LazyLock<String> = LazyLock::new(|| "hello".to_string());

fn main() {
    println!("{}", *GREETING);
}
  • Deref to &T. No get_or_init boilerplate at call sites.
  • Single-thread variant is LazyCell<T, F> (also stable in 1.80).
  • Internally a OnceLock plus the init closure.

ALWAYS prefer LazyLock<T> over OnceLock<T> when the call sites should look like a plain &T with no get_or_init. Use OnceLock<T> when initialization depends on runtime input not available at definition site.

NEVER use the external lazy_static! or once_cell crate in new code on Rust 1.80+. The std types replace them with zero ceremony.


Pin<P>: pinning overview (deep reference under async-await)

Pin<P> is a wrapper around a pointer type P (typically Pin<Box<T>> or Pin<&mut T>) that guarantees the pointee will not be moved in memory for the rest of its lifetime. Required to make self-referential types sound, which is why Future::poll takes self: Pin<&mut Self>.

Source: https://doc.rust-lang.org/std/pin/index.html

use std::pin::Pin;
use std::future::Future;
use std::task::{Context, Poll};

// The Future trait signature: poll receives a pinned mutable reference.
trait FutureSketch {
    type Output;
    fn poll(self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll<Self::Output>;
}

Three facts to know in this skill:

  1. Unpin opts a type out of the pinning restriction. Almost all everyday types are Unpin (the trait is an auto-trait). For an Unpin type, Pin<Box<T>> behaves identically to Box<T> and Pin<&mut T> behaves identically to &mut T.
  2. The drop guarantee. Once a value is pinned, its drop handler MUST be called before its memory is invalidated. Leaking via mem::forget is allowed (it skips drop but never invalidates memory).
  3. PhantomPinned opts a type IN to the pinning restriction. Use this in types that store self-references (typically generated by async fn, or by hand in custom futures).

ALWAYS treat Pin<P> as a marker that says "this pointer's target cannot be moved". When writing your own futures, see the deep reference: [[rust-syntax-async-await]]/references/pinning.md.

NEVER attempt to extract a &mut T out of a Pin<&mut T> for T: !Unpin via Pin::into_inner_unchecked: that breaks the pin guarantee and is unsound. The safe path is Pin::as_mut and pin-projection (see deep reference).


Common compositions

Rc<RefCell<T>>: shared + mutable, single thread

The canonical pattern for graphs, observer registries, interpreters, ECS prototypes in single-threaded code.

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

let shared = Rc::new(RefCell::new(0_i32));
let copy = Rc::clone(&shared);
*shared.borrow_mut() += 1;
*copy.borrow_mut() += 10;
assert_eq!(*shared.borrow(), 11);

ALWAYS keep RefCell::borrow_mut() scopes as small as possible. A panic from "already borrowed" is the most common bug with this pattern.

Arc<Mutex<T>>: shared + mutable, multi thread

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

let counter = Arc::new(Mutex::new(0_u32));
let handles: Vec<_> = (0..4).map(|_| {
    let c = Arc::clone(&counter);
    thread::spawn(move || {
        let mut g = c.lock().unwrap();
        *g += 1;
    })
}).collect();
for h in handles { h.join().unwrap(); }
assert_eq!(*counter.lock().unwrap(), 4);

ALWAYS hold MutexGuard for the shortest critical section possible. Drop before any unrelated work.

NEVER hold a std::sync::Mutex guard across .await. Use tokio::sync::Mutex in async code.


Quick reference table

Type Send Sync Cost Use when
Box<T> iff T: Send iff T: Sync one heap alloc heap-owned single value, recursive types, trait objects
Rc<T> NO NO non-atomic refcount shared read-only, single thread
Weak<T> (rc) NO NO non-atomic break Rc cycles
Arc<T> iff T: Send + Sync iff T: Send + Sync atomic refcount shared read-only, multi thread
Weak<T> (sync) iff T: Send + Sync iff T: Send + Sync atomic break Arc cycles
Cell<T> iff T: Send NO zero Copy interior, single thread
RefCell<T> iff T: Send NO runtime borrow check &/&mut through &Self, single thread
OnceCell<T> iff T: Send NO one-time init lazy single-thread
OnceLock<T> iff T: Send + Sync iff T: Send + Sync one CAS + atomic load lazy multi-thread, since 1.70
LazyLock<T, F> iff T, F: Send iff T: Send + Sync, F: Send one-time init + atomic load lazy multi-thread static, since 1.80
Pin<P> depends on P depends on P none self-referential types, futures

For full method signatures see methods. For end-to-end examples see examples. For pitfalls see anti-patterns.


Decision tree: which smart pointer

Need shared ownership?
|
+-- No: do you need heap allocation?
|   |
|   +-- Yes (recursive type, dyn Trait, large value): Box<T>
|   |
|   +-- No (small Copy type): just T on the stack
|
+-- Yes: single thread or multi thread?
    |
    +-- Single thread: Rc<T> (+ RefCell<T> if mutation needed)
    |   |
    |   +-- Need to break a cycle? Weak<T> for the back-edge.
    |
    +-- Multi thread: Arc<T> (+ Mutex/RwLock/Atomic if mutation needed)
        |
        +-- Mutating a primitive int/bool/pointer? AtomicX, no Mutex.
        +-- Many readers, occasional writer? RwLock.
        +-- Otherwise: Mutex.
        +-- Need to break a cycle? sync::Weak<T>.
Need a lazily-initialized value?
|
+-- Single thread, manual get_or_init: OnceCell<T>
+-- Single thread, deref-style global: LazyCell<T, F>  (1.80+)
+-- Multi thread, manual get_or_init: OnceLock<T>     (1.70+)
+-- Multi thread, deref-style static: LazyLock<T, F>  (1.80+)

ALWAYS follow the tree top to bottom. Picking the wrong primitive shows up as performance, deadlock, or compile-error months later.


Reference links