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

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

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:

  1. Each parameter gets its own lifetime parameter
  2. If there's exactly one input lifetime, it's assigned to all output lifetimes
  3. 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:


← PreviousNext →