Lifetimes
keywords : safety, borrow checker, dangling references
The concept of lifetimes is central to Rust’s ownership and borrowing system, which ensures memory safety without the need for a garbage collector.
Every reference in Rust has a lifetime, which is the scope for which that reference is valid. The Rust compiler’s borrow checker will compare scopes to determine whether all borrows are valid.
Wherever borrowing happens, the lifetime should be considered.
Most of the time, lifetimes are implicit and inferred. But we must annotate lifetimes when the lifetimes of references could be related in a few different ways.
The Main Aim of lifetimes is to prevent dangling references, which cause a program to reference data other than the data it’s intended to reference.
For furture reading:
Validating References with Lifetimes
Key Concepts of Lifetimes
- References and Borrowing:
- A reference in Rust is a way of borrowing data, allowing you to
refer to a value without taking ownership of it. References are denoted
by
&T(immutable reference) or&mut T(mutable reference). - Since Rust guarantees memory safety, it needs to ensure that References Never Outlive the Data they Point to . Lifetimes help enforce this rule.
- A reference in Rust is a way of borrowing data, allowing you to
refer to a value without taking ownership of it. References are denoted
by
- Lifetime Inference:
- Most of the time, lifetimes are implicit and inferred. Rust’s lifetime elision rules cover the most common cases.
- They don’t provide full inference. If Rust deterministically applies the rules but there is still ambiguity as to what lifetimes the references have, the compiler will give you an error that you can resolve by adding the lifetime annotations.
- Lifetime Annotations:
- Lifetime annotations are a way to explicitly specify how long
references are valid in your code. They appear in the form of
&'a Twhere'ais the lifetime. - Lifetime annotations do not change the lifetime of references; they simply describe the relationships between lifetimes.
- Lifetime annotations are a way to explicitly specify how long
references are valid in your code. They appear in the form of
- lifetime elision rules
- If the codes are written in some way, the borrow checker could infer the lifetimes without explicit annotations. The patterns programmed into Rust’s analysis of references are called the lifetime elision rules .
- These aren’t rules for programmers to follow; they’re a set of particular cases that the compiler will consider, and if your code fits these cases, you don’t need to write the lifetimes explicitly.
- Ownership and Lifetimes:
- When a reference is created, Rust must ensure that the data it points to lives long enough to be valid for the entire duration of the reference’s use.
- Lifetimes are particularly important when dealing with functions, structs, and any situation where references are passed around and returned.
- Safety
- Every reference will have a lifetime (scope) implicitly added by the compiler or explicitly added by the user.
- The Rust compiler’s borrow checker will compare scopes to determine whether all borrows are valid.
Example: Scopes (i.e., the lifetimes)
// case 1
fn main() {
let r; // ---------+-- 'a
// |
{ // |
let x = 5; // -+-- 'b |
r = &x; // | |
} // -+ |
// |
println!("r: {}", r); // |
} // ---------+
// case 2
fn main() {
let x = 5; // ----------+-- 'b
// |
let r = &x; // --+-- 'a |
// | |
println!("r: {}", r); // | |
// --+ |
} // ----------+
Example: Lifetime Errors - Dangling References
Rust’s compiler enforces lifetime rules to prevent errors such as dangling references. (It is the main aim of lifetime). If a reference outlives the data it points to, Rust will produce a compile-time error, avoiding issues that could lead to undefined behavior.
fn dangling_reference() -> &String {
let s = String::from("hello");
&s // Error: `s` does not live long enough
}
Example: How borrow checker works with lifetime
Consider a function that returns a reference to the larger of two integer slices:
fn longest<'a>(x: &'a str, y: &'a str) -> &'a str {
if x.len() > y.len() {
x
} else {
y
}
}
'a: This is a lifetime parameter. It indicates that the references x, y, and the returned reference all have the same lifetime'a.- Purpose: This means that the returned reference will be valid for as long as both x and y are valid. If either of the input references goes out of scope before the returned reference, Rust will produce a compile-time error.
- concrete reference: When we pass concrete references to
longest, the concrete lifetime that is substituted for
'ais the part of the scope of x that overlaps with the scope of y. the returned reference will also be valid for the length of the smaller of the lifetimes of x and y. The compiler borrow checker will check if it is valid.
The following examples show distinct scenario. One will pass the check and the other will not pass it. It shows that lifetimes help to prevent dangling references. With lifetimes, the borrow checker ensures data outlives its references.
// Example: pass the valid check
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);
}
}
// Example: fail
fn main() {
let string1 = String::from("long string is long");
let result;
{
let string2 = String::from("xyz");
result = longest(string1.as_str(), string2.as_str()); // <-- voilate lifetime contract
} // <--- if it does not fail, here will introduce dangling references
println!("The longest string is {}", result);
}
// result:
$ cargo run
Compiling chapter10 v0.1.0 (file:///projects/chapter10)
error[E0597]: `string2` does not live long enough
--> src/main.rs:6:44
|
6 | result = longest(string1.as_str(), string2.as_str());
| ^^^^^^^^^^^^^^^^ borrowed value does not live long enough
7 | }
| - `string2` dropped here while still borrowed
8 | println!("The longest string is {}", result);
| ------ borrow later used here
For more information about this error, try `rustc --explain E0597`.
error: could not compile `chapter10` due to previous error
Example: Lifetime Elision
The compiler uses three rules to figure out the lifetimes of the references when there aren’t explicit annotations.
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[..]
}
The lifetime elision rules applies:
fn first_word<'a>(s: &'a str) -> &'a str {
Now all the references in this function signature have lifetimes, and the compiler can continue its analysis without needing the programmer to annotate the lifetimes in this function signature.
Example with Partially Annotated Lifetimes
The way in which you need to specify lifetime parameters depends on what your function is doing.
case 1
fn longest<'a>(x: &'a str, y: &str) -> &'a str {
x
}
We wouldn’t need to specify a lifetime on the y parameter because the lifetime of y has no relationship with the lifetime of x or the return value.
case 2
pub fn search<'a>(query: &'a str, contents: &str) -> Vec<&'a str> {
let mut results = Vec::new();
for line in contents.lines() {
if line.contains(query) {
results.push(line);
}
}
results
}
// result:
error[E0621]: explicit lifetime required in the type of `contents`
--> src\lib.rs:41:5
|
32 | pub fn search<'a>(query: &'a str, contents: &str) -> Vec<&'a str> {
| ---- help: add explicit lifetime `'a` to the type of `contents`: `&'a str`
...
41 | results
| ^^^^^^^ lifetime `'a` required
The returned reference has to do with the ‘contents’ parameter. The user shall specify lifetime to tell their relationship. And then borrow checker will compare scopes to determine whether all borrows are valid. Otherwise the compiler will automatically give it a distinct lifetime that does not tell that relationship. Just like the following:
pub fn search<'a, 'b>(query: &'a str, contents: &'b str) -> Vec<&'a str> {
let mut results = Vec::new();
for line in contents.lines() {
if line.contains(query) {
results.push(line);
}
}
results
}
// result:
error: lifetime may not live long enough
--> src\lib.rs:41:5
|
32 | pub fn search<'a, 'b>(query: &'a str, contents: &'b str) -> Vec<&'a str> {
| -- -- lifetime `'b` defined here
| |
| lifetime `'a` defined here
...
41 | results
| ^^^^^^^ function was supposed to return data with lifetime `'a` but it is returning data with lifetime `'b`
|
= help: consider adding the following bound: `'b: 'a`
As we can see, the lifetime of contents parameter has no relationship with the lifetime of returned value. It is not the case.
Our solution is to specify lifetimes to make clear the relationship.
// solution 1
pub fn search<'a, 'b>(query: &'a str, contents: &'b str) -> Vec<&'b str> {
let mut results = Vec::new();
for line in contents.lines() {
if line.contains(query) {
results.push(line);
}
}
results
}
// solution 2
pub fn search<'b>(query: &str, contents: &'b str) -> Vec<&'b str> {
let mut results = Vec::new();
for line in contents.lines() {
if line.contains(query) {
results.push(line);
}
}
results
}
Summary
Lifetimes are a way of telling the Rust compiler how long references should be valid.
- Enforcement: Lifetimes are enforced at compile time to ensure that references do not outlive the data they refer to, preventing dangling references and ensuring memory safety.
- Annotations: While Rust can often infer lifetimes, sometimes you need to annotate them explicitly, particularly in more complex scenarios.
'staticLifetime: A special lifetime indicating that the reference is valid for the entire duration of the program.