matsakis-ownership-mastery
Niko Matsakis Style Guide
Overview
Niko Matsakis is the architect of Rust's borrow checker and a driving force behind the language's type system. His blog "Baby Steps" and work on Polonius (the next-gen borrow checker) define how Rustaceans think about ownership.
Core Philosophy
"The borrow checker is not your enemy—it's your pair programmer."
"Lifetimes are not about how long data lives; they're about how long borrows are valid."
Matsakis sees the borrow checker as a tool that encodes knowledge about your program. Fighting it usually means your mental model is wrong.
Design Principles
-
Trust the Borrow Checker: It knows things about your code you haven't realized yet.
-
Lifetimes Are Relationships: They describe how references relate, not absolute durations.
-
Ownership Shapes APIs: Good APIs make ownership transfer obvious.
-
Minimize Lifetime Annotations: If the compiler can infer it, don't write it.
When Writing Code
Always
- Understand why the borrow checker rejects code before "fixing" it
- Use lifetime elision rules—don't annotate unnecessarily
- Design structs with ownership in mind
- Prefer owned types in structs, borrowed in function parameters
- Use
'_(anonymous lifetime) when you don't care about the specific lifetime
Never
- Add
'staticjust to make code compile - Use
Rc<RefCell<T>>as a first resort (it's a last resort) - Clone to avoid borrow checker errors without understanding why
- Create self-referential structs naively
Prefer
&Tparameters overTfor read-only access&mut ToverRefCell<T>when possible- Returning owned values over returning references (usually)
- Splitting borrows instead of fighting the checker
- NLL (non-lexical lifetimes) patterns
Code Patterns
Understanding Lifetime Elision
// Lifetime elision rules mean you rarely write explicit lifetimes
// Rule 1: Each elided lifetime in input gets its own parameter
fn print(s: &str) { } // Actually: fn print<'a>(s: &'a str)
// Rule 2: If there's exactly one input lifetime, it's assigned to all outputs
fn first_word(s: &str) -> &str { } // Actually: fn first_word<'a>(s: &'a str) -> &'a str
// Rule 3: If there's &self or &mut self, its lifetime is assigned to outputs
impl MyStruct {
fn method(&self) -> &str { } // Actually: fn method<'a>(&'a self) -> &'a str
}
// Only annotate when elision doesn't apply
fn longest<'a>(x: &'a str, y: &'a str) -> &'a str {
if x.len() > y.len() { x } else { y }
}
Splitting Borrows
struct Data {
field1: String,
field2: Vec<i32>,
}
// BAD: Borrow checker sees whole struct borrowed
fn bad(data: &mut Data) {
let f1 = &mut data.field1;
let f2 = &mut data.field2; // ERROR: data already borrowed
// ...
}
// GOOD: Borrow disjoint fields separately
fn good(data: &mut Data) {
let Data { field1, field2 } = data;
// Now field1 and field2 are separate borrows
field1.push_str("hello");
field2.push(42);
}
// Or use methods that return split borrows
impl Data {
fn split(&mut self) -> (&mut String, &mut Vec<i32>) {
(&mut self.field1, &mut self.field2)
}
}
The Borrow Checker as Design Guide
// When the borrow checker complains, ask: "What is it telling me?"
// BAD: Trying to hold reference while modifying
fn bad_design(items: &mut Vec<String>) {
for item in items.iter() { // Immutable borrow
if item.starts_with("remove") {
items.retain(|s| s != item); // ERROR: can't mutate while borrowed
}
}
}
// GOOD: Collect indices first, then modify
fn good_design(items: &mut Vec<String>) {
let to_remove: Vec<_> = items
.iter()
.filter(|s| s.starts_with("remove"))
.cloned()
.collect();
items.retain(|s| !to_remove.contains(s));
}
// BETTER: drain_filter (nightly) or swap_remove pattern
fn better_design(items: &mut Vec<String>) {
items.retain(|s| !s.starts_with("remove"));
}
Lifetime Bounds in Generics
// 'a: 'b means 'a outlives 'b
struct Parser<'input> {
input: &'input str,
}
impl<'input> Parser<'input> {
// Output lifetime tied to input lifetime
fn parse(&self) -> Token<'input> {
Token { text: &self.input[0..5] }
}
}
// Trait bounds with lifetimes
fn process<'a, T>(item: &'a T) -> &'a str
where
T: AsRef<str> + 'a, // T must live at least as long as 'a
{
item.as_ref()
}
// Higher-ranked trait bounds (HRTB) for callbacks
fn with_callback<F>(f: F)
where
F: for<'a> Fn(&'a str) -> &'a str, // F works for ANY lifetime
{
let s = String::from("hello");
let result = f(&s);
println!("{}", result);
}
Interior Mutability (When Needed)
use std::cell::{Cell, RefCell};
// Cell: for Copy types, no borrow checking at runtime
struct Counter {
count: Cell<usize>,
}
impl Counter {
fn increment(&self) { // Note: &self, not &mut self
self.count.set(self.count.get() + 1);
}
}
// RefCell: runtime borrow checking (panics if violated)
struct CachedComputation {
value: i32,
cache: RefCell<Option<i32>>,
}
impl CachedComputation {
fn compute(&self) -> i32 {
let mut cache = self.cache.borrow_mut();
if let Some(cached) = *cache {
return cached;
}
let result = expensive_computation(self.value);
*cache = Some(result);
result
}
}
// Use interior mutability ONLY when external mutability won't work
// (e.g., shared ownership, trait requirements)
Self-Referential Structs (The Right Way)
// PROBLEM: Can't have a struct reference its own field
// struct Bad {
// data: String,
// slice: &str, // Can't reference data!
// }
// SOLUTION 1: Store indices, not references
struct Good {
data: String,
start: usize,
end: usize,
}
impl Good {
fn slice(&self) -> &str {
&self.data[self.start..self.end]
}
}
// SOLUTION 2: Use Pin for self-referential async code
use std::pin::Pin;
// SOLUTION 3: Use ouroboros or self_cell crates for complex cases
Mental Model
Matsakis thinks about borrows as capabilities:
&T= capability to read&mut T= exclusive capability to read and write- The borrow checker ensures capabilities don't conflict
- Lifetimes = how long a capability is valid
Niko's Debugging Questions
When the borrow checker rejects code:
- What capability am I trying to use?
- What other capability conflicts with it?
- Can I restructure to avoid the conflict?
- Is the borrow checker revealing a real bug?