Reading Rust

Part 2 · The compile-time machine I — borrows & lifetimes

Reading lifetime annotations

The practical half. Elision rules, the anonymous lifetime, the two meanings of `static`, and enough variance to follow the conversation.

50 minbeacon trainingPOEcontrasting cases
What this lesson is trying to break (3)
  • Most functions have no lifetimes because they do not need any.
  • `T: static` means the value lives for the whole program.
  • If two things have the same lifetime name, they must be equally long-lived.

Lesson 2.3 was the model. This one is the reading practice: what you actually see in signatures, and how to decode it fast.

Why most functions show no lifetimes

They have them. They are hidden.

fn first_word(s: &str) -> &str { /* ... */ }

The compiler expands this to:

fn first_word<'a>(s: &'a str) -> &'a str { /* ... */ }

That expansion is lifetime elision, and it runs on exactly three rules. Learn them once; they explain nearly every signature you will read.

Varies: the number and kind of input references

RuleWhen it appliesEffect
1Always, to inputsEach elided input lifetime gets its own fresh parameter.
2Exactly one input lifetimeThat lifetime is given to all elided output lifetimes.
3Several inputs, one of which is &self or &mut selfself’s lifetime is given to all elided output lifetimes.

If no rule determines an output lifetime, elision fails and the compiler demands an explicit annotation. That error is the compiler saying it cannot guess which input the output borrows from.

Rule 3 is why methods almost never need annotations — and it encodes a real assumption: if a method returns a reference, it probably borrows from self, not from the arguments. That is right the overwhelming majority of the time.

Now watch elision fail:

fn longest(x: &str, y: &str) -> &str { /* ... */ }

Rule 1 gives x and y separate lifetimes. Rule 2 does not apply — there are two. Rule 3 does not apply — no self. So the output lifetime is undetermined:

error[E0106]: missing lifetime specifier
  |
  = help: this function's return type contains a borrowed value, but the
    signature does not say whether it is borrowed from `x` or `y`

Read that error as “tell me which input the output borrows from”. That is all it wants. And it is exactly why longest needs the annotation while first_word does not — not because one is more dangerous, but because one is ambiguous.

Predict firstcommit in writing, then look

What does elision expand this to?

impl Parser {
    fn parse<'x>(&self, input: &'x str) -> &str { /* ... */ }
}

The anonymous lifetime '_

Beaconsee this → think this, without decoding it

Foo<'_> · impl Trait for Foo<'_> · fn f(x: &str) -> Bar<'_>

“There is a lifetime here; infer it.” '_ does not name a region — it marks the presence of one.

The value is honesty. Writing bare Foo when Foo has a lifetime parameter hides the fact that the type borrows at all. Foo<'_> makes it visible while staying silent about which region. The elided_lifetimes_in_paths lint exists to push code in this direction, and modern crates mostly follow it.

When you see '_ in a return type, read: this return value borrows from something in the arguments.

'static, both of them

This is the highest-value item in the lesson. 'static means two different things depending on position, and mixing them up is the most common lifetime error among people who otherwise know what they are doing.

Varies: only where the 'static appears

FormMeaningExample that satisfies it
&'static T
a reference type
This reference is valid for the entire program. The data is in the binary or was deliberately leaked."hello" (type &'static str), Box::leak(b)
T: 'static
a bound
This type contains no borrowed data with a shorter region. It says nothing about how long any value lives.String, i32, Vec<u8>, &'static str — but not &'a str

A `String` created and dropped in one line satisfies `T: 'static`. Nothing about that is long-lived. The bound is about *borrowing*, not about *duration*.

The example that makes it concrete:

tokio::spawn(async move { /* ... */ });

spawn requires F: Future + Send + 'static. That is the bound form. It means: this future must not borrow anything from the stack frame that spawned it — because the task may still be running after that frame returns.

It does not mean the future must live for the whole program. Most spawned tasks finish in milliseconds.

So when the compiler tells you `x` does not live long enough ... requires `'static` inside a spawn, the fix is never “make it live longer”. It is: move the data into the task (async move), or clone it, or wrap it in an Arc. That is one of the most common questions asked by people new to async Rust, and knowing why is a genuine credibility marker.

Lifetimes on structs

struct Excerpt<'a> {
    part: &'a str,
}

Read this as: this struct borrows, and it cannot outlive what it borrows from. The 'a is not describing the struct — it is a tag saying “there is a reference inside me, and here is the region it depends on”.

The practical consequence is the one that catches people building larger programs: a struct with a lifetime parameter cannot be stored just anywhere. It cannot be put in a 'static global, returned from a function whose inputs it does not borrow from, or sent to a tokio::spawn. The lifetime propagates outward through every type that contains it.

This is why experienced Rust programmers often reach for owned data (String instead of &'a str) in struct fields even at the cost of an allocation. Not laziness — a deliberate trade of a small runtime cost against a large amount of lifetime propagation through the codebase. Being able to explain that trade-off is a design-review skill.

Higher-ranked bounds

Beaconsee this → think this, without decoding it

for<'a> Fn(&'a str) -> &'a str · F: for<'de> Deserialize<'de>

“Must work for every region, not one the caller picks.”

Ordinary generic lifetimes are chosen by the caller. A higher-ranked bound reverses that: the callee will supply references whose regions it invents, and your closure must handle all of them.

You meet it in exactly two places, and then constantly:

  • closures that receive a reference the callee creates — a callback given a borrowed line from a file, say, where the region is per-iteration
  • serdeDeserializeOwned is defined as for<'de> Deserialize<'de>, meaning “deserialisable from a buffer of any lifetime”

Most of the time this is elided too: Fn(&str) -> &str already means for<'a> Fn(&'a str) -> &'a str.

Variance, at reading level

You do not need the full theory. You need enough to follow a conversation and to recognise one error.

Covariant means a longer region can substitute for a shorter one. &'a T is covariant in 'a, which is why you can pass a &'static str to something expecting &'a str. This is the case you rely on constantly without noticing.

Invariant means no substitution at all — the regions must match exactly. &'a mut T is invariant in T, and so are Cell<T> and RefCell<T>.

The one error this explains:

fn set_static(v: &mut Vec<&'static str>) { }

fn main() {
    let s = String::from("x");
    let mut v: Vec<&str> = vec![&s];
    set_static(&mut v);        // error — lifetime may not live long enough
}

Passing &mut Vec<&'a str> where &mut Vec<&'static str> is expected is rejected even though 'static is “longer”, because &mut is invariant in its contents. If it were allowed, the function could push a &'static str into the vector — fine — but it could equally hand out the vector’s short-lived contents as 'static. Invariance blocks both directions at once.

You will not write variance annotations; there are none to write. You just need to recognise the phrase when a lifetime error mentions it, and know that the answer is usually “because &mut is invariant”.

Explain it to yourselfwrite it — thinking it does not count

A colleague is stuck. They wrote:

async fn handle(cfg: &Config) {
    tokio::spawn(async move {
        use_config(cfg).await;
    });
}

and the compiler says the borrow may not live long enough — it requires 'static.

Explain why, using the bound meaning of 'static, and give them two different fixes with the trade-off of each.

What you should now be able to say

  • Three elision rules; rule 3 ties elided output lifetimes to self, which is usually right and occasionally a trap.
  • '_ means there is a lifetime here, infer it — and signals that a value borrows.
  • &'static T is a reference valid for the whole program. T: 'static means no borrowed data. Different things.
  • A lifetime on a struct propagates outward through everything containing it, which is why owned fields are often the right call.
  • for<'a> means “for every region”, and you meet it in closures and serde.
  • &mut is invariant, which is the answer to a whole family of confusing errors.

Next: 2.5 — The borrow checker’s errors as a curriculum, which turns the eight errors you will actually hit into a diagnostic table.