6.6 Lifetimes: Ensuring Valid References

Lifetimes in Rust prevent dangling references by ensuring that all references are valid as long as they are in use. Think of lifetimes as labels that tell the compiler how long references are valid. They ensure that references do not outlive the data they point to.

6.6.1 Understanding Lifetimes

Every reference in Rust has a lifetime, which is the scope during which the reference is valid. Lifetimes are enforced by the compiler to ensure that references do not outlive the data they refer to.

6.6.2 Lifetime Annotations

In simple cases, Rust infers lifetimes, but in more complex scenarios, you need to specify them. Lifetime annotations use an apostrophe followed by a name (e.g., 'a) and are placed after the & symbol in references (e.g., &'a str). They link the lifetimes of references to ensure validity.

Example: Function Returning a Reference

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

The 'a lifetime parameter specifies that the returned reference will be valid as long as both x and y are valid.

6.6.3 Invalid Code Examples and Lifetime Misunderstandings

Understanding lifetimes can be challenging, especially when dealing with references that might outlive the data they point to. In this section, we'll explore invalid code examples related to lifetimes, explain why they don't compile, and clarify concepts like the use of as_str(), the role of string literals, and how variable scopes affect lifetimes.

Example: Missing Lifetime Annotations

Consider the following function that returns a reference to a string slice:

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

When you try to compile this code, you'll encounter a compiler error:

Click to see the error message and explanation
error[E0106]: missing lifetime specifier
 --> src/main.rs:1:33
  |
1 | fn longest(x: &str, y: &str) -> &str {
  |                                 ^ expected lifetime parameter
  |
  = help: this function's return type contains a borrowed value, but there is no value for it to be borrowed from
  = help: consider giving it a 'static lifetime

Explanation:

  • The compiler cannot determine the lifetime of the reference being returned.
  • Since x and y could have different lifetimes, Rust requires explicit lifetime annotations to ensure safety.

Adding Lifetime Annotations

By adding lifetime annotations, we specify that the returned reference will have the same lifetime as the input references:

#![allow(unused)]
fn main() {
fn longest<'a>(x: &'a str, y: &'a str) -> &'a str {
    if x.len() > y.len() { x } else { y }
}
}
  • 'a is a generic lifetime parameter.
  • This tells the compiler that the returned reference will be valid as long as both x and y are valid.

Example with Variable Scope and Lifetimes

Let's explore a scenario where variable scopes and lifetimes interact in a way that causes a compiler error.

Code Example:

fn main() {
    let result;
    {
        let s1 = String::from("hello");
        result = longest(s1.as_str(), "world");
    } // s1 goes out of scope here
    // println!("The longest string is {}", result); // Error: `s1` does not live long enough
}

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

Explanation:

  • Inside the inner scope, we create s1, a String owning the heap-allocated data "hello".
  • We call longest(s1.as_str(), "world"), passing a reference to s1's data and the string literal "world".
  • After the inner scope ends, s1 is dropped, and its data becomes invalid.
  • result holds a reference to the data returned by longest, which may be s1.as_str().
  • When we attempt to use result outside the inner scope, it may reference invalid data, leading to a compiler error.

Compiler Error:

error[E0597]: `s1` does not live long enough
 --> src/main.rs:5:21
  |
3 |         let s1 = String::from("hello");
  |             -- binding `s1` declared here
4 |         result = longest(s1.as_str(), "world");
  |                          ^^^^^^^^^^ borrowed value does not live long enough
5 |     } // s1 goes out of scope here
  |     - `s1` dropped here while still borrowed
6 |     println!("The longest string is {}", result);
  |                                          ------ borrow later used here

Why Is as_str() Used and What Does It Do?

Purpose of as_str():

  • s1 is a String, which owns its data.
  • as_str() converts the String into a string slice (&str), a reference to the data inside the String.
  • This allows us to pass a &str to the longest function, which expects string slices.

Alternative Without as_str():

  • You can use &s1 instead of s1.as_str().
  • Rust automatically dereferences &String to &str because String implements the Deref trait.

Modified Code:

fn main() {
    let result;
    {
        let s1 = String::from("hello");
        result = longest(&s1, "world"); // Using &s1 instead of s1.as_str()
    }
    // println!("The longest string is {}", result); // Error remains the same
}

Key Point:

  • Whether you use s1.as_str() or &s1, the issue is not with the method but with the lifetime of s1.

What Happens If We Use a String Literal Instead?

Suppose we change s1 to be a string literal:

fn main() {
    let result;
    {
        let s1 = "hello"; // s1 is a &str with 'static lifetime
        result = longest(s1, "world");
    }
    println!("The longest string is {}", result); // This works now
}

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

Explanation:

  • String literals like "hello" have a 'static lifetime, meaning they are valid for the entire duration of the program.
  • Even though s1 (the variable) goes out of scope, the data it references remains valid.
  • The longest function returns a reference with a lifetime tied to the shortest input lifetime, but since both are 'static, the returned reference is valid outside the inner scope.

Understanding Lifetimes in the longest Function

  • The function signature:

    #![allow(unused)]
    fn main() {
    fn longest<'a>(x: &'a str, y: &'a str) -> &'a str
    }
  • This means the returned reference's lifetime 'a is the same as the lifetimes of x and y.

  • When one of the inputs has a shorter lifetime, 'a becomes that shorter lifetime.

In the Original Code:

  • s1.as_str() has a lifetime tied to s1, which is limited to the inner scope.
  • "world" has a 'static lifetime.
  • The compiler infers 'a to be the shorter lifetime (that of s1.as_str()).
  • Therefore, result cannot outlive s1.

Fixing the Lifetime Issue

To resolve the error, we need to ensure that the data referenced by result is valid when we use it.

Option 1: Extend the Lifetime of s1

fn main() {
    let s1 = String::from("hello"); // Move s1 to the outer scope
    let result = longest(s1.as_str(), "world");
    println!("The longest string is {}", result); // Now this works
}
  • By declaring s1 in the outer scope, its data remains valid when we use result.

Option 2: Return an Owned String

Modify longest to return a String:

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

fn main() {
    let result;
    {
        let s1 = String::from("hello");
        result = longest(s1.as_str(), "world");
    }
    println!("The longest string is {}", result); // Works because result owns the data
}
  • By returning a String, we transfer ownership of the data to result.
  • This eliminates lifetime concerns since result owns its data.

Key Takeaways

  • Lifetimes Ensure Valid References: They prevent references from pointing to invalid data.
  • Variables vs. Data Lifetime: A variable going out of scope doesn't necessarily mean the data is invalid (e.g., string literals).
  • String Literals Have 'static Lifetime: They are valid for the entire duration of the program.
  • Returning References: Be cautious when returning references to data created within a limited scope.

6.6.4 Lifetime Elision

In many cases, Rust can infer lifetimes, so you don't need to annotate them explicitly. Rust applies lifetime elision rules in certain cases, allowing you to omit lifetime annotations. For example, in functions with a single reference parameter and return type, the compiler assumes they have the same lifetime.

Understanding when and how to use lifetime annotations is important for more complex code.