11.3 Lifetimes in Rust
11.3.1 Understanding Lifetimes
Lifetimes are a way for Rust to track how long references are valid, preventing dangling references and ensuring memory safety without a garbage collector. Lifetimes are especially important when working with references in functions, structs, and traits.
A lifetime in Rust is a construct the compiler (or more specifically, the borrow checker) uses to ensure that all borrows are valid. It represents the scope during which a reference is valid. By assigning lifetimes to references, Rust can check at compile time that you are not using references that have become invalid.
Key Points:
- Ownership and Borrowing: Lifetimes work with Rust's ownership model to manage memory safety.
- Compiler Checks: Rust uses lifetimes to enforce that references do not outlive the data they point to.
- Annotations: Sometimes, you need to annotate lifetimes explicitly to help the compiler understand the relationships between references.
11.3.2 Lifetime Annotations
Lifetime annotations are specified using an apostrophe followed by a name, like 'a
. They are used to label references so the compiler can ensure they are valid.
Syntax:
&'a Type
Here, 'a
is a lifetime parameter associated with the reference.
Typically, lowercase letters like 'a
, 'b
, etc., are used for lifetime parameters.
Example with Lifetime Annotations:
#![allow(unused)] fn main() { fn print_ref<'a>(x: &'a i32) { println!("x is {}", x); } }
In this example:
- The function
print_ref
takes a reference to ani32
with a lifetime'a
. - The lifetime
'a
indicates that the referencex
is valid for at least as long as'a
.
Note: In this simple case, the lifetime annotation is not strictly necessary, as the compiler can infer the lifetimes. We include the annotation here to illustrate the syntax.
11.3.3 Lifetimes in Functions
When a function returns a reference, you often need to specify lifetime parameters to indicate how the lifetimes of the input parameters relate to the output.
Example Without Lifetimes (Will Not Compile):
fn longest(x: &str, y: &str) -> &str {
if x.len() > y.len() {
x
} else {
y
}
}
This code will not compile because the compiler cannot determine how the lifetimes of x
, y
, and the return value are related. The compiler needs explicit annotations to ensure memory safety.
Adding Lifetime Annotations:
#![allow(unused)] fn main() { fn longest<'a>(x: &'a str, y: &'a str) -> &'a str { if x.len() > y.len() { x } else { y } } }
Explanation:
- Lifetime Parameter
'a
: We introduce a lifetime parameter'a
that represents a generic lifetime. This lifetime parameter doesn't specify how long the lifetime is; instead, it tells the compiler that all references annotated with'a
are related in a particular way. - Input References: Both
x
andy
are references that have the lifetime'a
, meaning they are valid for at least as long as'a
. - Return Reference: The function returns a reference with the lifetime
'a
, indicating that the returned reference is valid for at least as long as'a
.
Understanding Lifetimes in This Context:
- The function
longest
can acceptx
andy
with different lifetimes. - The lifetime
'a
ensures that the returned reference cannot outlive eitherx
ory
. - The returned reference is valid only as long as both
x
andy
are valid, specifically the shorter of the two lifetimes.
Note: The lifetime annotations do not affect the runtime performance of the code; they are checked at compile time and do not exist in the compiled machine code.
11.3.4 Lifetime Elision Rules
In many cases, Rust can infer lifetimes, and you don't need to write them explicitly. The compiler uses lifetime elision rules to determine lifetimes when they are not explicitly annotated.
There are three main rules:
-
Each parameter that is a reference gets its own lifetime parameter.
- Example:
fn foo(x: &i32, y: &i32)
becomesfn foo<'a, 'b>(x: &'a i32, y: &'b i32)
- Example:
-
If there is exactly one input lifetime parameter, that lifetime is assigned to all output lifetime parameters.
- Example:
fn foo(x: &i32) -> &i32
becomesfn foo<'a>(x: &'a i32) -> &'a i32
- Example:
-
If there are multiple input lifetime parameters, but one of them is
&self
or&mut self
, the lifetime ofself
is assigned to all output lifetime parameters.- This rule applies to methods of structs or traits.
Example:
#![allow(unused)] fn main() { impl<'a> Excerpt<'a> { fn announce_and_return_part(&self, announcement: &str) -> &str { println!("Attention please: {}", announcement); self.part } } }
In this method:
- The method takes
&self
andannouncement: &str
. - According to rule 3, the lifetime of
self
('a
) is assigned to the return reference. - We don't need to specify lifetimes explicitly because the compiler applies the elision rules.
Note: When the compiler can apply the lifetime elision rules, you do not need to annotate lifetimes explicitly. This helps keep code concise and readable.
11.3.5 Lifetimes in Structs
Structs can have lifetime parameters to ensure that references within the struct do not outlive the data they point to.
Example:
struct Excerpt<'a> { part: &'a str, } fn main() { let text = String::from("The quick brown fox jumps over the lazy dog."); let first_word = text.split_whitespace().next().unwrap(); let excerpt = Excerpt { part: first_word }; println!("Excerpt: {}", excerpt.part); }
Explanation:
- Lifetime Parameter
'a
: The structExcerpt
has a lifetime parameter'a
because it holds a referencepart
that must not outlive the data it points to. - Instance Creation: In
main
,text
owns the string data, andfirst_word
is a slice (&str
) oftext
. The lifetime offirst_word
is tied totext
. - Struct Instance: The
excerpt
instance holds a reference tofirst_word
, soexcerpt
cannot outlivetext
. - Compiler Enforcement: The compiler uses the lifetime annotations to ensure that
excerpt.part
remains valid for as long asexcerpt
is in use.
11.3.6 Lifetimes with Generics and Traits
Lifetimes often interact with generics and traits, especially when working with references.
Example with Generics and Lifetimes:
#![allow(unused)] fn main() { use std::fmt::Display; fn announce_and_return_part<'a, T>(announcement: T, text: &'a str) -> &'a str where T: Display, { println!("Announcement: {}", announcement); &text[0..5] } }
Explanation:
- Lifetime Parameter
'a
: Indicates that the returned reference will be valid as long as the lifetime'a
. - Generic Type
T
: A generic type that must implement theDisplay
trait. - Order of Lifetimes and Generics: When specifying both lifetimes and generic types, lifetimes are declared first within the angle brackets
<>
.
Example Usage:
fn main() {
let text = String::from("Hello, world!");
let part = announce_and_return_part(42, &text);
println!("Part: {}", part);
}
11.3.7 Order of Generics and Lifetimes
When specifying both lifetimes and generic types, the order is:
fn function_name<'a, T>(param: &'a T) -> &'a T {
// Function body...
}
Lifetimes come before type parameters in the angle brackets <>
.
11.3.8 Lifetimes and Machine Code
It's important to note that lifetime annotations have no impact on the generated machine code. They are purely a compile-time feature that helps the Rust compiler ensure memory safety. Lifetimes are not present in the compiled binary, and they do not affect runtime performance.