Skip to content
academia.sh

Lesson 07 / 14

Explicit Lifetime Annotations

When a function takes multiple references and returns one of them, which input the returned reference is tied to cannot be inferred from the source alone, and an explicit lifetime annotation is required. It is measured that the annotation neither extends nor shortens a reference's life, only writes an existing relationship into the source, and that the compiler validates this writing as a request.

Published Updated

In this lesson

  • Determines when an explicit lifetime annotation is required in a function signature
  • Runs code to show which input the annotation ties the returned reference to
  • Shows that an annotation that cannot be satisfied is rejected, and is accepted once made satisfiable
Contents

The previous lesson showed that two independent references’ ranges can sit side by side without entering any relationship — most references live unaware of each other, and the compiler builds no bond between them. But when a function takes multiple references and returns one of them, independence is no longer enough: which input the returned reference comes from, which input’s life it is tied to, cannot be inferred from the source alone. This lesson measures the explicit syntax that steps in right at this point — the lifetime annotation: when it is needed, what information it adds, and how the compiler treats it as a promise.

What the Go curriculum’s Concurrency course measured does not apply here either: there, a running unit’s stopping was triggered by an outside decision; what is measured here is a relationship written into a source’s own signature. None of the four end classes established in the Stack and Heap lesson operate here either; the annotation does not write down the end of a value, but a relationship between two references.

Writing this relationship is not optional, it is mandatory: the compiler rejects a signature that stays ambiguous without making any assumption. A human reader looking at a two-reference function might guess “probably the first parameter is returned,” but the compiler does not guess — either the relationship is written explicitly or the function does not compile at all. This repeats a principle the course established earlier: the compiler’s guarantee rests on information written in the source, not on a probable correct reading.

When It Needs to Be Written

If a function takes a single reference and returns a reference, which input the returned reference is tied to is obvious: the one input it took. But in a function that takes two references and returns one, that bond becomes ambiguous — the compiler cannot tell from the type in the source alone whether the returned reference is tied to the first input, the second, or both. It is exactly at this moment of ambiguity that a lifetime annotation is needed: a short name written into the function signature (such as 'a) says which input’s life is carried into the returned reference.

This ambiguity is not only about the number of inputs, it is also about the returned value being a reference. If a function takes two references and returns a number or a freshly built value, no annotation is needed at all, because the returned value’s life is not tied to any input — it exists on its own. The annotation only comes into play when the returned value is also a reference and which input that reference comes from cannot be read from the type. The measurement below sets up exactly this situation: a function that takes two Trace references but returns a string slice derived from only one of them.

// two_source_return.rs — the returned reference is tied only to the input carrying 'a.

use std::cell::RefCell;
use std::rc::Rc;

type Ledger = Rc<RefCell<Vec<String>>>;

struct Trace {
    name: String,
    ledger: Ledger,
}

impl Trace {
    fn new(name: &str, ledger: &Ledger) -> Trace {
        Trace { name: name.to_string(), ledger: Rc::clone(ledger) }
    }
}

impl Drop for Trace {
    fn drop(&mut self) {
        self.ledger.borrow_mut().push(self.name.clone());
    }
}

fn read(ledger: &Ledger) -> Vec<String> {
    ledger.borrow().clone()
}

// 'a is established only between primary and the returned value; other does not enter this bond.
fn primary_name<'a>(primary: &'a Trace, other: &Trace) -> &'a str {
    println!("other is visible too -> {}", other.name);
    &primary.name
}

fn main() {
    let ledger: Ledger = Rc::new(RefCell::new(Vec::new()));

    let outside = Trace::new("lives-outside", &ledger);
    let result;
    {
        let inside = Trace::new("lives-inside", &ledger);
        result = primary_name(&outside, &inside);
        println!("result inside the call -> {}", result);
    }
    println!("ledger once inner scope closes -> [{}]", read(&ledger).join(", "));
    println!("result still usable -> {}", result);
}
other is visible too -> lives-inside
result inside the call -> lives-outside
ledger once inner scope closes -> [lives-inside]
result still usable -> lives-outside

primary_name takes two references, but the annotation is established only between primary and the returned value: both carry the same 'a, other does not. This is a situation where the two sources’ lives diverge — outside lives in the outer scope, inside lives in the inner scope, and their lives are of different lengths. The measurement confirms this: even after the inner scope closes and lives-inside drops into the ledger, result is still usable, because result is tied only to outside’s life — not to other’s (that is, inside’s) life at all.

LI4. An explicit lifetime annotation writes a relationship that already exists between two references into the source; it does not create the relationship, it makes an existing one visible. In primary_name, this relationship holds only between primary and the returned value; other stays outside the annotation even though it is used in the function’s body.

In the measurement, result staying usable after other (tied to inside) has vanished is not a coincidence; it is a consequence stated directly by the signature. outside was set up before inside and is still standing until the inner scope closes; because result is also tied to outside’s life, it stays valid even once inside ends. Had the function returned other instead, that same call would have produced a reference that could no longer be used once inside’s life ended — which is exactly why the signature chooses primary, not other.

There is one more point worth noting here: writing 'a neither extends nor shortens outside‘s life. outside was already in the outer scope, set up before inside, and would have lived until the outer scope’s end regardless — that would not have changed without the annotation. The annotation’s only job is to tell the compiler and the reader which life the returned reference is tied to; it does not change any life itself.

What the Annotation Covers and Does Not Cover

In primary_name’s signature, 'a appears only between primary and the returned value; the other parameter has its own separate, unnamed life, and that life has no relationship with 'a at all. This shows that two references being present together in the same function call does not mean a relationship exists between them — a relationship exists only to the extent it is written in the signature. other’s life could be shorter than primary’s or longer; the function does not care either way, because the body only uses other to read it, not to return it.

This shows that it is a virtue for an annotation to say as little as possible in a signature. We were not required to give the other parameter a name — if its own life is never used anywhere, the compiler treats it, on its own, as a separate and unnamed range. A name is needed only when a genuinely shared relationship exists; if there is no relationship, there is no name either.

This distinction rests on the same root as the previous lesson’s limiting measurement: as long as a function signature does not establish a bond between two references, those two references’ ranges stay independent for the compiler. Here, 'a tied only primary and the returned value together; it left other outside that bond, and other stayed in its own independent range.

A signature can also carry more than one named life instead of a single 'a — each establishes its own relationship. The function below takes two references and returns both of them inside a tuple; 'a ties only the first part, 'b ties only the second, and no relationship is established between the two.

// two_named_lifetimes.rs — two separately named lifetimes, each tied to its own part of the returned tuple.

use std::cell::RefCell;
use std::rc::Rc;

type Ledger = Rc<RefCell<Vec<String>>>;

struct Trace {
    name: String,
    ledger: Ledger,
}

impl Trace {
    fn new(name: &str, ledger: &Ledger) -> Trace {
        Trace { name: name.to_string(), ledger: Rc::clone(ledger) }
    }
}

impl Drop for Trace {
    fn drop(&mut self) {
        self.ledger.borrow_mut().push(self.name.clone());
    }
}

fn read(ledger: &Ledger) -> Vec<String> {
    ledger.borrow().clone()
}

// 'a and 'b are independent of each other: neither constrains the other.
fn return_both<'a, 'b>(x: &'a Trace, y: &'b Trace) -> (&'a str, &'b str) {
    (&x.name, &y.name)
}

fn main() {
    let ledger: Ledger = Rc::new(RefCell::new(Vec::new()));

    let outside = Trace::new("long-lived", &ledger);
    let first;
    {
        let inside = Trace::new("short-lived", &ledger);
        let (a, b) = return_both(&outside, &inside);
        first = a;
        println!("inside the inner scope -> {}, {}", first, b);
    }
    println!("ledger once inner scope closes -> [{}]", read(&ledger).join(", "));
    println!("first still usable -> {}", first);
}
inside the inner scope -> long-lived, short-lived
ledger once inner scope closes -> [short-lived]
first still usable -> long-lived

The tuple’s second part (b, tied to 'b) is only used inside the inner scope and goes out of relevance along with that scope — inside drops into the ledger. The tuple’s first part (first, tied to 'a) is stored in the outer scope and stays usable even after inside has ended. The two named lives here really did correspond to lives of different lengths, and the compiler accepted this because the signature asked for no equality between the two. 'a here and the single 'a in primary_name‘s signature correspond to different concrete ranges in different functions even though they carry the same letter — a name is a label valid within its own signature’s scope, not a record shared across functions.

Limiting Measurement: The Annotation Is Not a Promise, It Is a Request

Writing an annotation may look like giving the compiler information, but it is really a request: a way of saying “let the returned reference live at least as long as 'a.” The compiler has to verify this request, and if the request does not hold up against what the body actually does, the code is rejected. This is what sets an annotation apart from a documentation note: a wrong comment line does not affect compilation, but a wrong lifetime annotation means compilation never happens at all.

If primary_name’s body had tried to return other.name — with the signature left unchanged and only the last line changed — the compiler would not have accepted it. The signature ties 'a only to the primary parameter; other’s own life is not associated with 'a, so as far as the compiler is concerned, how long other will live after the call is unknown. Once the returned value has been promised to live as long as 'a, if the body returns a value from a source (other) that does not satisfy that promise, the promise cannot be kept, and the compiler rejects the signature; the reason for the rejection is that the 'a annotation was left out for other as well.

The compiler has no other option here: how long primary will live during the call is known from the signature, but other’s is not. Unless the compiler can be sure the caller will always supply, for other, a reference that lives at least as long as 'a, it cannot let the body return a value from other — otherwise, a caller could pass a short-lived other and cause the returned reference to outlive its owner. This shows that the boundary established in the previous lesson (a reference cannot outlive its owner) also holds across function boundaries.

There are two ways to make this request satisfiable. The approach seen in the Ownership and Moves lesson would work here too: if the function returned an owned value instead of a reference (something like other.name.clone()), the lifetime problem would never arise, because the returned value would no longer have a life tied to any input. This lesson does not follow that path; it follows the second path, the one that keeps the reference: tying both parameters to the same 'a, so that both primary and other promise to live at least as long as 'a, and the body can return either one.

// unified_lifetime.rs — accepted once the request is made satisfiable: both parameters carry 'a.

use std::cell::RefCell;
use std::rc::Rc;

type Ledger = Rc<RefCell<Vec<String>>>;

struct Trace {
    name: String,
    ledger: Ledger,
}

impl Trace {
    fn new(name: &str, ledger: &Ledger) -> Trace {
        Trace { name: name.to_string(), ledger: Rc::clone(ledger) }
    }
}

impl Drop for Trace {
    fn drop(&mut self) {
        self.ledger.borrow_mut().push(self.name.clone());
    }
}

// Now both are 'a: the returned value can come from either parameter.
fn other_name<'a>(_primary: &'a Trace, other: &'a Trace) -> &'a str {
    &other.name
}

fn main() {
    let ledger: Ledger = Rc::new(RefCell::new(Vec::new()));
    let a = Trace::new("a", &ledger);
    let b = Trace::new("b", &ledger);
    println!("{}", other_name(&a, &b));
}
b

other_name can now return other.name, because the signature now says both parameters live at least as long as 'a, and the body satisfies that promise — whichever parameter is returned, the promise holds. The difference between primary_name and other_name is not in behavior but in the promise the signature covers: the first promised only for one parameter, the second promises for both.

LI5. A lifetime annotation is a request the compiler has to verify. If the body returns a value from a source that does not keep the promise the annotation makes, the code is rejected; once the promise is broadened to cover both sources, the same body is accepted.

Tying both parameters to the same 'a in other_name is not free. When primary_name was called, other living shorter than primary was not a problem at all, because the annotation did not cover other in any way. When other_name is called, both now have to live at least as long as 'a; the caller cannot hand other_name a shorter-lived reference, because the signature does not allow it. Broadening the promise does not make the function more flexible — quite the opposite, it tightens the condition the caller has to meet. This shows that an annotation determines not only “what information is added” but also “what the added information places on the caller” — the narrowest annotation, only as much as the body actually needs, is generally the one to prefer.

Together, these three measurements showed three faces of the annotation: primary_name wrote a relationship, return_both showed that two relationships can stay independent, and other_name showed that a relationship primary_name could not satisfy, gets accepted once the annotation is broadened. In all three, the annotation itself never changed any reference’s life; it only put into writing a relationship that already existed, or was wanted, in the source, and the compiler tested that writing for consistency.

Summary

  • An explicit lifetime annotation is needed when a function takes multiple references and returns one of them, and which input the returned reference is tied to cannot be inferred from the source.
  • The annotation writes a relationship that already exists between references into the source; it neither extends nor shortens any reference’s life — the values referenced still end at their own scope end.
  • A signature can carry more than one named life; each name is valid only within its own signature’s scope, and the same name in different functions is not tied to the same range.
  • The annotation is a request the compiler has to verify: the returned value must come from the source the annotation promised.
  • A body that does not satisfy the promise is rejected; once the promise is broadened to cover all relevant sources, the same body is accepted, but this broadening also tightens the condition placed on the caller.

Next Step

This lesson showed when an annotation needs to be written, what it expresses, and why it is rejected when it is not satisfied. But most functions in the course’s earlier lessons — single-input functions, or methods that take self — carried no explicit annotation at all and still compiled; in those functions, a relationship was already established even though no annotation was written. The next lesson fills that gap: it measures which rules the compiler uses to derive an unwritten annotation, how many signature forms those rules cover, and which signatures fall outside them.

To keep your progress and take notes, Log in

My notes

Log in to take notes.

Start typing to search.

↑↓ ↵ Esc navigate · open · close