DEV Community

Cover image for RUST SERIES : Borrow Checker Part 2 | As Design Partner - The Compiler's Mental Model
Abhishek Kumar
Abhishek Kumar

Posted on

RUST SERIES : Borrow Checker Part 2 | As Design Partner - The Compiler's Mental Model

Understanding how the Rust compiler thinks about lifetimes will save you hours of debugging. Let's peek inside the compiler's brain.

Recap from Part 1

In Part 1, we learned that lifetimes are like lease contracts. Now we'll understand exactly how the compiler manages these contracts and when it needs our help.

The Scope Tree: How the Compiler Sees Your Code

The compiler builds a tree of scopes and tracks where every value lives and dies. Think of it as a family tree for your data.

fn main() {
    // COMPLETE PROGRAM: Demonstrating scope trees and lifetime relationships

    println!("=== Scope Tree Visualization ===");
    scope_tree_example();

    println!("\n=== Lifetime Elision Rules ===");
    elision_examples();

    println!("\n=== When Elision Fails ===");
    explicit_lifetime_examples();

    println!("\n=== Multiple Lifetime Parameters ===");
    multiple_lifetime_examples();
}

fn scope_tree_example() {
    // CONCEPT: Nested scopes create a tree structure
    // Each scope level is like a generation in a family tree

    // ROOT SCOPE: Grandparent level
    let grandparent = String::from("Ancient wisdom passed down");
    println!("Grandparent created: {}", grandparent);

    { // CHILD SCOPE 1: Parent level
        // CONCEPT: Child scopes can borrow from parent scopes
        let parent = &grandparent; // Borrows from grandparent
        println!("Parent borrows: {}", parent);

        { // GRANDCHILD SCOPE: Child level
            // CONCEPT: Children can borrow from their parents
            // This creates a chain: grandchild -> parent -> grandparent
            let child = &parent; // Borrows from parent (who borrowed from grandparent)
            println!("Child borrows: {}", child);

            // CONCEPT: All three generations coexist safely
            // The borrow checker ensures no conflicts
            println!("Three generations: '{}', '{}', '{}'", 
                    grandparent, parent, child);

            // CONCEPT: The rule - children cannot outlive their parents
            // `child` must be dropped before `parent`
            // `parent` must be dropped before `grandparent`

        } // Child scope ends - `child` is dropped here

        // CONCEPT: Parent can still be used after child is gone
        println!("Parent survives child: {}", parent);

    } // Parent scope ends - `parent` is dropped here

    // CONCEPT: Grandparent survives all descendants
    println!("Grandparent survives all: {}", grandparent);

    // CONCEPT: Why this works
    // The compiler tracks the lifetime hierarchy:
    // - grandparent: lives for entire function
    // - parent: lives for middle scope, borrows from grandparent
    // - child: lives for inner scope, borrows from parent
    // No reference outlives its source!
}
Enter fullscreen mode Exit fullscreen mode

Lifetime Elision Rules

The compiler's auto-complete for lifetimes covers most common cases.

Rule 1: Each parameter gets its own lifetime

fn process_single_input(input: &str) -> String {
    // WHAT YOU WRITE: No explicit lifetimes
    // WHAT COMPILER SEES: fn process_single_input<'a>(input: &'a str) -> String

    // CONCEPT: Since we're returning owned data (String),
    // no lifetime relationship exists between input and output
    input.to_uppercase()
}
Enter fullscreen mode Exit fullscreen mode

Rule 2: Single input reference → output gets same lifetime

fn get_first_word(text: &str) -> &str {
    // WHAT YOU WRITE: No explicit lifetimes
    // WHAT COMPILER INFERS: fn get_first_word<'a>(text: &'a str) -> &'a str

    // CONCEPT: The output is a slice of the input
    // So the output must live as long as the input
    text.split_whitespace().next().unwrap_or("")
}
Enter fullscreen mode Exit fullscreen mode

Rule 3: Method with &self → output gets self's lifetime

struct TextAnalyzer {
    content: String,
}

impl TextAnalyzer {
    fn new(content: String) -> Self {
        Self { content }
    }

    // WHAT YOU WRITE: No explicit lifetimes
    fn get_content(&self) -> &str {
        // WHAT COMPILER INFERS: fn get_content<'a>(&'a self) -> &'a str

        // CONCEPT: The returned slice borrows from self
        // The slice can't outlive the TextAnalyzer instance
        &self.content
    }

    fn get_word_count(&self) -> usize {
        // CONCEPT: No lifetime issues here - returning owned data
        self.content.split_whitespace().count()
    }

    // CONCEPT: Multiple borrows from self are fine
    fn get_summary(&self) -> (usize, &str) {
        // WHAT COMPILER INFERS: fn get_summary<'a>(&'a self) -> (usize, &'a str)

        let word_count = self.get_word_count();
        let first_word = self.get_content().split_whitespace().next().unwrap_or("");
        (word_count, first_word)
    }
}

fn elision_examples() {
    // CONCEPT: Testing elision rules in practice

    let data = "Hello world from Rust";

    // Rule 1: Single input, owned output
    let processed = process_single_input(data);
    println!("Processed (owned): {}", processed);

    // Rule 2: Single input, borrowed output
    let first_word = get_first_word(data);
    println!("First word (borrowed): {}", first_word);

    // Rule 3: Method with &self
    let analyzer = TextAnalyzer::new(data.to_string());
    let content = analyzer.get_content();
    let (count, first) = analyzer.get_summary();

    println!("Content: {}", content);
    println!("Summary: {} words, starts with '{}'", count, first);

    // CONCEPT: All borrows are valid because analyzer outlives all references
}
Enter fullscreen mode Exit fullscreen mode

When Elision Fails

When the compiler can't guess which input the output should be tied to, explicit lifetimes are needed.

fn explicit_lifetime_examples() {
    let text1 = "Hello world";
    let text2 = "Rust programming";

    // CONCEPT: When multiple inputs exist, compiler can't guess
    // which input the output should be tied to
    let longer = choose_longer_explicit(&text1, &text2);
    println!("Longer text: {}", longer);

    // CONCEPT: Different lifetime requirements
    let first_part = get_first_part_explicit(&text1, &text2);
    println!("First part of first input: {}", first_part);
}

// CONCEPT: Explicit lifetime when compiler can't infer
fn choose_longer_explicit<'a>(s1: &'a str, s2: &'a str) -> &'a str {
    // CONCEPT: Both inputs must live as long as the output
    // The 'a lifetime ties all three together
    // This means: "s1 and s2 must both live at least as long as the returned reference"

    if s1.len() > s2.len() {
        s1  // Could return either input
    } else {
        s2  // So both must live long enough
    }
}

// CONCEPT: Different lifetimes when only one input matters
fn get_first_part_explicit<'a, 'b>(primary: &'a str, _secondary: &'b str) -> &'a str {
    // CONCEPT: Output only depends on 'primary'
    // 'secondary' can have a completely different lifetime
    // This is more flexible than forcing both to have the same lifetime

    &primary[0..primary.len().min(5)] // Return first 5 chars of primary
}
Enter fullscreen mode Exit fullscreen mode

Multiple Lifetime Parameters

Testing functions with multiple lifetime parameters provides flexibility.

fn multiple_lifetime_examples() {
    // CONCEPT: Testing functions with multiple lifetime parameters

    let long_lived = String::from("This is a long-lived string");

    { // Shorter scope
        let short_lived = String::from("Short");

        // CONCEPT: Both inputs available, function works
        let result1 = choose_longer_explicit(&long_lived, &short_lived);
        println!("Result 1: {}", result1);

        // CONCEPT: Only primary input matters for this function
        let result2 = get_first_part_explicit(&long_lived, &short_lived);
        println!("Result 2: {}", result2);

        // CONCEPT: result2 can outlive short_lived because it only
        // depends on long_lived (due to different lifetime parameters)

    } // short_lived dropped here

    // This would work for result2 but not result1:
    // println!("After scope: {}", result2); // Would be OK
    // println!("After scope: {}", result1); // Would be ERROR
}
Enter fullscreen mode Exit fullscreen mode

Complex Lifetime Relationships

Structs can hold references with proper lifetime constraints.

struct DataProcessor<'data> {
    // CONCEPT: Struct that holds a reference to external data
    // The struct cannot outlive the data it references
    source: &'data str,
    processed_count: usize,
}

impl<'data> DataProcessor<'data> {
    fn new(source: &'data str) -> Self {
        // CONCEPT: The processor borrows the source data
        // It doesn't own the data, just processes it
        Self {
            source,
            processed_count: 0,
        }
    }

    fn process_chunk(&mut self, start: usize, len: usize) -> Option<&'data str> {
        // CONCEPT: Return a slice that lives as long as the original source
        // The lifetime 'data ensures the slice is valid

        if start + len <= self.source.len() {
            self.processed_count += 1;
            Some(&self.source[start..start + len])
        } else {
            None
        }
    }

    fn get_stats(&self) -> (usize, usize) {
        // CONCEPT: Returning owned data - no lifetime constraints
        (self.source.len(), self.processed_count)
    }
}

// CONCEPT: Advanced lifetime demonstration
fn demonstrate_processor() {
    println!("\n=== Advanced Lifetime Relationships ===");

    let source_data = "The quick brown fox jumps over the lazy dog";

    // CONCEPT: Create processor that borrows source_data
    let mut processor = DataProcessor::new(source_data);

    // CONCEPT: Process chunks - returned slices borrow from original data
    if let Some(chunk1) = processor.process_chunk(0, 9) {
        println!("Chunk 1: '{}'", chunk1);
    }

    if let Some(chunk2) = processor.process_chunk(10, 5) {
        println!("Chunk 2: '{}'", chunk2);
    }

    // CONCEPT: Get statistics - owned data, no lifetime constraints
    let (total_len, processed_count) = processor.get_stats();
    println!("Processed {} chunks from {} characters", processed_count, total_len);

    // CONCEPT: All chunks and processor must be dropped before source_data
    // The borrow checker enforces this automatically
}
Enter fullscreen mode Exit fullscreen mode

Tests

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn test_elision_rules() {
        // CONCEPT: Testing that elision works as expected
        let input = "test input";

        // Rule 1: owned output
        let owned = process_single_input(input);
        assert_eq!(owned, "TEST INPUT");

        // Rule 2: borrowed output tied to input
        let borrowed = get_first_word(input);
        assert_eq!(borrowed, "test");

        // Rule 3: method with &self
        let analyzer = TextAnalyzer::new(input.to_string());
        let content = analyzer.get_content();
        assert_eq!(content, input);
    }

    #[test]
    fn test_explicit_lifetimes() {
        // CONCEPT: Testing explicit lifetime annotations
        let s1 = "short";
        let s2 = "much longer string";

        let result = choose_longer_explicit(s1, s2);
        assert_eq!(result, s2); // s2 is longer

        let first_part = get_first_part_explicit(s1, s2);
        assert_eq!(first_part, "short"); // First 5 chars of s1
    }

    #[test]
    fn test_data_processor() {
        // CONCEPT: Testing struct with lifetime parameters
        let data = "Hello Rust World";
        let mut processor = DataProcessor::new(data);

        let chunk = processor.process_chunk(0, 5).unwrap();
        assert_eq!(chunk, "Hello");

        let (len, count) = processor.get_stats();
        assert_eq!(len, 16);
        assert_eq!(count, 1);
    }
}
Enter fullscreen mode Exit fullscreen mode

The Compiler's Three Questions

When analyzing lifetimes, the compiler asks:

  1. Where does this data come from? (source scope)
  2. How long does it need to live? (usage scope)
  3. Can I guarantee the source outlives the usage? (safety check)

Key Concepts Covered

  • Scope Trees - How the compiler tracks data relationships
  • Lifetime Elision Rules - When the compiler can infer lifetimes
  • Explicit Lifetime Annotations - When and why to be explicit
  • Multiple Lifetime Parameters - Flexible lifetime relationships
  • Struct Lifetimes - Holding references in data structures

What We've Learned

  • The compiler builds a scope tree to track data lifetimes
  • Elision rules handle 90% of common cases automatically
  • Explicit lifetimes document relationships between inputs and outputs
  • Multiple lifetime parameters provide flexibility
  • Structs can hold references with proper lifetime constraints

Important Note: Owned vs Borrowed Data

When you return a String (owned data), you're creating entirely new data that the caller will own. This breaks any lifetime dependency on the input parameters.

fn process_single_input(input: &str) -> String {
    input.to_uppercase() // Creates a NEW String, not borrowing from input
}
Enter fullscreen mode Exit fullscreen mode

Why No Lifetime Relationship Exists

The returned String:

  • Is allocated on the heap with its own memory
  • Contains a copy/transformation of the input data
  • Has no references pointing back to the original input
  • Will live as long as the caller keeps it around

Contrast with Borrowing

If you were returning a reference instead, you'd create a lifetime dependency:

// This WOULD create a lifetime relationship
fn get_first_word(input: &str) -> &str {
    // WHAT COMPILER SEES: fn get_first_word<'a>(input: &'a str) -> &'a str
    input.split_whitespace().next().unwrap_or("")
}
Enter fullscreen mode Exit fullscreen mode

Here the returned &str borrows from input, so it can't outlive the input parameter.

The Key Insight

Owned return types break the lifetime chain. When you return owned data like String, Vec<T>, or any other owned type, the compiler doesn't need to track relationships between input and output lifetimes because the output is independent—it owns its data and can live as long as needed.

This is why functions that transform borrowed input into owned output are often simpler to work with in Rust's borrow checker.

Complete Program - Copy & Paste Ready

Here's the complete program in one block for easy copying:

fn main() {
    // COMPLETE PROGRAM: Demonstrating scope trees and lifetime relationships

    println!("=== Scope Tree Visualization ===");
    scope_tree_example();

    println!("\n=== Lifetime Elision Rules ===");
    elision_examples();

    println!("\n=== When Elision Fails ===");
    explicit_lifetime_examples();

    println!("\n=== Multiple Lifetime Parameters ===");
    multiple_lifetime_examples();

    // Run advanced demonstrations
    demonstrate_processor();
}

fn scope_tree_example() {
    // CONCEPT: Nested scopes create a tree structure
    // Each scope level is like a generation in a family tree

    // ROOT SCOPE: Grandparent level
    let grandparent = String::from("Ancient wisdom passed down");
    println!("Grandparent created: {}", grandparent);

    { // CHILD SCOPE 1: Parent level
        // CONCEPT: Child scopes can borrow from parent scopes
        let parent = &grandparent; // Borrows from grandparent
        println!("Parent borrows: {}", parent);

        { // GRANDCHILD SCOPE: Child level
            // CONCEPT: Children can borrow from their parents
            // This creates a chain: grandchild -> parent -> grandparent
            let child = &parent; // Borrows from parent (who borrowed from grandparent)
            println!("Child borrows: {}", child);

            // CONCEPT: All three generations coexist safely
            // The borrow checker ensures no conflicts
            println!("Three generations: '{}', '{}', '{}'", 
                    grandparent, parent, child);

            // CONCEPT: The rule - children cannot outlive their parents
            // `child` must be dropped before `parent`
            // `parent` must be dropped before `grandparent`

        } // Child scope ends - `child` is dropped here

        // CONCEPT: Parent can still be used after child is gone
        println!("Parent survives child: {}", parent);

    } // Parent scope ends - `parent` is dropped here

    // CONCEPT: Grandparent survives all descendants
    println!("Grandparent survives all: {}", grandparent);

    // CONCEPT: Why this works
    // The compiler tracks the lifetime hierarchy:
    // - grandparent: lives for entire function
    // - parent: lives for middle scope, borrows from grandparent
    // - child: lives for inner scope, borrows from parent
    // No reference outlives its source!
}

// CONCEPT: Lifetime elision - the compiler's auto-complete for lifetimes
// Rule 1: Each parameter gets its own lifetime
fn process_single_input(input: &str) -> String {
    // WHAT YOU WRITE: No explicit lifetimes
    // WHAT COMPILER SEES: fn process_single_input<'a>(input: &'a str) -> String

    // CONCEPT: Since we're returning owned data (String),
    // no lifetime relationship exists between input and output
    input.to_uppercase()
}

// Rule 2: Single input reference → output gets same lifetime
fn get_first_word(text: &str) -> &str {
    // WHAT YOU WRITE: No explicit lifetimes
    // WHAT COMPILER INFERS: fn get_first_word<'a>(text: &'a str) -> &'a str

    // CONCEPT: The output is a slice of the input
    // So the output must live as long as the input
    text.split_whitespace().next().unwrap_or("")
}

// Rule 3: Method with &self → output gets self's lifetime
struct TextAnalyzer {
    content: String,
}

impl TextAnalyzer {
    fn new(content: String) -> Self {
        Self { content }
    }

    // WHAT YOU WRITE: No explicit lifetimes
    fn get_content(&self) -> &str {
        // WHAT COMPILER INFERS: fn get_content<'a>(&'a self) -> &'a str

        // CONCEPT: The returned slice borrows from self
        // The slice can't outlive the TextAnalyzer instance
        &self.content
    }

    fn get_word_count(&self) -> usize {
        // CONCEPT: No lifetime issues here - returning owned data
        self.content.split_whitespace().count()
    }

    // CONCEPT: Multiple borrows from self are fine
    fn get_summary(&self) -> (usize, &str) {
        // WHAT COMPILER INFERS: fn get_summary<'a>(&'a self) -> (usize, &'a str)

        let word_count = self.get_word_count();
        let first_word = self.get_content().split_whitespace().next().unwrap_or("");
        (word_count, first_word)
    }
}

fn elision_examples() {
    // CONCEPT: Testing elision rules in practice

    let data = "Hello world from Rust";

    // Rule 1: Single input, owned output
    let processed = process_single_input(data);
    println!("Processed (owned): {}", processed);

    // Rule 2: Single input, borrowed output
    let first_word = get_first_word(data);
    println!("First word (borrowed): {}", first_word);

    // Rule 3: Method with &self
    let analyzer = TextAnalyzer::new(data.to_string());
    let content = analyzer.get_content();
    let (count, first) = analyzer.get_summary();

    println!("Content: {}", content);
    println!("Summary: {} words, starts with '{}'", count, first);

    // CONCEPT: All borrows are valid because analyzer outlives all references
}

// CONCEPT: When elision fails - compiler needs explicit help
fn explicit_lifetime_examples() {
    let text1 = "Hello world";
    let text2 = "Rust programming";

    // CONCEPT: When multiple inputs exist, compiler can't guess
    // which input the output should be tied to
    let longer = choose_longer_explicit(&text1, &text2);
    println!("Longer text: {}", longer);

    // CONCEPT: Different lifetime requirements
    let first_part = get_first_part_explicit(&text1, &text2);
    println!("First part of first input: {}", first_part);
}

// CONCEPT: Explicit lifetime when compiler can't infer
fn choose_longer_explicit<'a>(s1: &'a str, s2: &'a str) -> &'a str {
    // CONCEPT: Both inputs must live as long as the output
    // The 'a lifetime ties all three together
    // This means: "s1 and s2 must both live at least as long as the returned reference"

    if s1.len() > s2.len() {
        s1  // Could return either input
    } else {
        s2  // So both must live long enough
    }
}

// CONCEPT: Different lifetimes when only one input matters
fn get_first_part_explicit<'a, 'b>(primary: &'a str, _secondary: &'b str) -> &'a str {
    // CONCEPT: Output only depends on 'primary'
    // 'secondary' can have a completely different lifetime
    // This is more flexible than forcing both to have the same lifetime

    &primary[0..primary.len().min(5)] // Return first 5 chars of primary
}

fn multiple_lifetime_examples() {
    // CONCEPT: Testing functions with multiple lifetime parameters

    let long_lived = String::from("This is a long-lived string");

    { // Shorter scope
        let short_lived = String::from("Short");

        // CONCEPT: Both inputs available, function works
        let result1 = choose_longer_explicit(&long_lived, &short_lived);
        println!("Result 1: {}", result1);

        // CONCEPT: Only primary input matters for this function
        let result2 = get_first_part_explicit(&long_lived, &short_lived);
        println!("Result 2: {}", result2);

        // CONCEPT: result2 can outlive short_lived because it only
        // depends on long_lived (due to different lifetime parameters)

    } // short_lived dropped here

    // This would work for result2 but not result1:
    // println!("After scope: {}", result2); // Would be OK
    // println!("After scope: {}", result1); // Would be ERROR
}

// CONCEPT: Complex lifetime relationships
struct DataProcessor<'data> {
    // CONCEPT: Struct that holds a reference to external data
    // The struct cannot outlive the data it references
    source: &'data str,
    processed_count: usize,
}

impl<'data> DataProcessor<'data> {
    fn new(source: &'data str) -> Self {
        // CONCEPT: The processor borrows the source data
        // It doesn't own the data, just processes it
        Self {
            source,
            processed_count: 0,
        }
    }

    fn process_chunk(&mut self, start: usize, len: usize) -> Option<&'data str> {
        // CONCEPT: Return a slice that lives as long as the original source
        // The lifetime 'data ensures the slice is valid

        if start + len <= self.source.len() {
            self.processed_count += 1;
            Some(&self.source[start..start + len])
        } else {
            None
        }
    }

    fn get_stats(&self) -> (usize, usize) {
        // CONCEPT: Returning owned data - no lifetime constraints
        (self.source.len(), self.processed_count)
    }
}

// CONCEPT: Advanced lifetime demonstration
fn demonstrate_processor() {
    println!("\n=== Advanced Lifetime Relationships ===");

    let source_data = "The quick brown fox jumps over the lazy dog";

    // CONCEPT: Create processor that borrows source_data
    let mut processor = DataProcessor::new(source_data);

    // CONCEPT: Process chunks - returned slices borrow from original data
    if let Some(chunk1) = processor.process_chunk(0, 9) {
        println!("Chunk 1: '{}'", chunk1);
    }

    if let Some(chunk2) = processor.process_chunk(10, 5) {
        println!("Chunk 2: '{}'", chunk2);
    }

    // CONCEPT: Get statistics - owned data, no lifetime constraints
    let (total_len, processed_count) = processor.get_stats();
    println!("Processed {} chunks from {} characters", processed_count, total_len);

    // CONCEPT: All chunks and processor must be dropped before source_data
    // The borrow checker enforces this automatically
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn test_elision_rules() {
        // CONCEPT: Testing that elision works as expected
        let input = "test input";

        // Rule 1: owned output
        let owned = process_single_input(input);
        assert_eq!(owned, "TEST INPUT");

        // Rule 2: borrowed output tied to input
        let borrowed = get_first_word(input);
        assert_eq!(borrowed, "test");

        // Rule 3: method with &self
        let analyzer = TextAnalyzer::new(input.to_string());
        let content = analyzer.get_content();
        assert_eq!(content, input);
    }

    #[test]
    fn test_explicit_lifetimes() {
        // CONCEPT: Testing explicit lifetime annotations
        let s1 = "short";
        let s2 = "much longer string";

        let result = choose_longer_explicit(s1, s2);
        assert_eq!(result, s2); // s2 is longer

        let first_part = get_first_part_explicit(s1, s2);
        assert_eq!(first_part, "short"); // First 5 chars of s1
    }

    #[test]
    fn test_data_processor() {
        // CONCEPT: Testing struct with lifetime parameters
        let data = "Hello Rust World";
        let mut processor = DataProcessor::new(data);

        let chunk = processor.process_chunk(0, 5).unwrap();
        assert_eq!(chunk, "Hello");

        let (len, count) = processor.get_stats();
        assert_eq!(len, 16);
        assert_eq!(count, 1);
    }
}
Enter fullscreen mode Exit fullscreen mode

Try This Yourself

Copy the code above into the Rust Playground and:

  1. Try removing the lifetime annotations from explicit functions to see the errors
  2. Experiment with the DataProcessor struct
  3. Add more methods to TextAnalyzer and see how elision works
  4. Try creating invalid borrow relationships and read the error messages

Links


#RustLang #BorrowChecker #Lifetimes

Top comments (0)