10 patterns, 677 lines. Full spec compliance. Patterns: borrowing over owning, Clone/Copy, Cow, mem::take, Box, Arc, Drop/RAII, lifetime elision, AsRef, PhantomData.
18 KiB
Rust Ownership & Lifetime Patterns
Patterns for ownership, borrowing, and lifetimes in Rust, extracted from the standard library source.
Source: rust-lang/rust at commit
f53b654
Stats: 7,909 &mut references, 798 Rc/Arc, 697 Box<>, 410 Cell/RefCell, 197 Cow<>, 254 Drop impls, 401 PhantomData, 112 mem::take/replace.
1. Borrowing Over Owning in Parameters
Source:
library/alloc/src/string.rs (String/str relationship)
// The stdlib accepts &str not String for read-only access:
impl str {
pub fn contains<P: Pattern>(&self, pat: P) -> bool { ... }
pub fn starts_with<P: Pattern>(&self, pat: P) -> bool { ... }
}
Why
Taking &T instead of T means the caller keeps ownership. This
is the most fundamental Rust pattern: borrow when you only need to
read, own when you need to keep or modify.
When to Use
Triggers:
- You only need to read the value (don't need to store it)
- You want to accept both owned and borrowed values (String and &str)
- You don't want to force callers to clone
Example — before:
// Takes ownership unnecessarily — forces caller to clone
fn greet(name: String) {
println!("Hello, {name}!");
}
let name = String::from("Alice");
greet(name); // name is moved — can't use it anymore
Example — after:
// Borrows — caller keeps ownership
fn greet(name: &str) {
println!("Hello, {name}!");
}
let name = String::from("Alice");
greet(&name); // works with &String (deref coercion)
greet("Bob"); // works with &str directly
// name is still usable here
When NOT to Use
Don't use this when:
- You need to store the value in a struct (take ownership)
- You need to send it to another thread (ownership needed for Send)
- The function will outlive the caller (return a Future that needs 'static)
2. Clone vs Copy Semantics
Source:
library/core/src/clone.rs, library/core/src/marker.rs (Copy)
Top derives: Clone (880), Copy (537).
// Copy = implicit bitwise copy (stack only, no heap)
#[derive(Clone, Copy)]
pub struct Duration { secs: u64, nanos: Nanoseconds }
// Clone only = explicit .clone() needed (may allocate)
#[derive(Clone)]
pub struct String { vec: Vec<u8> } // Can't be Copy — heap allocated
Why
Copy types are implicitly duplicated on assignment (like integers).
Clone types require explicit .clone(). The distinction prevents
accidental expensive copies. If you can be Copy, you should be.
When to Use
Triggers:
- Copy: Type is small, stack-only, bitwise-copyable (no heap, no Drop impl, no &mut interior)
- Clone only: Type allocates, has Drop, or copying is expensive
Example — before:
// Missing Copy on a small stack type
struct Point { x: f64, y: f64 }
let p = Point { x: 1.0, y: 2.0 };
let q = p; // MOVED — p is now invalid
// println!("{}", p.x); // ERROR: use after move
Example — after:
#[derive(Clone, Copy)]
struct Point { x: f64, y: f64 }
let p = Point { x: 1.0, y: 2.0 };
let q = p; // COPIED — p is still valid
println!("{}", p.x); // Works fine
When NOT to Use
Don't use this when:
- Type has heap allocation (Vec, String, Box) — can't be Copy
- Type has a Drop impl — can't be Copy
- Implicit copying would be expensive or surprising
- Adding Copy now would be a breaking change to remove later
Anti-pattern
// DON'T: Derive Copy on something that might grow
#[derive(Clone, Copy)]
struct Config {
port: u16,
// Later you add: name: String — BREAKS because String isn't Copy
}
// DO: Only derive Copy on types that are permanently small and stack-only
3. Cow<'a, B> (Clone on Write)
Source:
library/alloc/src/borrow.rs#L169
pub enum Cow<'a, B: ?Sized + 'a> where B: ToOwned {
Borrowed(&'a B),
Owned(<B as ToOwned>::Owned),
}
197 usages in the stdlib.
Why
Cow delays cloning until mutation is needed. If you only read, you pay zero allocation cost (borrowed). If you need to modify, it clones on first write. This is the "avoid allocation in the common case" pattern.
When to Use
Triggers:
- A function usually returns borrowed data but sometimes needs to allocate (e.g., string escaping — most strings need no change)
- You want to accept either owned or borrowed without forcing a clone
- Performance matters and most inputs don't need modification
Example — before:
// Always allocates even when input needs no change
fn escape(s: &str) -> String {
if s.contains('<') {
s.replace('<', "<") // allocates
} else {
s.to_string() // ALSO allocates — unnecessary!
}
}
Example — after:
use std::borrow::Cow;
fn escape(s: &str) -> Cow<'_, str> {
if s.contains('<') {
Cow::Owned(s.replace('<', "<")) // allocates only when needed
} else {
Cow::Borrowed(s) // zero-cost — just returns a reference
}
}
When NOT to Use
Don't use this when:
- You always modify the value (just return the owned type)
- The lifetime makes the API confusing for callers
- The performance difference is negligible
4. mem::take / mem::replace (Move Out of &mut)
Source:
112 usages of mem::take/mem::replace in the stdlib.
pub fn take<T: Default>(dest: &mut T) -> T {
replace(dest, T::default())
}
pub fn replace<T>(dest: &mut T, src: T) -> T {
// Swaps src into dest, returns old dest
unsafe { ... }
}
Why
You can't move out of a &mut T directly (would leave the reference
dangling). mem::take solves this by replacing with Default and
giving you the old value. This is the idiomatic way to "take
ownership through a mutable reference."
When to Use
Triggers:
- You have
&mut selfand need to move a field out - You're implementing state machines (swap states)
- You need to "reset" a field while extracting its value
Example — before:
struct Parser {
buffer: Vec<u8>,
}
impl Parser {
fn flush(&mut self) -> Vec<u8> {
let result = self.buffer.clone(); // EXPENSIVE clone
self.buffer.clear();
result
}
}
Example — after:
use std::mem;
impl Parser {
fn flush(&mut self) -> Vec<u8> {
mem::take(&mut self.buffer) // moves buffer out, replaces with empty Vec
// Zero allocations, zero copies
}
}
When NOT to Use
Don't use this when:
- The type doesn't implement Default
- You need to keep the original value (use clone)
- You're in a consuming method (just take self)
5. Box for Heap Allocation
Source:
697 Box<> usages in library/.
Why
Box is the simplest smart pointer: single-owner heap allocation. Use it when you need a value on the heap with known, fixed ownership. Recursive types REQUIRE boxing (otherwise infinite size).
When to Use
Triggers:
- Recursive data structures (tree nodes, linked lists)
- Trait objects (
Box<dyn Error>,Box<dyn Fn()>) - Large values you want on the heap to avoid stack overflow
- Transferring ownership without copying large structs
Example — before:
// COMPILE ERROR: recursive type has infinite size
enum Tree {
Leaf(i32),
Node(Tree, Tree), // How big is Tree? Depends on Tree... infinite
}
Example — after:
enum Tree {
Leaf(i32),
Node(Box<Tree>, Box<Tree>), // Box is pointer-sized — finite!
}
When NOT to Use
Don't use this when:
- The value is small and stack allocation is fine
- You need shared ownership (use Rc/Arc)
- You need interior mutability (use RefCell or Mutex)
- You're boxing just to avoid lifetime annotations (fix the lifetimes)
6. Arc for Shared Ownership Across Threads
Source:
798 Rc/Arc usages in library/.
Why
Arc (Atomic Reference Counted) enables multiple owners across threads. The data lives on the heap; cloning an Arc increments the reference count (not the data). Data is freed when the last Arc is dropped.
When to Use
Triggers:
- Multiple threads need read access to the same data
- Ownership is shared (no single owner)
- Combined with Mutex for shared mutable state:
Arc<Mutex<T>>
Example — before:
// Can't send &data to another thread — lifetime issue
fn spawn_with_data(data: &Config) {
std::thread::spawn(|| {
// ERROR: borrowed data may not outlive the scope
println!("{}", data.name);
});
}
Example — after:
use std::sync::Arc;
fn spawn_with_data(data: Arc<Config>) {
let data = Arc::clone(&data); // cheap: just increments counter
std::thread::spawn(move || {
println!("{}", data.name); // owns a reference, lives as long as needed
});
}
When NOT to Use
Don't use this when:
- Single-threaded (use Rc — no atomic overhead)
- Single owner is sufficient (use Box)
- You can restructure to avoid shared ownership (prefer this)
- You're using it because you can't figure out lifetimes (usually a design smell)
Anti-pattern
// DON'T: Arc everything because lifetimes are hard
struct App {
db: Arc<Database>,
cache: Arc<Cache>,
config: Arc<Config>,
logger: Arc<Logger>,
}
// If App owns all of these, just use owned fields!
// Arc is for SHARING, not for avoiding lifetime annotations.
7. Drop for Cleanup (RAII)
Source:
254 Drop implementations in the stdlib.
pub trait Drop {
fn drop(&mut self);
}
Why
Drop runs automatically when a value goes out of scope. This is RAII: resources are tied to ownership. File handles close, locks release, memory frees — all automatically, without try/finally or defer.
When to Use
Triggers:
- Your type holds an external resource (file, socket, lock)
- You need cleanup code to run even on panic
- You're implementing a smart pointer or wrapper
Example — before (in another language):
# Must remember to close — easy to forget on error paths
f = open("data.txt")
try:
process(f)
finally:
f.close() # What if we forget this?
Example — in Rust:
// Drop handles cleanup automatically
{
let f = File::open("data.txt")?;
process(&f)?;
} // f.drop() called here — file closed automatically
// Even if process() panics, Drop still runs during unwinding
When NOT to Use
Don't use this when:
- Type doesn't hold external resources (just let memory free)
- You need Copy (can't impl both Drop and Copy)
- Cleanup order matters precisely (Drop order is reverse of creation, but this isn't always what you want — use explicit close methods)
8. Lifetime Elision (Let the Compiler Infer)
Source:
The Rust compiler applies 3 elision rules automatically. The stdlib exploits this — most function signatures DON'T write lifetimes:
// Written in source (elided):
fn first_word(s: &str) -> &str { ... }
// What the compiler actually sees:
fn first_word<'a>(s: &'a str) -> &'a str { ... }
1,206 explicit lifetime annotations exist in library/ — only where elision rules don't apply.
Why
Lifetimes are inferred in the common case. Only annotate when:
- Multiple references in input (compiler can't guess which one the output borrows from)
- Struct fields that borrow
- Static or complex relationships
When to Use
Triggers:
- Function has one reference parameter and returns a reference (rule 1: output gets input's lifetime)
- Method has
&selfand returns a reference (rule 3: output gets self's lifetime)
Example — before:
// Unnecessary explicit lifetimes
fn longest<'a>(x: &'a str, y: &'a str) -> &'a str {
if x.len() > y.len() { x } else { y }
}
// This NEEDS annotations because there are TWO input references
Example — after:
// Let elision work when it can:
fn trim(s: &str) -> &str { s.trim() }
// No annotations needed — one input ref, one output ref → same lifetime
// Only annotate when the compiler ASKS you to
fn longest<'a>(x: &'a str, y: &'a str) -> &'a str { ... }
When NOT to Use
Don't use this when:
- Multiple input references exist (compiler can't infer which)
- Struct borrows from multiple sources
- The relationship is non-obvious (annotate for clarity even if the compiler could infer it)
9. AsRef/Into for Flexible Function Parameters
Source:
library/core/src/convert/mod.rs
79 AsRef implementations in library/.
// std::fs::read accepts anything that can become a Path:
pub fn read<P: AsRef<Path>>(path: P) -> io::Result<Vec<u8>> { ... }
Why
AsRef<Path> accepts &str, String, PathBuf, &Path — any
type that cheaply references a Path. One function, many input types,
zero allocation.
When to Use
Triggers:
- You want to accept multiple types that can cheaply become &T
- Ergonomics: let callers pass String or &str interchangeably
- The conversion is cheap (reference, not allocation)
Example — before:
// Accepts only &Path — callers must convert manually
fn file_exists(path: &Path) -> bool {
path.exists()
}
file_exists(Path::new("/tmp/foo")); // works
// file_exists("/tmp/foo"); // ERROR — &str isn't &Path
Example — after:
fn file_exists(path: impl AsRef<Path>) -> bool {
path.as_ref().exists()
}
file_exists("/tmp/foo"); // &str works
file_exists(String::from("/tmp")); // String works
file_exists(PathBuf::from("/tmp")); // PathBuf works
When NOT to Use
Don't use this when:
- The function is private/internal (just accept the concrete type)
- The generic bound adds confusion for no real benefit
- You need ownership, not a reference (use
Into<T>instead)
10. PhantomData for Unused Type Parameters
Source:
library/core/src/marker.rs (PhantomData)
401 PhantomData usages in the stdlib.
pub struct PhantomData<T: ?Sized>;
Why
Sometimes you need a type parameter for lifetime or type safety but don't actually store the value. PhantomData tells the compiler "I'm logically related to T" without physically containing T. Used for variance, drop checking, and marker relationships.
When to Use
Triggers:
- Your struct has a type parameter it doesn't store (for type safety)
- You need correct variance (covariant, contravariant, invariant)
- You need the compiler to know your type "owns" a T for drop checking
- Implementing raw pointer wrappers that should act like &T
Example — before:
// COMPILE ERROR: parameter T is never used
struct TypedId<T> {
id: u64,
}
Example — after:
use std::marker::PhantomData;
struct TypedId<T> {
id: u64,
_phantom: PhantomData<T>, // tells compiler this is related to T
}
// Now TypedId<User> and TypedId<Product> are different types!
let user_id: TypedId<User> = TypedId { id: 42, _phantom: PhantomData };
let product_id: TypedId<Product> = TypedId { id: 42, _phantom: PhantomData };
// user_id == product_id // COMPILE ERROR: different types
When NOT to Use
Don't use this when:
- You can just store the T directly
- The type parameter doesn't add safety (just remove it)
- You're cargo-culting PhantomData without understanding variance
Summary: Ownership Decision Tree
Who needs this value?
├── One owner, stack → plain value (no wrapper)
├── One owner, heap → Box<T>
├── Multiple owners, single thread → Rc<T>
├── Multiple owners, multi thread → Arc<T>
└── Need mutation with shared refs?
├── Single thread → RefCell<T> (runtime borrow check)
└── Multi thread → Mutex<T> or RwLock<T>
How to pass to a function?
├── Read only → &T (borrow)
├── Need to modify → &mut T (mutable borrow)
├── Need to store/send → T (ownership transfer)
├── Accept multiple types → impl AsRef<T> or impl Into<T>
└── Might need to clone → Cow<'a, T>
Need to move out of &mut?
└── mem::take (replaces with Default, returns old value)
| Pattern | Use when |
|---|---|
&T parameter |
Read-only access, no ownership needed |
T parameter |
Must store, send, or consume the value |
Clone/Copy |
Small stack types (Copy), expensive heap types (Clone) |
Cow<'a, B> |
Usually borrowed, sometimes needs to own |
mem::take |
Move a field out of &mut self |
Box<T> |
Heap allocation with single owner |
Arc<T> |
Shared ownership across threads |
Drop |
RAII cleanup (files, locks, connections) |
| Lifetime elision | Let compiler infer when rules apply |
AsRef<T> |
Accept multiple types cheaply |
PhantomData<T> |
Unused type parameter for type safety |
See also:
- traits.md — Clone, Copy, Drop trait design
- concurrency.md — Arc/Mutex patterns
- error-handling.md — Ownership in error propagation