Rust - Lifetimes
Overview
Estimated time: 75–90 minutes
Master Rust's lifetime system for advanced memory safety. Learn lifetime annotations, constraints, and how to solve complex borrowing scenarios while maintaining zero-cost abstractions.
Learning Objectives
- Understand lifetime annotations and their purpose
- Apply lifetime parameters to functions, structs, and methods
- Master lifetime elision rules and when annotations are needed
- Solve complex borrowing scenarios with lifetimes
Prerequisites
Understanding Lifetimes
What Are Lifetimes?
Lifetimes ensure references are valid for as long as needed, preventing dangling pointers:
fn main() {
let r; // r declared but not initialized
{
let x = 5;
r = &x; // r references x
} // x goes out of scope here
// println!("r: {}", r); // ERROR: x doesn't live long enough
}
// Fixed version - ensuring reference lives long enough
fn main() {
let x = 5; // x has long lifetime
let r = &x; // r references x
println!("r: {}", r); // OK: both x and r are valid here
} // Both x and r go out of scope
Lifetime Annotations Syntax
Use apostrophe syntax to name lifetimes:
// Function with lifetime annotation
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 = "xyz";
let result = longest(string1.as_str(), string2);
println!("The longest string is: {}", result); // The longest string is: long string is long
}
Lifetime Annotations in Functions
Basic Function Lifetimes
When functions take multiple references, lifetimes specify relationships:
// Function that returns the first element
fn first_word<'a>(s: &'a str) -> &'a str {
let bytes = s.as_bytes();
for (i, &item) in bytes.iter().enumerate() {
if item == b' ' {
return &s[0..i];
}
}
&s[..]
}
// Function comparing string lengths
fn longer<'a>(x: &'a str, y: &'a str) -> &'a str {
if x.len() > y.len() {
x
} else {
y
}
}
// Function with different lifetime requirements
fn announce_and_return_part<'a, 'b>(
announcement: &'a str,
part: &'b str
) -> &'b str {
println!("Attention please: {}", announcement);
part // Return value has lifetime 'b
}
fn main() {
let sentence = "Hello world programming";
let first = first_word(sentence);
println!("First word: {}", first); // First word: Hello
let string1 = "short";
let string2 = "this is longer";
let result = longer(string1, string2);
println!("Longer: {}", result); // Longer: this is longer
let announcement = "Important message";
let part = "key information";
let returned = announce_and_return_part(announcement, part);
println!("Returned: {}", returned); // Returned: key information
}
Multiple Lifetime Parameters
Different references can have different lifetimes:
// Function with multiple lifetime parameters
fn compare_and_print<'a, 'b>(x: &'a str, y: &'b str) -> &'a str
where
'b: 'a // 'b must live at least as long as 'a
{
println!("Comparing '{}' and '{}'", x, y);
if x.len() > y.len() {
x
} else {
// Can't return y here because it has lifetime 'b, not 'a
x
}
}
// Function that doesn't need to relate input lifetimes to output
fn process_strings<'a, 'b>(first: &'a str, second: &'b str) -> String {
format!("{} processed with {}", first, second) // Returns owned String
}
// Function with static lifetime parameter
fn get_static_str() -> &'static str {
"This string lives for the entire program duration"
}
fn main() {
let long_lived = String::from("This lives long");
{
let short_lived = String::from("Short");
let result = process_strings(&long_lived, &short_lived);
println!("{}", result); // This lives long processed with Short
}
let static_str = get_static_str();
println!("{}", static_str); // This string lives for the entire program duration
}
Lifetimes in Structs
Structs with References
Structs holding references need lifetime annotations:
// Struct that holds a reference
struct ImportantExcerpt<'a> {
part: &'a str,
}
impl<'a> ImportantExcerpt<'a> {
// Method that doesn't need additional lifetime annotation
fn level(&self) -> i32 {
3
}
// Method with lifetime annotation
fn announce_and_return_part(&self, announcement: &str) -> &str {
println!("Attention please: {}", announcement);
self.part
}
// Method with explicit lifetime relationship
fn compare_parts<'b>(&self, other: &'b str) -> &'a str
where
'a: 'b // self.part must live at least as long as other
{
println!("Comparing '{}' with '{}'", self.part, other);
self.part
}
}
// More complex struct with multiple references
struct BookReview<'a, 'b> {
title: &'a str,
content: &'b str,
rating: u8,
}
impl<'a, 'b> BookReview<'a, 'b> {
fn new(title: &'a str, content: &'b str, rating: u8) -> Self {
BookReview { title, content, rating }
}
fn summary(&self) -> String {
format!("'{}' - Rating: {}/5", self.title, self.rating)
}
fn full_review(&self) -> String {
format!("Title: {}\nRating: {}/5\nReview: {}",
self.title, self.rating, self.content)
}
}
fn main() {
let novel = String::from("Call them by their true names");
let first_sentence = "In a village of La Mancha the name of which I prefer not to remember";
{
let excerpt = ImportantExcerpt {
part: first_sentence,
};
println!("Level: {}", excerpt.level()); // Level: 3
let returned = excerpt.announce_and_return_part("Listen up!");
println!("Returned: {}", returned); // Returned: In a village of La Mancha...
}
// Book review example
let title = "The Rust Programming Language";
let review_text = "Excellent book for learning Rust. Comprehensive and well-written.";
let review = BookReview::new(title, review_text, 5);
println!("{}", review.summary()); // 'The Rust Programming Language' - Rating: 5/5
println!("\n{}", review.full_review());
}
Lifetime Elision Rules
When Lifetimes Are Inferred
Rust follows three rules to infer lifetimes automatically:
// Rule 1: Each parameter gets its own lifetime
// This function:
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[..]
}
// Is automatically understood as:
fn first_word_explicit<'a>(s: &'a str) -> &'a str {
let bytes = s.as_bytes();
for (i, &item) in bytes.iter().enumerate() {
if item == b' ' {
return &s[0..i];
}
}
&s[..]
}
// Rule 2: If there's exactly one input lifetime, it's assigned to all outputs
fn get_length(s: &str) -> usize {
s.len() // No references returned, so no lifetime needed
}
fn get_first_char(s: &str) -> Option<&str> {
s.chars().next().map(|_| &s[0..1]) // Input lifetime assigned to output
}
// Rule 3: If there's &self or &mut self, its lifetime is assigned to all outputs
struct Parser<'a> {
text: &'a str,
pos: usize,
}
impl<'a> Parser<'a> {
// These methods don't need explicit lifetime annotations
fn current_char(&self) -> Option {
self.text.chars().nth(self.pos)
}
fn remaining(&self) -> &str { // Returns &'a str due to rule 3
&self.text[self.pos..]
}
fn advance(&mut self) {
self.pos += 1;
}
}
fn main() {
let text = "Hello, world!";
let mut parser = Parser { text, pos: 0 };
if let Some(ch) = parser.current_char() {
println!("Current char: {}", ch); // Current char: H
}
parser.advance();
let remaining = parser.remaining();
println!("Remaining: {}", remaining); // Remaining: ello, world!
}
Advanced Lifetime Scenarios
Lifetime Bounds
Constraining one lifetime to outlive another:
// Lifetime bound: T must live at least as long as 'a
fn process_with_bound<'a, T>(item: &'a T) -> &'a T
where
T: std::fmt::Display + 'a, // T must live for lifetime 'a
{
println!("Processing: {}", item);
item
}
// Multiple lifetime bounds
struct Container<'a, 'b, T>
where
T: 'a + 'b, // T must satisfy both lifetimes
{
primary: &'a T,
secondary: &'b T,
}
impl<'a, 'b, T> Container<'a, 'b, T>
where
T: std::fmt::Debug + 'a + 'b,
{
fn new(primary: &'a T, secondary: &'b T) -> Self {
Container { primary, secondary }
}
fn compare(&self) -> String
where
T: PartialEq,
{
if self.primary == self.secondary {
"Items are equal".to_string()
} else {
"Items are different".to_string()
}
}
fn debug_info(&self) -> String {
format!("Primary: {:?}, Secondary: {:?}", self.primary, self.secondary)
}
}
fn main() {
let value1 = 42;
let value2 = 42;
let processed = process_with_bound(&value1);
println!("Processed value: {}", processed); // Processed value: 42
let container = Container::new(&value1, &value2);
println!("{}", container.compare()); // Items are equal
println!("{}", container.debug_info()); // Primary: 42, Secondary: 42
}
Higher-Ranked Trait Bounds (HRTB)
Working with closures and higher-ranked lifetimes:
// Function that takes a closure working with any lifetime
fn apply_to_all(items: Vec<&str>, mut f: F) -> Vec
where
F: for<'a> FnMut(&'a str) -> String, // Higher-ranked trait bound
{
items.into_iter().map(|item| f(item)).collect()
}
// Function with complex lifetime relationships
fn process_pairs<'a, F>(pairs: &[(&'a str, &'a str)], mut processor: F) -> Vec
where
F: for<'b> FnMut(&'b str, &'b str) -> String,
{
pairs.iter()
.map(|(first, second)| processor(first, second))
.collect()
}
fn main() {
let items = vec!["hello", "world", "rust", "programming"];
let uppercase = apply_to_all(items, |s| s.to_uppercase());
println!("Uppercase: {:?}", uppercase); // Uppercase: ["HELLO", "WORLD", "RUST", "PROGRAMMING"]
let pairs = [
("hello", "world"),
("rust", "programming"),
("safe", "fast"),
];
let combined = process_pairs(&pairs, |first, second| {
format!("{}-{}", first, second)
});
println!("Combined: {:?}", combined); // Combined: ["hello-world", "rust-programming", "safe-fast"]
}
Real-World Example: String Parser
Complete Parser Implementation
Building a practical string parser with lifetimes:
use std::str::Chars;
use std::iter::Peekable;
struct StringParser<'a> {
input: &'a str,
chars: Peekable>,
position: usize,
}
impl<'a> StringParser<'a> {
fn new(input: &'a str) -> Self {
StringParser {
input,
chars: input.chars().peekable(),
position: 0,
}
}
fn peek(&mut self) -> Option<&char> {
self.chars.peek()
}
fn next_char(&mut self) -> Option {
if let Some(ch) = self.chars.next() {
self.position += ch.len_utf8();
Some(ch)
} else {
None
}
}
fn skip_whitespace(&mut self) {
while let Some(&ch) = self.peek() {
if ch.is_whitespace() {
self.next_char();
} else {
break;
}
}
}
fn parse_word(&mut self) -> Option<&'a str> {
self.skip_whitespace();
let start = self.position;
while let Some(&ch) = self.peek() {
if ch.is_alphabetic() {
self.next_char();
} else {
break;
}
}
if self.position > start {
Some(&self.input[start..self.position])
} else {
None
}
}
fn parse_number(&mut self) -> Option {
self.skip_whitespace();
let start = self.position;
// Handle optional negative sign
if let Some(&'-') = self.peek() {
self.next_char();
}
while let Some(&ch) = self.peek() {
if ch.is_ascii_digit() {
self.next_char();
} else {
break;
}
}
if self.position > start {
self.input[start..self.position].parse().ok()
} else {
None
}
}
fn remaining(&self) -> &'a str {
&self.input[self.position..]
}
fn is_finished(&mut self) -> bool {
self.skip_whitespace();
self.peek().is_none()
}
}
// Token types with lifetime parameter
#[derive(Debug, PartialEq)]
enum Token<'a> {
Word(&'a str),
Number(i32),
End,
}
impl<'a> StringParser<'a> {
fn next_token(&mut self) -> Token<'a> {
if let Some(word) = self.parse_word() {
Token::Word(word)
} else if let Some(number) = self.parse_number() {
Token::Number(number)
} else if self.is_finished() {
Token::End
} else {
// Skip unknown character and try again
self.next_char();
self.next_token()
}
}
fn tokenize(&mut self) -> Vec> {
let mut tokens = Vec::new();
loop {
match self.next_token() {
Token::End => break,
token => tokens.push(token),
}
}
tokens
}
}
fn main() {
let input = "hello 42 world -17 rust 2024";
let mut parser = StringParser::new(input);
// Parse individual tokens
println!("Parsing tokens one by one:");
while !parser.is_finished() {
let token = parser.next_token();
println!("Token: {:?}", token);
}
// Parse all tokens at once
let mut parser2 = StringParser::new(input);
let all_tokens = parser2.tokenize();
println!("\nAll tokens: {:?}", all_tokens);
// Expected output:
// Token: Word("hello")
// Token: Number(42)
// Token: Word("world")
// Token: Number(-17)
// Token: Word("rust")
// Token: Number(2024)
// All tokens: [Word("hello"), Number(42), Word("world"), Number(-17), Word("rust"), Number(2024)]
}
Common Pitfalls
⚠️ Lifetime Annotation Confusion
Remember that lifetime annotations describe relationships, not create them:
// ❌ Wrong - this doesn't make the reference live longer
fn broken<'a>() -> &'a str {
let s = String::from("hello");
&s // ERROR: s is dropped when function returns
}
// ✅ Correct - return owned data or use static lifetime
fn fixed() -> String {
String::from("hello") // Return owned data
}
fn static_str() -> &'static str {
"hello" // String literal has static lifetime
}
⚠️ Overly Complex Lifetime Annotations
Don't add unnecessary lifetime annotations:
// ❌ Unnecessary - elision rules handle this
fn get_first<'a>(items: &'a Vec) -> &'a String {
&items[0]
}
// ✅ Better - let elision rules work
fn get_first(items: &Vec) -> &String {
&items[0]
}
// ✅ Even better - use slice instead of Vec reference
fn get_first(items: &[String]) -> &String {
&items[0]
}
Best Practices
Lifetime Guidelines
- Start without lifetimes - let the compiler tell you when needed
- Use descriptive lifetime names for complex scenarios
- Prefer owned types when lifetime complexity becomes unwieldy
- Use lifetime elision - explicit annotations are often unnecessary
- Design APIs to minimize lifetime complexity for users
Checks for Understanding
Question 1: When do you need explicit lifetime annotations?
Answer: Explicit lifetime annotations are needed when:
- Multiple input references and the compiler can't determine which lifetime the output should have
- Structs hold references
- Complex lifetime relationships that can't be inferred by elision rules
- Higher-ranked trait bounds with closures
Question 2: What do lifetime bounds like 'a: 'b
mean?
Answer: 'a: 'b
means lifetime 'a
must live at least as long as lifetime 'b
. In other words, 'a
outlives 'b
. This is used to establish relationships between different lifetimes.
Question 3: What are the three lifetime elision rules?
Answer:
- Each parameter gets its own lifetime parameter
- If there's exactly one input lifetime, it's assigned to all output lifetimes
- If there's
&self
or&mut self
, its lifetime is assigned to all output lifetimes
Summary
Lifetimes are Rust's way of ensuring memory safety without runtime overhead:
- Lifetime annotations describe relationships between reference lifetimes
- Elision rules minimize the need for explicit annotations
- Structs with references need lifetime parameters
- Complex scenarios may require lifetime bounds and constraints
- Best practice is to start simple and add complexity only when needed