6.6 Lifetimes: Ensuring References Remain Valid

Lifetimes are the mechanism Rust uses to ensure references never outlive the data they refer to, preventing dangling pointers at compile time. Think of a lifetime as representing a scope for which a reference is guaranteed to be valid.

Every reference in Rust has a lifetime, but the compiler can often infer them without explicit annotation through a set of rules called lifetime elision rules. You only need to write lifetime annotations when the compiler’s inference rules are insufficient to guarantee safety, typically in function or struct definitions involving references where the relationships between input and output reference lifetimes are ambiguous.

6.6.1 Explicit Lifetime Annotation Syntax

When you need to be explicit, lifetime annotations use the following syntax:

  • Names: Lifetime names start with an apostrophe (') followed by a short, lowercase name (conventionally starting from 'a, e.g., 'a, 'b, 'input). The name 'static has a special, reserved meaning (see below).
  • Declaration: Generic lifetime parameters are declared in angle brackets after a function name (e.g., fn my_func<'a, 'b>) or struct/enum name (e.g., struct MyStruct<'a>).
  • Usage: The lifetime name is placed after the & (or &mut) in a reference type (e.g., x: &'a str, y: &'b mut i32).

Lifetime annotations do not change how long any values live. Instead, they describe the relationships between the validity scopes (lifetimes) of different references, allowing the borrow checker to verify that references are used safely. They act as constraints for the compiler’s analysis.

Example: Function with Lifetimes

Consider a function that returns the longer of two string slices. Because the returned reference borrows from one of the inputs, the compiler needs explicit annotations to know how the lifetime of the output relates to the lifetimes of the inputs.

// `<'a>` declares a generic lifetime parameter `'a`.
// `x: &'a str` and `y: &'a str` constrain both input slices to live at least as long as `'a`.
// `-> &'a str` declares that the returned slice is also bound by this same lifetime `'a`.
fn longest<'a>(x: &'a str, y: &'a str) -> &'a str {
    if x.len() > y.len() {
        x
    } else {
        y
    }
}

fn main() {
    let string1 = String::from("long string is long");
    let result;
    {
        let string2 = String::from("xyz");
        // The compiler enforces that 'a is, at most, the shorter lifetime
        // of string1 and string2 relevant to this call.
        result = longest(&string1, &string2);
        println!("The longest string is '{}'", result); // Works here, result is valid.
    }
    // println!("The longest string is '{}'", result); // Compile-time error!
    // `string2` went out of scope, so the lifetime 'a associated with `result`
    // (which might point to `string2`'s data) has ended. Using `result` here
    // would risk accessing freed memory.
}

The annotation 'a connects the lifetimes: the returned reference is guaranteed to be valid only as long as both input references (x and y) are valid. If the function tried to return a reference to data created inside the function (like the dangle example earlier), the compiler would reject it because that data’s lifetime would be shorter than the required lifetime 'a.

The 'static Lifetime

The special lifetime 'static indicates that a reference is valid for the entire duration of the program. String literals (&'static str) have this lifetime because their data is embedded in the program’s binary. References to global constants or leaked Boxes can also have the 'static lifetime.

Mastering lifetimes, particularly understanding elision rules and when annotations are needed, is key to leveraging Rust’s compile-time safety guarantees effectively. We’ll encounter more complex lifetime scenarios later.