Lifetimes

Lifetimes

Ensuring references are valid









Recap: Ownership + Borrowing

  1. Each value in Rust has an owner.
  2. There can only be a single owner at any time.
  3. Value is dropped (reclaimed) when owner goes out of scope.

To “access” data, no need to take ownership, just borrow

  • &T gives a shared reference (read access)
  • &mut T gives a mutable reference (write access)
  • Sharing XOR Mutability: many &T or single &mut T

Today: how does Rust know when a borrow is valid?









Goal Today: Lifetimes








QUIZ

What happens when we run this code?

fn main() {
    let r;
    {
        let x = 5;
        r = &x;
    }
    println!("r: {r}");
}









Dangling References

x goes out of scope before r is used!

error[E0597]: `x` does not live long enough
 --> src/main.rs:6:13
  |
5 |         let x = 5;
  |             - binding `x` declared here
6 |         r = &x;
  |             ^^ borrowed value does not live long enough
7 |     }
  |     - `x` dropped here while still borrowed
8 |
9 |     println!("r: {r}");
  |                   - borrow later used here

If Rust allowed this, r would point to freed memory!









Goal Today: Lifetimes

  • [+] Dangling References: references must not outlive the data they point to







The Borrow Checker

How does Rust know the reference is invalid?

The borrow checker compares the lifetimes (i.e. scopes) of references and the data they refer to.

fn main() {
    let r;                // ---------+-- 'a
                          //          |
    {                     //          |
        let x = 5;        // -+-- 'b  |
        r = &x;           //  |       |
    }                     // -+       |
                          //          |
    println!("r: {r}");   //          |
}                         // ---------+
  • r has lifetime 'a (outer scope)
  • x has lifetime 'b (inner scope)
  • 'b is shorter than 'a … so r could be a dangling reference!

The borrow checker rejects the program.









Valid Reference: Data Outlives the Reference

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

Here x has lifetime 'b which is longer than r’s lifetime 'a.

The borrow checker accepts the program.









Goal Today: Lifetimes

  • [+] Dangling References: references must not outlive the data they point to
  • [+] The Borrow Checker: compares scopes to ensure borrows are valid







QUIZ

Recall from last lecture: what happens when we compile this?

fn longer(s1: &str, s2: &str) -> &str {
    if s1.len() >= s2.len() { s1 } else { s2 }
}

fn main() {
    let text = String::from("bat man begins");
    let first = &text[0..3];
    let last = &text[8..14];
    let result = longer(first, last);
    println!("longer word: {result}");
}









Problem: Which Reference Does the Return Come From?

The compiler doesn’t know if the return value comes from s1 or s2!

error[E0106]: missing lifetime specifier
 --> src/main.rs:1:35
  |
1 | fn longer(s1: &str, s2: &str) -> &str {
  |               ----      ----     ^ expected named lifetime parameter
  |
  = help: this function's return type contains a borrowed value,
          but the signature does not say whether it is borrowed
          from `s1` or `s2`
help: consider introducing a named lifetime parameter
  |
1 | fn longer<'a>(s1: &'a str, s2: &'a str) -> &'a str {
  |          ++++      ++           ++           ++

Read the error message! It tells us to add a lifetime parameter 'a.









Why is the Borrow Checker Confused?

Consider two possible callers:

fn test_ok() {
    let s1 = String::from("hello");
    let s2 = String::from("hi");
    let result = longer(&s1, &s2);  // both live long enough
    println!("{result}");           // OK!
}
fn test_bad() {
    let s1 = String::from("hello");
    let result;
    {
        let s2 = String::from("hi");
        result = longer(&s1, &s2);   // s2 about to die!
    }
    println!("{result}");             // YIKES: result might point to dead s2!
}

The borrow checker can’t tell from the function signature alone how long the return value lives.

We have to tell it using lifetime annotations!









Lifetime Annotation Syntax

Lifetime annotations describe relationships between lifetimes of references.

  • Start with an apostrophe '
  • Usually short, lowercase: 'a, 'b, etc.
  • Placed after the & in a reference type
&i32            // a reference (implicit lifetime)
&'a i32         // a reference with explicit lifetime 'a
&'a mut i32     // a mutable reference with explicit lifetime 'a

Note: Annotations don’t change how long a reference lives. They just describe the relationships between lifetimes.









Goal Today: Lifetimes

  • [+] Dangling References: references must not outlive the data they point to
  • [+] The Borrow Checker: compares scopes to ensure borrows are valid
  • [+] Lifetime Annotations: &'a T to describe relationships between lifetimes







Lifetime Annotations in Functions

Add a generic lifetime parameter 'a to the function signature

fn longer<'a>(s1: &'a str, s2: &'a str) -> &'a str {
    if s1.len() >= s2.len() { s1 } else { s2 }
}

This signature says:

  • For some lifetime 'a,
  • both s1 and s2 must live at least as long as 'a,
  • and the returned reference also lives at least as long as 'a.

In practice: 'a is the shorter of the two input lifetimes.

Haskell Comparison

There’s no equivalent in Haskell because Haskell has GC!









QUIZ

Does this code compile and run?

fn longer<'a>(s1: &'a str, s2: &'a str) -> &'a str {
    if s1.len() >= s2.len() { s1 } else { s2 }
}

fn main() {
    let s1 = String::from("long string is long");
    {
        let s2 = String::from("xyz");
        let result = longer(s1.as_str(), s2.as_str());
        println!("The longer string is: {result}");
    }
}









Yes! Both References Are Valid When Used

fn main() {
    let s1 = String::from("long string is long"); // ---+-- 'a
    {                                             //    |
        let s2 = String::from("xyz");             // -+-|-- 'b
        let result = longer(                      //  | |
            s1.as_str(),                          //  | |
            s2.as_str()                           //  | |
        );                                        //  | |
        println!("longer: {result}");             //  | |  OK!
    }                                             // -+ |
}                                                 // ---+

result is used inside the inner scope where both s1 and s2 are alive.









QUIZ

What about this code?

fn longer<'a>(s1: &'a str, s2: &'a str) -> &'a str {
    if s1.len() >= s2.len() { s1 } else { s2 }
}

fn main() {
    let s1 = String::from("long string is long");
    let result;
    {
        let s2 = String::from("xyz");
        result = longer(s1.as_str(), s2.as_str());
    }
    println!("The longer string is: {result}");
}









No! result Might Point to Dead Data

error[E0597]: `s2` does not live long enough
 --> src/main.rs:6:44
  |
5 |         let s2 = String::from("xyz");
  |             -- binding `s2` declared here
6 |         result = longer(s1.as_str(), s2.as_str());
  |                                      ^^ borrowed value does not live long enough
7 |     }
  |     - `s2` dropped here while still borrowed
8 |     println!("The longer string is: {result}");
  |                                       ------ borrow later used here

Even though longer would return s1 (which is longer), the compiler doesn’t know that! The signature says result lives as long as the shorter lifetime.









Thinking in Terms of Lifetimes

The lifetime parameters depend on what the function does.

If longer always returned the first argument, we’d only need:

fn longer<'a>(s1: &'a str, s2: &str) -> &'a str {
    s1
}

No need to relate the lifetime of s2 to the return value!

Key idea: annotate the relationships between the inputs and outputs that actually exist.









QUIZ

What happens when we compile this?

fn longer<'a>(s1: &str, s2: &str) -> &'a str {
    let result = String::from("really long string");
    result.as_str()
}









Cannot Return a Reference to a Local Variable!

error[E0515]: cannot return value referencing local variable `result`
  --> src/main.rs:3:5
   |
3  |     result.as_str()
   |     ------^^^^^^^^^
   |     |
   |     returns a value referencing data owned by the current function

result is dropped when longer returns, so we’d get a dangling reference.

Fix: return an owned value instead of a reference

fn longer(s1: &str, s2: &str) -> String {
    let result = String::from("really long string");
    result    // ownership transferred to caller
}









Goal Today: Lifetimes

  • [+] Dangling References: references must not outlive the data they point to
  • [+] The Borrow Checker: compares scopes to ensure borrows are valid
  • [+] Lifetime Annotations: &'a T to describe relationships between lifetimes
  • [+] Lifetimes in Functions: fn foo<'a>(x: &'a T, ...) -> &'a T







Lifetimes in Structs

What if a struct holds a reference?

#[derive(Debug)]
struct Excerpt {
    part: &str,
}

Compiler error!

error[E0106]: missing lifetime specifier
 --> src/main.rs:3:11
  |
3 |     part: &str,
  |           ^ expected named lifetime parameter
  |
help: consider introducing a named lifetime parameter
  |
2 ~ struct Excerpt<'a> {
3 ~     part: &'a str,









Lifetimes in Structs: the Fix

The struct must declare the lifetime of its reference fields

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

This says: an Excerpt cannot outlive the reference it holds in part.

fn main() {
    let novel = String::from("Call me Ishmael. Some years ago...");
    let first_sentence = novel.split('.').next().unwrap();
    let exc = Excerpt { part: first_sentence };
    println!("{:?}", exc);
}

Here novel outlives exc, so the reference is valid.









QUIZ

What happens when we compile this?

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

fn main() {
    let exc;
    {
        let novel = String::from("Call me Ishmael.");
        exc = Excerpt { part: &novel };
    }
    println!("{:?}", exc);
}









Struct Cannot Outlive its Reference!

error[E0597]: `novel` does not live long enough
  --> src/main.rs:9:33
   |
8  |         let novel = String::from("Call me Ishmael.");
   |             ----- binding `novel` declared here
9  |         exc = Excerpt { part: &novel };
   |                                ^^^^^ borrowed value does not live long enough
10 |     }
   |     - `novel` dropped here while still borrowed
11 |     println!("{:?}", exc);
   |                      --- borrow later used here

novel is dropped at end of inner scope, but exc lives longer, so the reference is invalid.

Same idea as with function lifetimes: the borrow checker ensures the data outlives the struct.









Goal Today: Lifetimes

  • [+] Dangling References: references must not outlive the data they point to
  • [+] The Borrow Checker: compares scopes to ensure borrows are valid
  • [+] Lifetime Annotations: &'a T to describe relationships between lifetimes
  • [+] Lifetimes in Functions: fn foo<'a>(x: &'a T, ...) -> &'a T
  • [+] Lifetimes in Structs: struct Foo<'a> { field: &'a T }







Lifetime Elision

Hang on … we wrote functions with & references before and never needed lifetimes!

fn first_word_str(s: &String) -> &str {
    ...
}

Why didn’t the compiler complain?









Lifetime Elision Rules

The compiler applies elision rules to infer lifetimes automatically.

Rule 1: Each reference parameter gets its own lifetime parameter.

fn foo(x: &i32)              // ==> fn foo<'a>(x: &'a i32)
fn foo(x: &i32, y: &i32)     // ==> fn foo<'a, 'b>(x: &'a i32, y: &'b i32)

Rule 2: If there’s exactly one input lifetime, it’s assigned to all outputs.

fn foo(x: &i32) -> &i32      // ==> fn foo<'a>(x: &'a i32) -> &'a i32

Rule 3: If one of the inputs is &self or &mut self, the lifetime of self is assigned to all outputs.









Elision Example 1: Single Reference Parameter

fn first_word_str(s: &String) -> &str { ... }

Applying the rules:

  1. Rule 1: s gets lifetime 'a ==> fn first_word_str<'a>(s: &'a String) -> &str
  2. Rule 2: one input lifetime, assign to output ==> fn first_word_str<'a>(s: &'a String) -> &'a str

All output lifetimes resolved! No explicit annotations needed.









Elision Example 2: Two Reference Parameters

fn longer(s1: &str, s2: &str) -> &str { ... }

Applying the rules:

  1. Rule 1: each gets a lifetime ==> fn longer<'a, 'b>(s1: &'a str, s2: &'b str) -> &str
  2. Rule 2: does NOT apply (two input lifetimes, not one)
  3. Rule 3: does NOT apply (not a method)

Output lifetime is unresolved … compiler requires annotation!

fn longer<'a>(s1: &'a str, s2: &'a str) -> &'a str { ... }









QUIZ

Does this need explicit lifetime annotations?

fn say(z: &String) {
    let n = z.len();
    println!("length of z = {n}");
}









No! No Output References

say returns nothing (no output references), so no lifetime annotations are needed.

Rule 1 assigns 'a to z, but there’s no output to annotate.

Lifetimes are only needed when the function returns a reference (or when a struct holds a reference).









The 'static Lifetime

One special lifetime: 'static means the reference lives for the entire program.

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

All string literals have the 'static lifetime because they are baked into the binary.

Warning: Don’t use 'static as a band-aid to fix lifetime errors!

If the compiler suggests 'static, it usually means there’s a real bug – fix the underlying problem instead.









Goal Today: Lifetimes

  • [+] Dangling References: references must not outlive the data they point to
  • [+] The Borrow Checker: compares scopes to ensure borrows are valid
  • [+] Lifetime Annotations: &'a T to describe relationships between lifetimes
  • [+] Lifetimes in Functions: fn foo<'a>(x: &'a T, ...) -> &'a T
  • [+] Lifetimes in Structs: struct Foo<'a> { field: &'a T }
  • [+] Lifetime Elision: compiler infers lifetimes in common cases







QUIZ

fn quiz1<'a>(x: &'a str) -> &'a str {
    x
}
fn quiz2<'a>(x: &'a str) -> &'a str {
    let s = String::from("hello");
    s.as_str()
}
fn quiz3<'a>(x: &'a str, y: &'a str) -> &'a str {
    if x.len() > 0 { x } else { y }
}
fn quiz4<'a, 'b>(x: &'a str, y: &'b str) -> &'a str {
    x
}

Which of these compile?









Answers

  • quiz1: compiles – returns x which has lifetime 'a
  • quiz2: error – returns reference to local s which is dropped!
  • quiz3: compiles – both x and y have lifetime 'a, return has lifetime 'a
  • quiz4: compiles – always returns x with lifetime 'a, y’s lifetime 'b is unrelated









Summary: Lifetimes

Lifetimes are Rust’s way of ensuring memory safety at compile time

  • Dangling references are prevented by the borrow checker
  • The borrow checker compares scopes to validate borrows
  • Lifetime annotations ('a) describe relationships between reference lifetimes
  • The compiler uses elision rules to infer lifetimes in common cases
  • 'static is a special lifetime for data that lives the entire program

All of this happens at compile time – zero runtime overhead!









Material Inspired by

  • https://doc.rust-lang.org/book/ch10-03-lifetime-syntax.html
  • https://rust-book.cs.brown.edu/ch04-01-what-is-ownership.html