11.3 Lifetimes in Rust

11.3.1 Understanding Lifetimes

Lifetimes are a way for Rust to track how long references are valid, preventing dangling references and ensuring memory safety without a garbage collector. Lifetimes are especially important when working with references in functions, structs, and traits.

A lifetime in Rust is a construct the compiler (or more specifically, the borrow checker) uses to ensure that all borrows are valid. It represents the scope during which a reference is valid. By assigning lifetimes to references, Rust can check at compile time that you are not using references that have become invalid.

Key Points:

  • Ownership and Borrowing: Lifetimes work with Rust's ownership model to manage memory safety.
  • Compiler Checks: Rust uses lifetimes to enforce that references do not outlive the data they point to.
  • Annotations: Sometimes, you need to annotate lifetimes explicitly to help the compiler understand the relationships between references.

11.3.2 Lifetime Annotations

Lifetime annotations are specified using an apostrophe followed by a name, like 'a. They are used to label references so the compiler can ensure they are valid.

Syntax:

&'a Type

Here, 'a is a lifetime parameter associated with the reference.

Typically, lowercase letters like 'a, 'b, etc., are used for lifetime parameters.

Example with Lifetime Annotations:

#![allow(unused)]
fn main() {
fn print_ref<'a>(x: &'a i32) {
    println!("x is {}", x);
}
}

In this example:

  • The function print_ref takes a reference to an i32 with a lifetime 'a.
  • The lifetime 'a indicates that the reference x is valid for at least as long as 'a.

Note: In this simple case, the lifetime annotation is not strictly necessary, as the compiler can infer the lifetimes. We include the annotation here to illustrate the syntax.

11.3.3 Lifetimes in Functions

When a function returns a reference, you often need to specify lifetime parameters to indicate how the lifetimes of the input parameters relate to the output.

Example Without Lifetimes (Will Not Compile):

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

This code will not compile because the compiler cannot determine how the lifetimes of x, y, and the return value are related. The compiler needs explicit annotations to ensure memory safety.

Adding Lifetime Annotations:

#![allow(unused)]
fn main() {
fn longest<'a>(x: &'a str, y: &'a str) -> &'a str {
    if x.len() > y.len() {
        x
    } else {
        y
    }
}
}

Explanation:

  • Lifetime Parameter 'a: We introduce a lifetime parameter 'a that represents a generic lifetime. This lifetime parameter doesn't specify how long the lifetime is; instead, it tells the compiler that all references annotated with 'a are related in a particular way.
  • Input References: Both x and y are references that have the lifetime 'a, meaning they are valid for at least as long as 'a.
  • Return Reference: The function returns a reference with the lifetime 'a, indicating that the returned reference is valid for at least as long as 'a.

Understanding Lifetimes in This Context:

  • The function longest can accept x and y with different lifetimes.
  • The lifetime 'a ensures that the returned reference cannot outlive either x or y.
  • The returned reference is valid only as long as both x and y are valid, specifically the shorter of the two lifetimes.

Note: The lifetime annotations do not affect the runtime performance of the code; they are checked at compile time and do not exist in the compiled machine code.

11.3.4 Lifetime Elision Rules

In many cases, Rust can infer lifetimes, and you don't need to write them explicitly. The compiler uses lifetime elision rules to determine lifetimes when they are not explicitly annotated.

There are three main rules:

  1. Each parameter that is a reference gets its own lifetime parameter.

    • Example: fn foo(x: &i32, y: &i32) becomes 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 lifetime parameters.

    • Example: fn foo(x: &i32) -> &i32 becomes 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, the lifetime of self is assigned to all output lifetime parameters.

    • This rule applies to methods of structs or traits.

Example:

#![allow(unused)]
fn main() {
impl<'a> Excerpt<'a> {
    fn announce_and_return_part(&self, announcement: &str) -> &str {
        println!("Attention please: {}", announcement);
        self.part
    }
}
}

In this method:

  • The method takes &self and announcement: &str.
  • According to rule 3, the lifetime of self ('a) is assigned to the return reference.
  • We don't need to specify lifetimes explicitly because the compiler applies the elision rules.

Note: When the compiler can apply the lifetime elision rules, you do not need to annotate lifetimes explicitly. This helps keep code concise and readable.

11.3.5 Lifetimes in Structs

Structs can have lifetime parameters to ensure that references within the struct do not outlive the data they point to.

Example:

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

fn main() {
    let text = String::from("The quick brown fox jumps over the lazy dog.");
    let first_word = text.split_whitespace().next().unwrap();
    let excerpt = Excerpt { part: first_word };
    println!("Excerpt: {}", excerpt.part);
}

Explanation:

  • Lifetime Parameter 'a: The struct Excerpt has a lifetime parameter 'a because it holds a reference part that must not outlive the data it points to.
  • Instance Creation: In main, text owns the string data, and first_word is a slice (&str) of text. The lifetime of first_word is tied to text.
  • Struct Instance: The excerpt instance holds a reference to first_word, so excerpt cannot outlive text.
  • Compiler Enforcement: The compiler uses the lifetime annotations to ensure that excerpt.part remains valid for as long as excerpt is in use.

11.3.6 Lifetimes with Generics and Traits

Lifetimes often interact with generics and traits, especially when working with references.

Example with Generics and Lifetimes:

#![allow(unused)]
fn main() {
use std::fmt::Display;

fn announce_and_return_part<'a, T>(announcement: T, text: &'a str) -> &'a str
where
    T: Display,
{
    println!("Announcement: {}", announcement);
    &text[0..5]
}
}

Explanation:

  • Lifetime Parameter 'a: Indicates that the returned reference will be valid as long as the lifetime 'a.
  • Generic Type T: A generic type that must implement the Display trait.
  • Order of Lifetimes and Generics: When specifying both lifetimes and generic types, lifetimes are declared first within the angle brackets <>.

Example Usage:

fn main() {
    let text = String::from("Hello, world!");
    let part = announce_and_return_part(42, &text);
    println!("Part: {}", part);
}

11.3.7 Order of Generics and Lifetimes

When specifying both lifetimes and generic types, the order is:

fn function_name<'a, T>(param: &'a T) -> &'a T {
    // Function body...
}

Lifetimes come before type parameters in the angle brackets <>.

11.3.8 Lifetimes and Machine Code

It's important to note that lifetime annotations have no impact on the generated machine code. They are purely a compile-time feature that helps the Rust compiler ensure memory safety. Lifetimes are not present in the compiled binary, and they do not affect runtime performance.