Lifetimes in Rust

About This Module

This module introduces Rust's lifetime system, which ensures memory safety by tracking how long references remain valid. We'll explore lifetime annotations, the borrow checker, lifetime elision rules, and how lifetimes work with functions, structs, and methods.

Prework

Prework Reading

Read the following sections from "The Rust Programming Language" book:

Pre-lecture Reflections

Before class, consider these questions:

  1. How do lifetimes prevent dangling pointer bugs that plague other systems languages?
  2. When does Rust require explicit lifetime annotations vs. lifetime elision?
  3. How do lifetime parameters relate to generic type parameters?
  4. What are the trade-offs between memory safety and programming convenience in lifetime systems?
  5. How do lifetimes enable safe concurrent programming patterns?

Learning Objectives

By the end of this module, you should be able to:

  • Understand how the borrow checker prevents dangling references
  • Write explicit lifetime annotations when required by the compiler
  • Apply lifetime elision rules to understand when annotations are optional
  • Use lifetimes in function signatures, structs, and methods
  • Combine lifetimes with generics and trait bounds
  • Debug lifetime-related compilation errors effectively

Lifetimes Overview

  • Ensures references are valid as long as we need them to be
  • The goal is to enable Rust compiler to prevent dangling references.
  • A dangling reference is a reference that points to data that has been freed or is no longer valid.

Note: you can separate declaration and initialization

#![allow(unused)]
fn main() {
let r;  // declaration
r = 32;  // initialization
println!("r: {r}");
}
  • Consider the following code:
#![allow(unused)]
fn main() {
let r;

{
    let x = 5;
    r = &x;
}

println!("r: {r}");
}

The Rust Compiler Borrow Checker

  • Let's annotate the lifetimes of r and x.

  • Rust uses a special naming pattern for lifetimes: 'a (single quote followed by identifier)

#![allow(unused)]
fn main() {
let r;                // ---------+-- 'a
                      //          |
{                     //          |
    let x = 5;        // -+-- 'b  |
    r = &x;           //  |       |
}                     // -+       |
                      //          |
println!("r: {r}");   //          |                      // ---------+
}
  • We can see that x goes out of scope before we use a reference, r, to x.

  • We can can fix the scope so lifetimes overlap

#![allow(unused)]
fn main() {
let x = 5;            // ----------+-- 'b
                      //           |
let r = &x;           // --+-- 'a  |
                      //   |       |
println!("r: {r}");   //   |       |
                      // --+       |
                      // ----------+
}

Generic Lifetimes in Functions

  • Let's see an example of why we need to be able to specify lifetimes.

  • Say we want to compare to strings and pick the longest one

// Compiler Error

// compare two string slices and return reference to the longest
fn longest(x: &str, y: &str) -> &str {
    if x.len() > y.len() {x} else {y}
}

fn main() {
    let string1 = String::from("abcd");
    let string2 = "xyz";

    let result = longest(string1.as_str(), string2);
    println!("The longest string is {result}");
}
Why is this a problem?
Answer: In general, we don't know which reference will be returned and so we can't know the lifetime of the return reference.

The Solution: Lifetime Annotation Syntax

  • names of lifetime parameters must start with an apostrophe (') and are usually all lowercase and very short, like generic types
#![allow(unused)]
fn main() {
&i32        // a reference with inferred lifetime
&'a i32     // a reference with an explicit lifetime
&'a mut i32 // a mutable reference with an explicit lifetime
}
  • now we can annotate our function with lifetime
#![allow(unused)]
fn main() {
fn longest<'a>(x: &'a str, y: &'a str) -> &'a str {
    if x.len() > y.len() {x} else {y}
}
}

Update Example with Lifetime Annotation

  • we use the same syntax like we used for generic types, fn longest<'a>(...

  • The lifetime 'a is the shorter of the two input lifetimes: (x: &'a str, y: &'a str)

  • The returned string slice will have lifetime at least as long as 'a, e.g. -> &'a str

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("abcd");
    let string2 = "xyz";

    let result = longest(string1.as_str(), string2);
    println!("The longest string is {result}");
}
  • Above is not an issue, because all lifetimes are the same.

Example of Valid Code

// this code is still fine
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 string2 = String::from("xyz");
        let result = longest(string1.as_str(), string2.as_str());
        println!("The longest string is {result}");
    }
}
  • Above is not an issue, because the returned reference is no longer than the shorter of the two args

Example of Invalid Code

  • But what about below?
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("abcd");              // ----------+-- 'a
    let result;                                      //           |
    {                                                //           |
        let string2 = "xyz";                         // --+-- 'b  |
        result = longest(string1.as_str(), string2); //   |       |
    }                                                // --+       |
    println!("The longest string is {result}");      //           |
}                                                    // ----------+
  • We're trying to use result after the shortest arg lifetime ended

Lifetime of return type must match lifetime of at least one parameter

  • This won't work
#![allow(unused)]
fn main() {
fn first_str<'a>(_x: &str, _y: &str) -> &'a str {
    let result = String::from("really long string");
    result.as_str()
}
}
Why is this a problem?
Answer: The return reference is to `result` which gets dropped at end of function.

Lifetime Annotations in Struct Definitions

  • So far, we've only used structs that fully owned their member types.

  • We can define structs to hold references, but then we need lifetime annotations

#[derive(Debug)]
struct ImportantExcerpt<'a> {
    part: &'a str,
}

fn main() {
    let novel = String::from("Call me Ishmael. Some years ago...");
    let first_sentence = novel.split('.').next().unwrap();
    let i = ImportantExcerpt {
        part: first_sentence,
    };
    println!("{:?}", i);
}
  • An instance of ImportantExcerpt can't outlive the reference it holds in the part field.

Lifetime Elision

e·li·sion
/əˈliZH(ə)n/
noun

the omission of a sound or syllable when speaking (as in I'm, let's, e ' en ).

* an omission of a passage in a book, speech, or film.
  "the movie's elisions and distortions have been carefully thought out"

* the process of joining together or merging things, especially abstract ideas.
  "unease at the elision of so many vital questions"
  • In Rust, the cases where we can omit lifetime annotations are called lifetime elision.

Lifetime Elision Example

So why does this function compile without errors?

fn first_word(s: &str) -> &str {
    let bytes = s.as_bytes();

    for (i, &item) in bytes.iter().enumerate() {
        if item == b' ' {
            return &s[0..i];
        }
    }

    &s[..]
}
fn main() {
    let s = String::from("Call me Ishmael.");
    let word = first_word(&s);
    println!("The first word is: {word}");
}

Shouldn't we have to write?

#![allow(unused)]
fn main() {
fn first_word<'a>(s: &'a str) -> &'a str {
}

Inferring Lifetimes

The compiler developers decided that some patterns were so common and simple to infer that the compiler could just infer and automatically generate the lifetime specifications.

  • input lifetimes: lifetimes on function or method parameters

  • output lifetimes: lifetimes on return values

Three Rules for Compiler Lifetime Inference

First Rule

Assign a lifetime parameter to each parameter that is a reference.

#![allow(unused)]
fn main() {
// function with one parameter
fn foo<'a>(x: &'a i32);

//a function with two parameters gets two separate lifetime parameters: 
fn foo<'a, 'b>(x: &'a i32, y: &'b i32);

// and so on.
}

Three Rules for Compiler Lifetime Inference

Second Rule

If there is exactly one input lifetime parameter, that lifetime is assigned to all output lifetime parameters

#![allow(unused)]
fn main() {
fn foo<'a>(x: &'a i32) -> &'a i32
}

Three Rules for Compiler Lifetime Inference

Third Rule -- Methods

If there are multiple input lifetime parameters, but one of them is &self or &mut self because this is a method, the lifetime of self is assigned to all output lifetime parameters.

Let's Test Our Understanding

You're the compiler and you see this function.

fn first_word(s: &str) -> &str {...}
Do any rules apply? which one would you apply first?
Answer:

First rule: Apply input lifetime annotations.

fn first_word<'a>(s: &'a str) -> &str {...}

Second rule: Apply output lifetime annotation.

fn first_word<'a>(s: &'a str) -> &'a str {...}

Done! Everything is accounted for.

Test Our Understanding Again

What about if you see this function signature?

fn longest(x: &str, y: &str) -> &str {...}
Can we apply any rules?

We can apply first rule again. Each parameter gets it's own lifetime.

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

Can we apply anymore rules?
No! Produce a compiler error asking for annotations.

Lifetime Annotations in Method Definitions

Let's take a look at the third rule again:

If there are multiple input lifetime parameters, but one of them is &self or &mut self because this is a method, the lifetime of self is assigned to all output lifetime parameters.

Previously, we defined a struct with a field that takes a string slice reference.

#![allow(unused)]
fn main() {
#[derive(Debug)]
struct ImportantExcerpt<'a> {
    part: &'a str,
}

// For implementation, `impl` of methods, we use the generics style annotation, which is required.

// But we don't have to annotate the following method. The **First Rule** applies.
impl<'a> ImportantExcerpt<'a> {
    fn level(&self) -> i32 {
        3
    }
}

// For the following method...
impl<'a> ImportantExcerpt<'a> {
    fn announce_and_return_part(&self, announcement: &str) -> &str {
        println!("Attention please: {announcement}");
        self.part
    }
}
}

There are two input lifetimes so:

  • Rust applies the first lifetime elision rule and gives both &self and announcement their own lifetimes.
  • Then, because one of the parameters is &self, the return type gets the lifetime of &self, and all lifetimes have been accounted for.

The Static Lifetime

  • a special lifetime designation
  • lives for the entire duration of the program
#![allow(unused)]
fn main() {
// This is actually redundant since string literals are always 'static
let s: &'static str = "I have a static lifetime.";
}
  • use only if necessary

  • manage lifetimes more fine grained if at all possible

For more, see for example:

  • https://doc.rust-lang.org/rust-by-example/scope/lifetime/static_lifetime.html

Combining Lifetimes with Generics and Trait Bounds

Let's look at an example that combines:

  • lifetimes
  • generics with trait bounds
use std::fmt::Display;

fn longest_with_an_announcement<'a, T>(
    x: &'a str,
    y: &'a str,
    ann: T,
) -> &'a str
where
    T: Display,  // T must implement the Display trait
{
    println!("Announcement! {ann}");
    if x.len() > y.len() {
        x
    } else {
        y
    }
}

fn main() {
    let string1 = String::from("short");
    let string2 = "longer";

    let result = longest_with_an_announcement(string1.as_str(), string2, "Hear ye! Hear ye!");
    println!("The longest string is {result}");
}

Breaking Down the Function Declaration

Let's break down the function declaration:

#![allow(unused)]
fn main() {
fn longest_with_an_announcement<'a, T>(
    x: &'a str,
    y: &'a str,
    ann: T,
) -> &'a str
where
    T: Display,   // T must implement the Display trait
}
  • It has two generic parameters:
    • 'a: A lifetime parameter
    • T: A type parameter
  • It takes three arguments:
    • x: A string slice with lifetime 'a
    • y: A string slice with lifetime 'a
    • ann: A value of generic type T
  • Returns a string slice with lifetime 'a
  • The where clause specifies that type T must implement the Display trait

Recap

  • Lifetimes are a way to ensure that references are valid as long as we need them to be.
  • The borrow checker is a tool that helps us ensure that our references are valid.
  • We can use lifetime annotations to help the borrow checker understand our code better.
  • We can use lifetime elision to help the compiler infer lifetimes for us.
  • We can use lifetimes in function signatures, structs, and methods.
  • We can combine lifetimes with generics and trait bounds.

In-Class Exercise

Part 1 -- Illustrate the Lifetimes

Annotate the lifetimes of the variables in the following code using the notation from the beginning of the module.

Paste the result in GradeScope.

#![allow(unused)]
fn main() {
{
    let s = String::from("never mind how long precisely --"); // 
    {                                                         //
        let t = String::from("Some years ago -- ");           //
        {                                                     //
            let v = String::from("Call me Ishmael.");         //
            println!("{v}");                                  //
        }                                                     //
        println!("{t}");                                      //
    }                                                         //
    println!("{s}");                                          //
}                                                             //
}
Solution
#![allow(unused)]
fn main() {
{
    let s = String::from("never mind how long precisely --"); //----------+'a
    {                                                         //          |
        let t = String::from("Some years ago -- ");           //------+'b |
        {                                                     //      |   |
            let v = String::from("Call me Ishmael.");         //--+'c |   |
            println!("{v}");                                  //  |   |   |
        }                                                     //--+   |   |
        println!("{t}");                                      //      |   |
    }                                                         //--------+ |
    println!("{s}");                                          //          |
}                                                             //----------+
}

Part 2 -- Fix the Function with Multiple References

The following function is supposed to take a vector of string slices, a default value, and an index, and return either the string at the given index or the default if the index is out of bounds. However, it won't compile without lifetime annotations.

Add the appropriate lifetime annotations to make this code compile and paste the result in GradeScope.

fn get_or_default(strings: &Vec<&str>, default: &str, index: usize) -> &str {
    if index < strings.len() {
        strings[index]
    } else {
        default
    }
}

fn main() {
    let vec = vec!["hello", "world", "rust"];
    let default = "not found";
    let result = get_or_default(&vec, default, 5);
    println!("{}", result);
}
Solution
fn get_or_default<'a>(strings: &Vec<&'a str>, default: &'a str, index: usize) -> &'a str {
    if index < strings.len() {
        strings[index]
    } else {
        default
    }
}

fn main() {
    let vec = vec!["hello", "world", "rust"];
    let default = "not found";
    let result = get_or_default(&vec, default, 5);
    println!("{}", result);
}

The return value could come from either strings or default, so both need the same lifetime annotation 'a. The vector reference itself doesn't need to live as long since we're returning references to its contents, not the vector itself.

Part 3 -- Generic Type with Lifetime Annotations

The following code defines a Wrapper struct that holds both a generic value and a reference. The struct and its method won't compile without proper lifetime annotations.

Add the appropriate lifetime annotations to make this code compile and paste the result in GradeScope.

struct Wrapper<T> {
    value: T,
    description: &str,
}

impl<T> Wrapper<T> {
    fn new(value: T, description: &str) -> Self {
        Wrapper { value, description }
    }
    
    fn get_description(&self) -> &str {
        self.description
    }
    
    fn get_value(&self) -> &T {
        &self.value
    }
}

fn main() {
    let desc = String::from("A number");
    let wrapper = Wrapper::new(42, &desc);
    println!("Value: {}, Description: {}", wrapper.get_value(), wrapper.get_description());
}
Solution
struct Wrapper<'a, T> {
    value: T,
    description: &'a str,
}

impl<'a, T> Wrapper<'a, T> {
    fn new(value: T, description: &'a str) -> Self {
        Wrapper { value, description }
    }
    
    fn get_description(&self) -> &str {
        self.description
    }
    
    fn get_value(&self) -> &T {
        &self.value
    }
}

fn main() {
    let desc = String::from("A number");
    let wrapper = Wrapper::new(42, &desc);
    println!("Value: {}, Description: {}", wrapper.get_value(), wrapper.get_description());
}

The struct needs a lifetime parameter 'a because it holds a reference (description). The impl block must also declare this lifetime parameter: impl<'a, T>. The methods get_description and get_value don't need explicit lifetime annotations because the compiler can apply elision rules (the return lifetime is inferred from &self).