blog.dopana

Back

Memory safety without a garbage collector is Rust’s biggest selling point. To achieve this, the compiler uses a tool called the Borrow Checker to ensure that references always point to valid memory.

The primary mechanism the borrow checker uses to prevent dangling references (references pointing to data that has already been dropped) is Lifetimes.

In this post, we will unpack Rust’s lifetimes, understand why the compiler needs them, learn how to write lifetime annotations, and look at the underlying mental models.

The Problem: Dangling References#

A dangling reference occurs when a program references a location in memory that has been freed. Here is a simple example that Rust rejects:

fn main() {
    let r;

    {
        let x = 5;
        r = &x; // ❌ x is dropped at the end of this block
    }

    println!("r: {r}"); // ❌ r points to freed memory!
}
rust

If you try to compile this, Rust will throw an error: x does not live long enough. The compiler compares the scopes of the variables to ensure no references outlive their owners.

gantt
    title Scope Lifetimes of x and r
    dateFormat  X
    axisFormat %s
    section variable x
    x scope : active, 0, 2
    section reference r
    r scope : active, 0, 4

Here, r lives from 0 to 4, but x only lives from 0 to 2. Because r outlives x, the reference is invalid.

What is a Lifetime?#

A lifetime is the scope for which a reference is valid. Most of the time, lifetimes are implicit and inferred by the compiler. However, when the relationships between references in functions or structs are ambiguous, we must annotate them manually.

[!IMPORTANT] Lifetime annotations do not change how long any of the values live. Instead, they describe the relationships between the lifetimes of multiple references to prove to the compiler that no invalid memory access can occur.

Generic Lifetimes in Functions#

Let’s look at a function that returns the longer of two string slices:

// ❌ This will NOT compile
fn longest(x: &str, y: &str) -> &str {
    if x.len() > y.len() {
        x
    } else {
        y
    }
}
rust

The compiler rejects this because it does not know whether the returned reference refers to x or y. If x and y have different lifetimes, the returned reference might outlive one of them, leading to a dangling pointer.

To fix this, we introduce a generic lifetime parameter:

// ✅ This compiles!
fn longest<'a>(x: &'a str, y: &'a str) -> &'a str {
    if x.len() > y.len() {
        x
    } else {
        y
    }
}
rust
  • <'a> declares a generic lifetime parameter named 'a.
  • x: &'a str and y: &'a str specify that both input references must live at least as long as the lifetime 'a.
  • -> &'a str promises that the returned reference will also live at least as long as 'a.

In practice, the lifetime 'a of the return value will be equal to the smaller of the lifetimes of the inputs x and y.

ELI5 Analogy: The Room Rental#

Think of a lifetime as a lease agreement:

  • The Landlord (Owner): The variable that owns the data.
  • The Tenant (Reference): The borrower who uses the data.
  • The Sublease (Lifetime): The borrow contract.
Landlord's Lease:  ==================== (Data exists)
Sublease (Borrow):      ==========      (Valid borrow) ✅
Sublease (Borrow):      ====================== (Invalid borrow!) ❌
text

Your sublease (reference lifetime) cannot outlive the main lease of the apartment (owner scope). If you stay in the apartment after the landlord’s lease expires, you are trespassing on invalid memory!

Structs with References#

If you define a struct that holds a reference instead of an owned type, you must declare a lifetime annotation on that struct. This ensures the struct instance cannot outlive the reference it holds:

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

fn main() {
    let novel = String::from("Call me Ishmael. Some years ago...");
    let first_sentence = novel.split('.').next().expect("Could not find a '.'");
    
    // The struct instance is linked to the lifetime of first_sentence
    let i = ImportantExcerpt {
        part: first_sentence,
    };
}
rust

Here, an instance of ImportantExcerpt cannot outlive the reference stored in its part field.

Lifetime Elision: The Silent Rules#

You might have noticed that we wrote many functions returning references without annotations in the past, like:

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

Why did this compile without 'a?

The Rust team noticed that programmers wrote the same lifetime patterns repeatedly. To make coding more ergonomic, they programmed three deterministic rules into the compiler (called Lifetime Elision Rules):

  1. Each parameter that is a reference gets its own input lifetime parameter (e.g., fn foo<'a, 'b>(x: &'a i32, y: &'b i32)).
  2. If there is exactly one input lifetime parameter, that lifetime is assigned to all output references (e.g., fn foo<'a>(x: &'a i32) -> &'a i32).
  3. If there are multiple input lifetime parameters, but one of them is &self or &mut self (meaning it is a method), the lifetime of self is assigned to all output references.

If the compiler applies these rules and still cannot resolve the lifetimes of the return value, it throws a compile error, prompting you to write them yourself.

The Static Lifetime#

Rust has a special lifetime called 'static. It denotes that the reference can live for the entire duration of the program.

All string literals have the 'static lifetime because their data is baked directly into the program’s binary:

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

[!WARNING] While 'static is useful, avoid using it as a lazy fix for borrow checker errors. Forcing a reference to be 'static when it should be temporary will lead to compilation problems down the road.

Summary#

  • Lifetimes are naming scopes that tell the compiler how long references are valid.
  • They prevent dangling references by ensuring borrows do not outlive their owners.
  • Lifetime Elision rules allow you to omit annotations in common scenarios.
  • Structs holding references must declare lifetime bounds to guarantee memory safety.

In the next post, we will build on this to explore Smart Pointers, which allow us to customize reference management on the heap.

References#