Start with two questions: who needs to keep this value alive, and who needs to change it? Ownership types and interior-mutation types answer different questions, which is why useful designs often combine them.
A struct can own its fields directly, and functions can borrow them with
&T or &mut T. Start there when it expresses the required lifetimes.
Plain ownership does not mean every value lives on the stack: fields can
belong to heap-allocated objects, and Vec and String manage their own
buffers. Storage location alone does not tell you the cost of constructing,
moving or dropping a value.
Choose the ownership relationship
| Need | Consider | Main tradeoff |
|---|---|---|
| One owner with ordinary fields | T, Vec<T>, String and borrows |
Lifetimes and exclusive access are checked statically |
| An owned allocation or indirection | Box<T> |
Useful for recursive types, owned trait objects and separating a value from its owner's layout |
| Independent owners on one thread | Rc<T> |
Cloning a handle shares the value and updates a non-atomic reference count |
| Independent owners across threads | Arc<T> |
Atomic reference counting, with thread-safety bounds on the contents |
| A link that must not keep its target alive | Weak<T> from the matching rc or sync module |
upgrade() may return None |
| Borrow existing text or return changed text | Cow<'a, str> |
Can preserve a borrow; mutation through to_mut() creates owned storage when necessary |
A shared borrow may be enough even when several callers read the same data. Use reference counting when those callers need independent ownership, not merely because there is more than one reference.
Box can own an unsized target such as a slice or a trait object. It is not
required for every use of unsized data: &str and &dyn Trait are borrowed
alternatives. Handles to unsized targets carry metadata, so not every smart
pointer is a single machine word.
Choose how changes become visible
| Need | Consider | What readers observe |
|---|---|---|
| Exclusive access is available | &mut T |
Changes to the uniquely borrowed value |
| Updates should create an independent version when shared | Rc::make_mut or Arc::make_mut |
Other strong owners keep the previous version |
| Replace a value through a shared reference | Cell<T> |
Subsequent reads or replacements see the new value |
| Borrow a mutable value dynamically on one thread | RefCell<T> |
All handles refer to the same value, subject to runtime borrow checks |
| Initialize a value, then share references to it | OnceCell<T> or thread-safe OnceLock<T> |
Readers see the initialized value |
| Store an initializer for evaluation on first access | LazyCell<T> or thread-safe LazyLock<T> |
Initialization happens when forced |
| Coordinate mutable access across threads | Mutex<T>, RwLock<T> or suitable atomics |
Visibility and access follow the synchronization API |
These choices can apply without reference counting. A struct can contain a
Cell<u32> or OnceCell<String> directly and lend shared references to it.
Copy-on-write is not shared mutation. In this example, changing one handle leaves another owner's vector unchanged:
use std::rc::Rc;
fn main() {
let mut current = Rc::new(vec![1, 2]);
let previous = Rc::clone(¤t);
Rc::make_mut(&mut current).push(3);
assert_eq!(&*previous, &[1, 2]);
assert_eq!(&*current, &[1, 2, 3]);
}
For a cloneable payload, make_mut clones it when another strong owner
exists. With one strong owner and only weak observers, it can dissociate
those weak handles instead. It needs mutable access to the handle itself.
Choose this behavior for independent versions, not when every owner should
observe the same edit.
Understand the failure modes
Cell has no runtime borrow counter. get() requires T: Copy, but
replacement also works with non-Copy values; with exclusive access,
get_mut() provides &mut T. For example:
use std::cell::Cell;
fn main() {
let name = Cell::new(String::from("draft"));
let old = name.replace(String::from("ready"));
assert_eq!(old, "draft");
assert_eq!(name.into_inner(), "ready");
}
A lack of borrow checks does not make every operation panic-free. A Cell
update callback, a payload destructor or a clone used by make_mut can panic.
When a non-reentrant initializer panics, OnceCell::get_or_init propagates
the panic and leaves the cell uninitialized. Reentrant initialization is
an error.
Exclusive access permits taking a OnceCell value out and initializing it
again. The once-only rule describes shared access, not an irreversible state
for every owner. LazyCell and LazyLock have different failure behavior:
an initializer panic poisons the lazy value, so later forcing also panics.
RefCell::borrow and borrow_mut panic when their requested access conflicts
with a live guard. Their try_ counterparts report a borrow error instead:
use std::cell::RefCell;
fn main() {
let values = RefCell::new(vec![1]);
let reader = values.borrow();
assert!(values.try_borrow_mut().is_err());
assert_eq!(reader[0], 1);
drop(reader);
values.try_borrow_mut().unwrap().push(2);
assert_eq!(*values.borrow(), [1, 2]);
}
Keep guards short-lived when callbacks or recursive traversal may need the
same cell. Using try_borrow_mut is useful only if the caller handles the
conflict appropriately; immediately unwrapping an unexpected error still
panics. Locks introduce other concerns, such as contention, deadlock and
poison handling. Choose based on the access protocol, not a promise that
one wrapper removes all failures.
Crossing a thread boundary
Arc makes reference counting thread-safe; it does not make arbitrary
payload operations thread-safe. For the usual global allocator,
Arc<T> is Send and Sync when T is both Send and Sync.
Rc<T> is neither, even if its payload is safe to share.
Arc<RefCell<T>> is a valid type and can be used locally, but it cannot be
sent or shared across threads because RefCell<T> is not Sync. Clippy can
flag this combination with arc_with_non_send_sync even though the type
is legal. If
independent threads need shared mutable ownership, Arc<Mutex<T>> may fit.
If only a numeric state needs concurrent updates, an atomic type may fit,
provided its operations and memory ordering express the required protocol.
Check your understanding A configuration is built at startup and then read by six subsystems. Would you use Rc<RefCell<Config>>, and what changes if the subsystems run on threads? Show answer Hide answer
With no mutation, RefCell adds an access mechanism the configuration does
not need. Use &Config if a common owner outlives the readers, or Rc<Config>
when single-threaded subsystems need independent ownership.
Across threads, scoped readers can borrow a Config that is Sync.
Independent owning handles can use Arc<Config> when its payload meets
the thread-safety bounds. A process-wide OnceLock<Config> is another
option if global lifetime and access are intentional; it also introduces
global initialization and testing concerns. None of these choices requires
mutable borrowing merely to read the configuration.
Costs to check in your design
Reference-counted handles normally share one allocation for the value and counters. Cloning a handle does not clone the payload. Dropping the last strong handle drops the value; remaining weak handles can keep the backing allocation alive until they are dropped too.
Arc uses atomic count updates, while Rc does not. The difference depends
on the processor, contention and workload; there is no universal speed ratio.
Box normally adds an allocation for a nonzero-sized value and a pointer
indirection. Cell<T> has the same size and alignment as T, but copying or
replacing a large payload still costs work. RefCell tracks active borrows.
Locks have platform-dependent implementations and may block under contention.
Measure the operations your application actually performs rather than
comparing type names alone.
Common combinations express different relationships:
| Type | Meaning or consideration |
|---|---|
Rc<RefCell<T>> |
Multiple owners of one dynamically borrowed mutable value |
Rc<Cell<T>> |
Multiple owners using replacement or Copy-value access |
RefCell<Rc<T>> |
A mutable slot holding a shared handle; replacing it does not alter other owners' handles |
Rc<str> or Rc<[T]> |
Shared, fixed-size contents stored with the reference-counted allocation |
Rc<String> or Rc<Vec<T>> |
Shared ownership of a growable container; capacity and container APIs may be useful with exclusive or copy-on-write access |
Vec<Box<T>> |
Separately allocated values whose addresses need not change when the outer vector grows |
Converting an existing String or Vec to a reference-counted slice can
allocate and move or copy contents. Fewer retained allocations do not imply
a free conversion. Rc<Box<T>> and Box<Vec<T>> often add unnecessary
indirection, but an API, layout or address-stability requirement can justify
them. Clippy lints such as redundant_allocation, vec_box and
box_collection are prompts to inspect the design, not universal bans.
A type alias can make a nested type readable without changing its behavior.
Trees: shared nodes or an arena?
Check your understanding An AST owns its children. A later pass annotates nodes and walks from a node to its parent. What would a shared-pointer design and an index-arena design look like? Show answer Hide answer
A shared-pointer design can use Rc<RefCell<Node>> for child ownership and
Weak<RefCell<Node>> for a parent link inside each node. A child can then
outlive its parent; upgrading the parent link may return None. Keep dynamic
borrows short enough for the traversal's access pattern.
An arena can use Vec<Node> with a NodeId index, Option<NodeId> for the
parent and Vec<NodeId> for children. The arena owns the nodes together,
while links identify locations rather than owning targets. Vector indexing
still checks bounds, child vectors can allocate separately, and deletion or
reordering requires an id policy. Generational ids can help detect reuse of
removed slots. Whether the arena is Send or Clone depends on its fields;
serialization still needs an implementation.
For a pass-oriented AST with one lifetime and stable ids, an arena is a practical starting point. Independently owned nodes may suit callbacks or caches with different lifetimes. Pick the lifetime and access model first; neither representation wins every workload.
This small shared-node example shows a parent disappearing without keeping an ownership cycle alive:
use std::cell::RefCell;
use std::rc::{Rc, Weak};
struct Node {
parent: Weak<RefCell<Node>>,
children: Vec<Rc<RefCell<Node>>>,
}
fn main() {
let root = Rc::new(RefCell::new(Node {
parent: Weak::new(), children: Vec::new(),
}));
let child = Rc::new(RefCell::new(Node {
parent: Rc::downgrade(&root), children: Vec::new(),
}));
root.borrow_mut().children.push(Rc::clone(&child));
assert!(child.borrow().parent.upgrade().is_some());
drop(root);
assert!(child.borrow().parent.upgrade().is_none());
}
Strong Rc or Arc cycles can retain values after outside owners disappear.
Choose which edges own their targets and ensure cycles are broken or
explicitly dismantled. Weak parent links are one solution; arena ids or
borrowed links are others. Not every edge called a back-edge must literally
use Weak.
Before choosing a wrapper, write down the required lifetime, who must see edits, whether access crosses threads, and how conflicts or initialization failures should be handled. Those requirements provide a more useful guide than a fixed ranking of pointer types.