Trust is earned, not given

A different perspective

2023-06-20 · Projects

Rust, part 6: lifetimes without the fear

Part 6from the Rust series · 15 parts in all

Lifetimes have a reputation far beyond their difficulty, for a reason that is worth naming: most of the time they are invisible. You write them when the compiler cannot infer the relationship between references, which is exactly when a human reader needs them too. Part 6 is the small set of rules that covers nearly every case.

A lifetime is a claim about how long a reference stays valid

// The signature says: the result borrows from BOTH inputs, so it may not
// outlive either of them.
fn longest<'a>(a: &'a str, b: &'a str) -> &'a str {
    if a.len() >= b.len() { a } else { b }
}

No runtime thing happens here. 'a is a name for a region of the source, and the annotations are the compiler's way of checking that nothing you return outlives the data it points into. That is why the error message is about the caller the compiler could not prove safe.

Three elision rules cover most annotations

You usually write nothing, because rustc applies these in order:

  1. Each elided input reference gets its own lifetime.
  2. If there is exactly one input lifetime, it is assigned to all output references.
  3. If one of the inputs is &self or &mut self, its lifetime is assigned to all output references.

Rule 3 is why methods rarely need annotations and free functions with two references do. Structures that borrow are the other place it becomes explicit — and there the annotation is documentation:

// This parser does not own its input; it must not outlive it.
struct Parser<'i> {
    input: &'i str,
    pos: usize,
}

impl<'i> Parser<'i> {
    fn rest(&self) -> &'i str { &self.input[self.pos..] }
}

&self borrows the parser for the call; &'i str returns data that lives as long as the input did. Those are different lengths, and the difference is the whole reason both appear in one line.

The static escape hatch, and its cost

When a value has no natural owner to borrow from, the alternatives are owning it (String, Vec) or leaking it (Box::leak, OnceLock) to get a 'static. Leaking is sometimes right for configuration read once at start-up. As a habit it is wrong, because the point of lifetimes is that the compiler is tracking something real — reach for 'static and you have told it to stop.

The practical advice, after the theory: when a lifetime error appears, look at the signature first. The body is usually fine, and the compiler is objecting to a function that claims a relationship between its inputs and output that it cannot prove — which is a design question, not a syntax one. Next: the async model, which is where all of the above gets interesting again.