Tech Wiki

TOPICSSERIES

[Rust Zero to Production 10] Rust Lifetimes as Relationships, Not Annotation Tricks

When Rust reports a lifetime error, it is tempting to ask where 'a should go. That question comes too early. First identify which input a returned reference may borrow and whether that input remains valid while the return value is used. A lifetime annotation records that relationship in a signature. It does not keep a value alive longer.

This guide connects four parts of the same problem through a small endpoint-selection example: signatures covered by elision, E0106 when a function may return either of two inputs, structs that hold references, and the reason 'static cannot repair a reference to a local value. Every example uses a standalone Rust 2024 project.

1. Borrowing Relationships

Ask these questions before editing a signature:

  • Which input can the return value point into?
  • Does the source value remain valid everywhere the returned reference is used?
  • If the function creates new data, should it return an owned value instead?

Lifetime parameters express the answers. They allocate no memory and preserve no value. The compiler rejects a call when the caller cannot satisfy the declared relationship.

2. Lifetime Elision with One Input

This function returns the first segment of an input path.

fn first_segment(path: &str) -> &str {
    path.split('/').next().unwrap_or("")
}

The missing labels do not disable reference checking. Under function lifetime elision, every elided input reference receives a distinct lifetime. If there is exactly one input lifetime, Rust assigns it to every elided output reference. Writing the relationship explicitly produces this equivalent signature:

fn first_segment<'a>(path: &'a str) -> &'a str {
    path.split('/').next().unwrap_or("")
}

Both versions have the same contract. The returned &str cannot be used longer than the data referenced by path. The explicit form does not extend that data, and the elided form does not weaken the check.

Methods get one additional rule. If the receiver is &self or &mut self, Rust assigns the receiver's lifetime to elided outputs. That is why the later EndpointView::url(&self) -> &str method needs no lifetime annotation of its own.

3. Two Inputs and E0106

Suppose an empty endpoint setting should fall back to another URL:

fn choose_endpoint(candidate: &str, fallback: &str) -> &str {
    if candidate.is_empty() {
        fallback
    } else {
        candidate
    }
}

This fails with E0106. Elision gives the two inputs distinct lifetimes, but no rule decides which one belongs to the output. Here is the exact diagnostic recorded with rustc 1.98.1:

error[E0106]: missing lifetime specifier
 --> tests/ui/missing_output_lifetime.rs:1:56
  |
1 | fn choose_endpoint(candidate: &str, fallback: &str) -> &str {
  |                               ----            ----     ^ expected named lifetime parameter
  |
  = help: this function's return type contains a borrowed value, but the signature does not say whether it is borrowed from `candidate` or `fallback`
help: consider introducing a named lifetime parameter
  |
1 | fn choose_endpoint<'a>(candidate: &'a str, fallback: &'a str) -> &'a str {
  |                   ++++             ++                 ++          ++

error: aborting due to 1 previous error

For more information about this error, try `rustc --explain E0106`.

The diagnostic identifies the missing contract. Rust does not inspect the body and invent a different public relationship for each call. The signature must say whether the result borrows from candidate, fallback, or either one.

Because this function can return either input, one lifetime parameter connects both inputs to the output:

#[must_use]
pub fn choose_endpoint<'a>(candidate: &'a str, fallback: &'a str) -> &'a str {
    if candidate.is_empty() {
        fallback
    } else {
        candidate
    }
}

This does not make the two strings die at the same instant. At a call site, the compiler chooses an 'a contained in the period where both borrows are valid. The returned reference is usable only within that overlap. Nothing shortens the longer-owned value or extends the shorter one.

The contract can appear conservative. Even when the runtime branch selects candidate, the return type still says the result may come from either input. Since the function does not return a different static type for each branch, the caller cannot use the result after the shorter input has become invalid.

4. Lifetime Relationships in Structs

When a struct holds &str fields, its type must state that an instance cannot outlive the referenced strings.

#[derive(Debug, PartialEq, Eq)]
pub struct EndpointView<'a> {
    pub name: &'a str,
    pub url: &'a str,
}

impl<'a> EndpointView<'a> {
    #[must_use]
    pub const fn new(name: &'a str, url: &'a str) -> Self {
        Self { name, url }
    }

    #[must_use]
    pub const fn url(&self) -> &str {
        self.url
    }
}

EndpointView<'a> owns neither string. It is a view into data owned elsewhere, so Rust rejects code that keeps the view after those source strings are gone. The method elision rule ties url's output to its &self receiver.

Using one 'a for both fields is an API choice. Both references must remain valid while this view is used. A struct could use EndpointView<'name, 'url> if consumers benefit from independent relationships, but the extra parameter buys nothing for this example.

5. 'static Is Not a Repair Tool

A 'static reference points to data that can remain valid for the entire program. String literals are the familiar example because their text is stored in the program binary.

let fallback: &'static str = "https://fallback.example.com/health";

Annotating a reference to a function-local String as 'static does not change where that string is stored or when it is dropped.

fn endpoint_url() -> &'static str {
    let url = String::from("https://api.example.com/health");
    &url
}

If 'static is copied from the initial E0106 help without checking ownership, E0515 exposes the underlying bug:

error[E0515]: cannot return reference to local variable `url`
 --> tests/ui/return_local_reference.rs:3:5
  |
3 |     &url
  |     ^^^^ returns a reference to data owned by the current function

error: aborting due to 1 previous error

For more information about this error, try `rustc --explain E0515`.

url is dropped when the function returns. 'static does not order Rust to retain it. The annotation imposes the stronger requirement that the returned reference remain valid for the entire program, which this local value cannot satisfy.

When a function creates a new string, returning ownership is usually the right API:

fn endpoint_url() -> String {
    String::from("https://api.example.com/health")
}

Data intended to exist globally needs storage that actually matches the contract, such as a string literal, a static item, or a deliberately designed global store. It is also possible to leak an allocation to obtain a 'static reference, but using a leak for an ordinary return-value problem discards cleanup responsibility rather than solving the ownership design.

6. Reading Diagnostics in Order

When E0106 or another borrow error appears, use this order instead of immediately copying a suggested annotation:

  1. Find the source value that the returned reference points into.
  2. If the possible sources are inputs, decide which input-to-output relationship the API promises.
  3. If the source is local, stop returning a reference and consider an owned type such as String.
  4. If a struct stores references, encode how the struct instance relates to their source values.
  5. Use 'static only when the data really can exist for the entire program.

The annotation comes near the end. Adding more lifetime names before identifying the owner usually moves the error instead of fixing it.

7. Standalone Example and Diagnostic Tests

It has no external dependencies and uses Rust 2024.

cd examples/article-10-lifetimes
cargo fmt --check
cargo check --all-targets --all-features
cargo clippy --all-targets --all-features -- -D warnings
cargo test --all-features
cargo run --quiet

The binary prints:

api -> https://api.example.com/health

The tests/ui directory contains two sources that must not compile plus stderr artifacts from rustc 1.98.1. An integration test compiles each source with rustc --edition=2024 and compares the failure status, empty stdout, and stderr bytes. If the E0106 example starts compiling or a compiler upgrade changes the diagnostic, the test reports the mismatch.

The suite contains three unit tests and two compile-fail diagnostic tests. Compiler wording can change between toolchain releases, so a Rust upgrade should include a review of the real output before updating the .stderr files.

There is no need to imagine 'a as a timer. Trace what the return value borrows and make sure the reference is never used after its source becomes invalid. If that relationship is sound, the annotation is usually small. If it is impossible, return ownership instead.

Full source code

The complete runnable source for this article is available in the Chapter 10 project on GitHub.

Sources


Leave a Reply

Your email address will not be published. Required fields are marked *

Tech Wiki

Built with WordPress · Learn in public.