Rust Patterns That Matter #10: Lifetime Annotations
Post 10 of 22 in Rust Patterns That Matter. Companion series: Building a Chat Server in Rust.
Previous: #9: Drop and RAII | Next: #11: Cow
You write a function that returns a reference, or a struct that holds one. The compiler demands a lifetime annotation and you freeze. What do you write? What does it mean? The confusion usually comes from thinking lifetimes are instructions to the compiler. They're not. They're descriptions of relationships that already exist.
The motivation
A function takes two string slices and returns whichever is longer:
fn longest(a: &str, b: &str) -> &str {
if a.len() > b.len() { a } else { b }
}
The compiler:
error[E0106]: missing lifetime specifier
-- this function's return type contains a borrowed value,
but the signature does not say whether it is borrowed
from `a` or `b`
The return type is a reference. It must refer to something that outlives it. But the
compiler can't tell whether the returned reference comes from a or
b (it depends on runtime values), so it can't check safety without
your help.
Elision rules: what the compiler infers
Most of the time, you don't need lifetime annotations. Rust has three elision rules that let the compiler fill them in:
- Each reference parameter gets its own lifetime:
fn foo(a: &str, b: &str)becomesfn foo<'a, 'b>(a: &'a str, b: &'b str) - If there's exactly one input lifetime, it's applied to all output references:
fn foo(a: &str) -> &strbecomesfn foo<'a>(a: &'a str) -> &'a str - If one of the parameters is
&selfor&mut self, its lifetime is applied to all output references
The longest function fails because it has two input lifetimes
and neither is &self. The compiler can't pick one. You have to.
The mental model: constraints, not instructions
A lifetime annotation says: "these references are related." It does not make data live longer or shorter. You're describing a fact about your code so the compiler can check it.
fn longest<'a>(a: &'a str, b: &'a str) -> &'a str {
if a.len() > b.len() { a } else { b }
}
Read this as: "the returned reference is valid for at least as long as the shorter
of a and b." Both inputs share the lifetime
'a, so the compiler interprets 'a as the intersection
- the region where both are valid. The return value is constrained to that
region.
Now the compiler can check every call site. If a lives for 10 lines and
b lives for 5 lines, the returned reference is valid for at most 5
lines. If someone tries to use it on line 7, the compiler rejects it.
Multiple lifetimes
Sometimes the inputs have independent lifetimes and you want to express which one the return borrows from:
fn first<'a, 'b>(a: &'a str, b: &'b str) -> &'a str {
a
}
This says: "the return borrows from a only. b's lifetime
is irrelevant." The compiler knows b can be dropped without affecting
the returned reference.
Structs that hold references
When a struct stores a reference, it needs a lifetime parameter to express the constraint: the struct can't outlive the data it borrows.
struct Excerpt<'a> {
text: &'a str,
}
fn main() {
let excerpt;
{
let novel = String::from("Call me Ishmael...");
excerpt = Excerpt { text: &novel };
} // novel dropped here
// println!("{}", excerpt.text); // ERROR: `novel` does not live long enough
}
The lifetime 'a in Excerpt<'a> means: "this struct
borrows from something, and it can't outlive that something." The compiler catches
the dangling reference at compile time.
When lifetimes get painful
If you find yourself adding lifetime parameters to struct after struct, passing them through generic bounds, and fighting increasingly complex error messages, step back. The complexity is usually a signal.
The escape hatch is ownership: use String instead of &str.
Use Vec<T> instead of &[T]. Owned types have no
lifetime constraints. The struct that holds a String can live anywhere,
be sent to threads, be stored in collections, without any of the restrictions that
references bring.
This isn't a cop-out. Owning data is often the right design. References are an
optimisation - they avoid copies by borrowing in place. If the optimisation
is costing you more in complexity than it saves in allocation, own the data.
The next post covers this trade-off directly:
'static + Clone.
When to use lifetime annotations
- Functions that return a reference derived from an input reference, where elision doesn't apply
- Structs that borrow data (parsers holding a
&str, iterators holding a&[T]) - Trait bounds where borrowed data flows through
When annotations get deeply nested or thread through many layers of generics, it's usually a sign to switch from borrowing to owning.
See it in practice: Building a Chat Server #3: Parsing and Performance uses this pattern for zero-copy frame parsing.
Telex