The Rust Lang Book: Rust Lifetimes Finally Explained!
This chapter explores the mechanics of Rust's lifetimes, a core compile-time feature designed to prevent memory safety issues like dangling references. We will examine how the borrow checker analyzes reference validity, how to write generic lifetime annotations when the compiler encounters ambiguity, and how to apply these concepts to functions, structs, and methods. Finally, we will synthesize lifetimes, generics, and traits into a single, production-ready implementation.
1. The Problem of Dangling References
A dangling reference occurs when a reference points to invalid memory (data that has already been deallocated or gone out of scope). Rust's primary goal is to guarantee memory safety at compile time, which means the compiler must strictly forbid dangling references.
Naive Code: Creating a Dangling Reference
Consider the following invalid Rust code where we attempt to assign a reference to a variable that goes out of scope:
fn main() {
let r; // Declared in the outer scope
{
let x = 5; // Declared in the inner scope
r = &x; // r now points to x
} // x goes out of scope and is deallocated here!
println!("r: {}", r); // Error! r points to invalid memory
}
The Compiler's Response
If you attempt to compile this code, the Rust compiler stops compilation and throws a clear error:
2. The Borrow Checker's Mental Model
To prevent dangling references, the Rust compiler uses an internal component called the borrow checker. The borrow checker runs at compile time and compares the scopes of variables to ensure that all borrows remain valid.
To understand how the borrow checker works, we can visualize the lifetimes of variables using lifetime annotations like 'a and 'b. The lifetime of a variable refers to the exact span of execution during which that variable is valid in memory.
Visualizing Scopes and Lifetimes
Let's map the lifetimes of the variables from our invalid example:
fn main() {
let r; // ---------+-- 'a (Lifetime of r)
// |
{ // |
let x = 5; // ---+-- 'b| (Lifetime of x)
r = &x; // | |
} // ---+ |
// |
println!("r: {}", r); // |
} // ---------+
The Borrow Checker's Analysis
The borrow checker compares the lifetime of the reference holder with the lifetime of the data being referenced:
Because the lifetime of the referenced data ('b) is shorter than the lifetime of the reference itself ('a), the borrow checker identifies that r would become a dangling reference after the inner scope ends. Consequently, it rejects the program.
A Valid Lifetime Configuration
If we extend the lifetime of the referenced variable so that it lives at least as long as the reference, the code compiles successfully:
fn main() {
let x = 5; // ---------+-- 'b (Lifetime of x)
// |
let r = &x; // ---+-- 'a| (Lifetime of r)
// | |
println!("r: {}", r); // | |
} // ---+-----+
Here, the lifetime of the referenced data ('b) is larger than or equal to the lifetime of the reference ('a). The borrow checker confirms that r will never point to invalid data.
3. The Ambiguity of Multiple References
The borrow checker is highly capable and can automatically infer lifetimes in simple, localized code. However, when references are passed into and returned from functions, the compiler encounters ambiguity.
The Problematic longest Function
Consider a function designed to compare two string slices and return the longer one:
fn main() {
let string1 = String::from("abcd");
let string2 = "xyz";
let result = longest(string1.as_str(), string2);
println!("The longest string is {}", result);
}
// This code will NOT compile!
fn longest(x: &str, y: &str) -> &str {
if x.len() > y.len() {
x
} else {
y
}
}
Why the Compiler Fails
When analyzing the signature of longest, the compiler faces two major points of ambiguity:
- Dynamic Execution Paths: The function returns either
xorybased on a runtime condition (x.len() > y.len()). The compiler cannot predict at compile time which branch will execute. - Varying Input Lifetimes: The parameters
xandycould be passed from entirely different parts of the program, meaning they could have completely different, unrelated lifetimes.
Because the compiler does not know whether the returned reference will point to the memory of x or y, it cannot guarantee that the returned reference will remain valid. It halts compilation with the following error:
4. Understanding Generic Lifetime Annotations
To resolve this ambiguity, we must use generic lifetime annotations (often referred to simply as lifetimes).
Important Concept: Lifetime annotations do not change how long any reference actually lives. Instead, they describe the relationship between the lifetimes of multiple references so that the borrow checker can validate them.
Syntax Comparison
Lifetime annotations use a special syntax. They are declared using a single quote (or "tick") followed by a lowercase name. By convention, we start with 'a and proceed alphabetically.
5. Resolving Ambiguity: The Step-by-Step Evolution of the longest Function
To fix our longest function, we must declare a generic lifetime parameter inside angle brackets (<'a>) after the function name, and then apply that lifetime to our parameters and return type.
The Corrected Function Signature
fn longest<'a>(x: &'a str, y: &'a str) -> &'a str {
if x.len() > y.len() {
x
} else {
y
}
}
How the Borrow Checker Interprets This Relationship
By annotating x, y, and the return type with the same lifetime 'a, we are establishing a concrete relationship:
The borrow checker does not modify the lifetimes of the arguments passed into longest. Instead, it looks at the concrete lifetimes of the arguments at the call site, finds the smallest one, and assigns that smallest lifetime to the returned reference.
6. Real-World Lifetime Scenarios & Scope Violations
Let's analyze how the borrow checker evaluates different concrete lifetimes when calling our annotated longest function.
Scenario A: Valid Execution (Nested Scopes)
In this example, string1 lives for the entire outer scope, while string2 and result are declared in an inner scope.
fn main() {
let string1 = String::from("long string is long"); // ---+-- 'string1
// |
{ // |
let string2 = String::from("xyz"); // ---+-- 'string2 (shorter)
let result = longest(string1.as_str(), string2.as_str()); // |
println!("The longest string is {}", result); // |
} // ---+---+
}
Why it compiles:
- The arguments passed to
longesthave lifetimes'string1and'string2. - The smallest lifetime is
'string2. - Therefore, the returned
resultis assigned the lifetime of'string2. - Since
resultis printed inside the scope ofstring2, the reference is guaranteed to point to valid memory.
Scenario B: Invalid Execution (Scope Violation)
In this example, we attempt to access the result outside of the scope of the shorter-lived variable string2.
fn main() {
let string1 = String::from("long string is long"); // ---------+-- 'string1
let result; // |
// |
{ // |
let string2 = String::from("xyz"); // ---+-- 'string2 (shorter)
result = longest(string1.as_str(), string2.as_str()); // |
} // ---+ | (string2 is dropped!)
// |
println!("The longest string is {}", result); // Error! -+
}
Why it fails to compile:
- The borrow checker determines that the returned
resulthas a lifetime equal to the smaller input lifetime, which is'string2. - The scope of
string2ends at the closing brace of the inner block. - When we attempt to print
resultin the outer block,string2has already been deallocated. - If
longesthad returned a reference tostring2, printingresultwould access deallocated memory. The compiler prevents this potential bug.
7. The Golden Rule of Return References & Local Variables
When returning a reference from a function, the lifetime of the return value must be tied to the lifetime of at least one of the input parameters.
The Anti-Pattern: Returning a Reference to a Local Variable
If you attempt to create a value inside a function and return a reference to it, the code will fail to compile regardless of how you write your lifetime annotations:
// This will NOT compile!
fn longest<'a>(x: &str, y: &str) -> &'a str {
let result = String::from("really long string");
&result // Error! result is dropped when the function ends
}
The Root Cause
When a function finishes executing, all local variables (such as result) are dropped and their memory is freed. Returning a reference to result would immediately result in a dangling reference.
The Solution: Transferring Ownership
To return a value created inside a function, you must return an owned type rather than a reference. This transfers ownership of the data directly to the caller:
fn longest(x: &str, y: &str) -> String {
let result = String::from("really long string");
result // Ownership is moved to the caller; no lifetimes needed!
}
8. Structs with Lifetime Annotations
So far, we have only used structs that own their data (e.g., using String or i32). However, a struct can also hold references to data owned by something else. To do this, we must add a lifetime annotation to the struct's definition.
Defining a Struct with References
struct ImportantExcerpt<'a> {
part: &'a str, // This reference must live at least as long as the struct instance
}
This annotation tells the compiler that an instance of ImportantExcerpt cannot outlive the reference stored in its part field.
Instantiating the Struct
fn main() {
let novel = String::from("Call me Ishmael. Some years ago...");
// Extract the first sentence
let first_sentence = novel.split('.').next().expect("Could not find a '.'");
// Store the reference in our struct
let i = ImportantExcerpt {
part: first_sentence,
};
println!("Excerpt: {}", i.part);
}
If novel were to go out of scope while i was still active, the borrow checker would flag it as an error, ensuring i.part never points to invalid memory.
9. The Three Lifetime Elision Rules
In early versions of Rust (pre-1.0), developers had to write explicit lifetime annotations for every single function that used references. Over time, the Rust team identified deterministic patterns in how developers wrote lifetimes and programmed these patterns into the compiler. These are known as the Lifetime Elision Rules.
If your code fits these rules, the compiler will automatically inject the lifetimes behind the scenes, allowing you to omit (elide) them.
Key Terminology
- Input Lifetimes: Lifetime annotations applied to function parameters.
- Output Lifetimes: Lifetime annotations applied to function return values.
The Three Rules of Elision
If the compiler applies these three rules and still cannot resolve all output lifetimes, it will halt compilation and ask you to provide explicit annotations.
10. Lifetimes in Method Implementations (Impl Blocks)
When implementing methods on a struct that has a lifetime annotation, we must declare the generic lifetime parameter in the impl block header.
Syntax of Lifetime-Annotated Impl Blocks
struct ImportantExcerpt<'a> {
part: &'a str,
}
impl<'a> ImportantExcerpt<'a> {
// Method using Rule 3 of Lifetime Elision
fn return_part(&self, announcement: &str) -> &str {
println!("Attention: {}", announcement);
self.part
}
}
How Elision Rules Apply to return_part
Let's trace why we do not need to write explicit lifetimes on return_part:
- Apply Rule 1: Every reference parameter gets its own lifetime.
&selfgets'a(from the struct definition).announcement: &strgets'b.
- Apply Rule 2: Rule 2 does not apply because we have two input lifetimes (
'aand'b). - Apply Rule 3: Because one of our parameters is
&self, the output lifetime (the return type&str) is automatically assigned the lifetime ofself('a).
Thus, the compiler automatically expands the signature to:
fn return_part<'b>(&'a self, announcement: &'b str) -> &'a str
11. The Static Lifetime & Putting It All Together
The 'static Lifetime
The 'static lifetime is a special, reserved lifetime in Rust. It denotes that the referenced data can live for the entire duration of the program.
All string literals have an implicit 'static lifetime because their data is hardcoded directly into the read-only data segment of the program's binary:
// These two declarations are identical
let s: &str = "I have a static lifetime.";
let s: &'static str = "I have a static lifetime.";
Grand Finale: Combining Generics, Traits, Trait Bounds, and Lifetimes
To conclude Chapter 10, we can write a single, unified function that showcases all the advanced generic features of Rust:
- Generics: A generic type parameter
T. - Traits & Trait Bounds: Restricting
Tto types that implement theDisplaytrait using awhereclause. - Explicit Lifetimes: Annotating input references and the return type with
'ato manage reference relationships.
use std::fmt::Display;
fn longest_with_an_announcement<'a, T>(
x: &'a str,
y: &'a str,
ann: T,
) -> &'a str
where
T: Display,
{
println!("Announcement! {}", ann);
if x.len() > y.len() {
x
} else {
y
}
}
fn main() {
let string1 = String::from("apple");
let string2 = "banana";
let result = longest_with_an_announcement(
string1.as_str(),
string2,
"Comparing fruit lengths!",
);
println!("The longest fruit is: {}", result);
}
Summary and Key Takeaways
- Dangling References: Rust prevents dangling references at compile time by ensuring that no reference outlives the data it points to.
- The Borrow Checker: An internal compiler tool that compares variable scopes (
'avs'b) to guarantee reference validity. - No Lifetime Modification: Generic lifetime annotations do not alter how long a variable lives; they merely document the relationships between lifetimes for the compiler.
- The Smallest Lifetime Rule: When multiple references are annotated with the same lifetime
'a, the returned reference is constrained to the lifetime of the shortest-lived argument. - Return Value Restrictions: Any reference returned from a function must be tied to an input parameter's lifetime. If you need to return a locally created value, return an owned type (e.g.,
String) to transfer ownership. - Struct References: If a struct stores a reference, the struct definition must include a generic lifetime annotation (e.g.,
struct MyStruct<'a>). - Lifetime Elision: The compiler automatically infers lifetimes in common patterns using three deterministic rules, minimizing the need for manual annotations.
- The
'staticLifetime: A special lifetime indicating that data lives for the entire duration of the program (e.g., hardcoded string literals).