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:
- How do lifetimes prevent dangling pointer bugs that plague other systems languages?
- When does Rust require explicit lifetime annotations vs. lifetime elision?
- How do lifetime parameters relate to generic type parameters?
- What are the trade-offs between memory safety and programming convenience in lifetime systems?
- 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
randx. -
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
xgoes out of scope before we use a reference,r, tox. -
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
'ais 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
resultafter 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
ImportantExcerptcan't outlive the reference it holds in thepartfield.
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
&selfor&mut selfbecause 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
&selfand 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 parameterT: A type parameter
- It takes three arguments:
x: A string slice with lifetime'ay: A string slice with lifetime'aann: A value of generic typeT
- Returns a string slice with lifetime
'a - The
whereclause specifies that typeTmust implement theDisplaytrait
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).