Why Lifetimes Exist
References must always point to valid data. In C#, the garbage collector ensures this. In Rust, the compiler proves it at compile time using lifetimes.
The Problem: Dangling References
What happens if a reference outlives the data it points to?
fn main() { let reference; { let value = 42; reference = &value; } // value is dropped here println!("{}", reference); // ERROR: `value` does not live long enough }
The compiler catches this: reference would point to freed memory. This is a dangling reference, and Rust prevents it.
References in Functions
When functions return references, where does the data live?
fn first_word(s: &str) -> &str { match s.find(' ') { Some(i) => &s[..i], None => s, } } fn main() { let sentence = String::from("hello world"); let word = first_word(&sentence); println!("First word: {}", word); }
This works - but how does the compiler know word is valid? It needs to know that the returned reference lives as long as the input.
The Compiler's Question
When the compiler sees a function that returns a reference, it asks: "How long is this reference valid?"
// This function returns a reference, but to what? // The compiler will emit an error here because it doesn't know where the &str comes from fn get_name() -> &str { // Doesn't matter what we do here ... } fn main() { println!("{}", get_name()); }
The compiler needs to prove the returned reference won't dangle. If it can't, it rejects the code.
Lifetime Annotations: Describing Relationships
Lifetime annotations tell the compiler how references relate to each other:
// 'a is a lifetime parameter // This says: the returned reference lives as long as the input fn first_word<'a>(s: &'a str) -> &'a str { match s.find(' ') { Some(i) => &s[..i], None => s, } } fn main() { let sentence = String::from("hello world"); let word = first_word(&sentence); println!("First word: {}", word); }
The 'a connects the input and output: "the output reference is valid for the same scope as the input reference."
Lifetimes are Descriptive, Not Prescriptive
Important: lifetimes don't change how long data lives. They describe relationships that already exist.
fn longer<'a>(x: &'a str, y: &'a str) -> &'a str { if x.len() > y.len() { x } else { y } } fn main() { let string1 = String::from("hello"); let string2 = String::from("world!"); // The compiler knows: result is valid as long as BOTH inputs are valid let result = longer(&string1, &string2); println!("Longer: {}", result); }
You're telling the compiler: "I'm returning one of these two references, so the result is valid only while both are valid."
Lifetime Elision: When You Don't Write Them
You've been using lifetimes all along - the compiler infers them in common cases:
// You write:
fn first_word(s: &str) -> &str { /* ... */ }
// Compiler sees:
// fn first_word<'a>(s: &'a str) -> &'a str { /* ... */ }
The elision rules let you skip explicit lifetimes when the relationship is obvious:
- Each input reference gets its own lifetime
- If there's exactly one input lifetime, it's used for all outputs
- If there's
&selfor&mut self, that lifetime is used for outputs
When Elision Isn't Enough
Sometimes the compiler can't infer the relationship:
// Which input does the output come from? fn pick_one(x: &str, y: &str) -> &str { if x.len() > 0 { x } else { y } } // ERROR: missing lifetime specifier fn main() {}
We must be explicit:
fn pick_one<'a>(x: &'a str, y: &'a str) -> &'a str { if x.len() > 0 { x } else { y } } fn main() { let a = String::from("hello"); let b = String::from("world"); println!("{}", pick_one(&a, &b)); }
Two inputs, one output - the compiler needs you to specify the relationship.
The Mental Model
C# thinking: "References just work. The GC tracks what's alive."
Rust thinking: "References have lifetimes. The compiler tracks how long each reference is valid and proves they never dangle."
Lifetimes make explicit what C# hides behind garbage collection. The benefit: zero runtime cost, guaranteed safety, and documentation of reference relationships in your API.