🚧 We are still building the lighthouse. Hang tight!

projectlighthouse logoprojectlighthouse

Sign in with your Google or GitHub account to get started

Lifetimes: Connecting References and Scope

Understand lifetime annotations and how the borrow checker uses lifetimes to ensure references remain valid. Learn lifetime elision rules, lifetimes in structs, and the 'static lifetime.

References point to data owned by something else. The borrow checker ensures references remain validβ€”they can't outlive the data they reference. Most of the time, Rust infers lifetimes automatically. Sometimes it can't. When a function returns a reference, the compiler needs to know whether that reference is tied to the first parameter, the second parameter, or some other source.

Lifetime annotations tell the compiler how references relate to each other. They don't change how long values live. They describe relationships between lifetimes so the borrow checker can verify references remain valid. Getting lifetime errors is frustrating initially, but they prevent dangling pointers and use-after-free bugs that plague other languages.

The Problem Lifetimes Solve

Consider a function that returns the longer of two string slices:

fn longest(x: &str, y: &str) -> &str {
    if x.len() > y.len() {
        x
    } else {
        y
    }
}

The compiler rejects this:

error[E0106]: missing lifetime specifier
  |
1 | fn longest(x: &str, y: &str) -> &str {
  |               ----     ----     ^ expected named lifetime parameter

The function returns a reference, but the compiler doesn't know whether it's a reference to x or y. This matters because callers need to know how long the returned reference is valid.

If the function returns x, the returned reference is valid as long as x is valid. If it returns y, the returned reference is valid as long as y is valid. Without this information, the borrow checker can't verify safety.

Lifetime Annotation Syntax

Lifetime parameters use apostrophes and short names, conventionally starting with 'a:

fn longest<'a>(x: &'a str, y: &'a str) -> &'a str {
    if x.len() > y.len() {
        x
    } else {
        y
    }
}

The annotation 'a is a lifetime parameter. The syntax says: the returned reference is valid for the lifetime 'a, and both input references must be valid for at least 'a.

Lifetime annotations don't change how long references live. They describe constraints that the compiler verifies. If the constraints can't be satisfied, the compiler rejects the code.

The annotation &'a str reads as "a string slice with lifetime 'a." The lifetime parameter 'a is genericβ€”it represents some lifetime determined by the caller.

How Lifetimes Work

When you call longest, the compiler determines the concrete lifetime for 'a:

let string1 = String::from("long string");
let result;
{
    let string2 = String::from("short");
    result = longest(string1.as_str(), string2.as_str());
}
println!("{}", result); // Error: `string2` does not live long enough

The lifetime 'a must be valid for both string1 and string2. The variable string2 goes out of scope before result is used. The compiler rejects this because the returned reference might point to string2, which would be invalid when printed.

If string2 lives long enough:

let string1 = String::from("long string");
let string2 = String::from("short");
let result = longest(string1.as_str(), string2.as_str());
println!("{}", result); // Valid: both inputs outlive result

Both string1 and string2 outlive result, so the returned reference is guaranteed valid.

Lifetime Annotations Connect References

Lifetime annotations describe relationships. When a function has multiple references in its parameters and returns a reference, annotations specify which input the output is tied to:

fn first_word<'a>(s: &'a str) -> &'a str {
    let bytes = s.as_bytes();
    for (i, &byte) in bytes.iter().enumerate() {
        if byte == b' ' {
            return &s[..i];
        }
    }
    s
}

The return value has the same lifetime as the input s. The borrow checker knows the returned reference is valid as long as s is valid.

When parameters have different lifetimes:

fn announce_and_return<'a, 'b>(announcement: &'a str, value: &'b str) -> &'a str {
    println!("{}", announcement);
    announcement
}

The function returns a reference tied to announcement, not value. The lifetime 'b is independent. This tells the borrow checker the returned reference's validity depends only on announcement.

Lifetime Elision Rules

Rust infers lifetimes in common patterns. These are lifetime elision rules. When the rules apply, you don't need explicit annotations.

Rule 1: Each parameter that's a reference gets its own lifetime parameter.

fn foo(x: &str) -> &str
// Becomes:
fn foo<'a>(x: &'a str) -> &'a str

Rule 2: If there's exactly one input lifetime, that lifetime is assigned to all output lifetimes.

fn foo(x: &str) -> &str
// Becomes:
fn foo<'a>(x: &'a str) -> &'a str

Rule 3: If there are multiple input lifetimes but one is &self or &mut self (in a method), the lifetime of self is assigned to all output lifetimes.

These rules cover most cases. When they don't, you provide annotations explicitly.

Lifetimes in Structs

Structs can hold references, but this requires lifetime annotations:

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

The struct Excerpt holds a reference with lifetime 'a. An instance of Excerpt can't outlive the reference it holds.

let novel = String::from("Call me Ishmael. Some years ago...");
let first_sentence = novel.split('.').next().expect("Could not find '.'");
let excerpt = Excerpt { part: first_sentence };

The variable excerpt holds a reference to data owned by novel. The compiler ensures excerpt doesn't outlive novel.

This fails:

let excerpt;
{
    let novel = String::from("Call me Ishmael. Some years ago...");
    let first_sentence = novel.split('.').next().expect("Could not find '.'");
    excerpt = Excerpt { part: first_sentence };
}
// Error: `novel` does not live long enough

The variable novel drops before excerpt is used. The reference in excerpt would be dangling.

Lifetime Annotations in Method Definitions

Methods use lifetime elision rules, often making annotations unnecessary:

impl<'a> Excerpt<'a> {
    fn level(&self) -> i32 {
        3
    }
}

The method level doesn't need lifetime annotations. The return type doesn't contain references.

When returning references:

impl<'a> Excerpt<'a> {
    fn announce_and_return_part(&self, announcement: &str) -> &str {
        println!("Attention: {}", announcement);
        self.part
    }
}

Elision rules apply. The method has two input lifetimes (&self and announcement), but rule 3 assigns self's lifetime to the output. This is equivalent to:

fn announce_and_return_part<'b>(&'a self, announcement: &'b str) -> &'a str

The returned reference is tied to self, not announcement.

The 'static Lifetime

The 'static lifetime means the reference lives for the program's entire duration. String literals have 'static lifetime:

let s: &'static str = "I have a static lifetime.";

The string literal is embedded in the program binary and always available.

Use 'static cautiously. It's tempting when the compiler complains about lifetimes, but it's often not the right solution. Most references don't need to live for the entire program. Adding 'static might compile the code but creates different problemsβ€”values that should be freed aren't, or you're hiding the real issue.

Combining Lifetimes, Traits, and Generics

Lifetime parameters, trait bounds, and generic type parameters can combine:

use std::fmt::Display;

fn longest_with_announcement<'a, T>(x: &'a str, y: &'a str, announcement: T) -> &'a str
where
    T: Display,
{
    println!("Announcement: {}", announcement);
    if x.len() > y.len() {
        x
    } else {
        y
    }
}

The function has a lifetime parameter 'a, a generic type parameter T bounded by Display, and returns a reference with lifetime 'a.

Reading Lifetime Errors

Lifetime errors look intimidating but follow patterns. The compiler usually suggests fixes. Common scenarios:

A returned reference isn't tied to any parameter:

fn dangle() -> &str {
    let s = String::from("hello");
    &s
} // Error: `s` does not live long enough

The function tries to return a reference to local data. The solution is to return owned data:

fn no_dangle() -> String {
    String::from("hello")
}

A struct outlives a reference it holds:

let excerpt;
{
    let novel = String::from("...");
    excerpt = Excerpt { part: &novel };
}
// Error: `novel` does not live long enough

Restructure code so the reference's owner outlives the struct, or change the struct to own its data.

Why Lifetimes Matter

Lifetimes prevent dangling references without runtime overhead. The borrow checker verifies references at compile time. At runtime, references are just pointersβ€”no bookkeeping, no checks, no overhead.

Languages with manual memory management can't prevent dangling references. You free memory but keep pointers to it. Dereferencing those pointers causes undefined behavior. Languages with garbage collection prevent dangling references through runtime bookkeeping. The collector tracks which memory is reachable and frees the rest. This has runtime cost and unpredictable latency.

Rust achieves safety without runtime cost. The type system tracks lifetimes. The compiler rejects code where references might outlive their data. When your program compiles, references are valid. No runtime checks needed.

Lifetime annotations feel like fighting the compiler initially. The errors are cryptic. The fixes aren't obvious. Persist through the learning curve. Lifetimes prevent bugs that are nearly impossible to debug in other languages. When your Rust program compiles, you have guarantees about reference validity that no amount of testing provides in languages without these checks.

Notes & Highlights

Β© 2025 projectlighthouse. All rights reserved.