When I first ran into lifetimes in Rust, I kept skipping over them. In practice, I would often avoid the whole issue by using String in structs instead of &str. But lifetimes are too central to Rust to ignore forever, so after spending a couple of evenings working through them, things finally started to click.
Lifetimes and references
The core problem is straightforward: if several pointers refer to the same value, and that value is freed while some of those pointers still exist, the remaining pointers become dangling pointers. That is exactly the kind of memory-safety issue Rust tries to eliminate.
Rust checks this at compile time. If a function takes a string and returns a reference to that string, Rust must be able to prove that the input string stays valid for as long as the returned reference is used. If the input could be destroyed before the returned reference, that would create a dangling pointer, and the compiler will not allow it.
When function lifetime annotations are needed
Most of the time, Rust can infer lifetimes on its own, so we do not need to write them explicitly. But there are cases where the compiler cannot derive the relationship from the function signature alone. In those situations, lifetime annotations are needed to make the logic clear.
Take this very simple example:
fn foo(s: &str) -> &str {
s
}
fn main() {
let s = "hello";
println!("{}", foo(&s));
}
This function does nothing interesting at all: it just returns the string it receives. It compiles without any issues.
Now change foo() so that it accepts two string slices and returns the larger one:
fn foo(x: &str, y: &str) -> &str {
if x > y {
x
} else {
y
}
}
fn main() {
let x = "a";
let y = "b";
println!("{}", foo(&x, &y));
}
This time, compilation fails:
error[E0106]: missing lifetime specifier
--> src/bin/b.rs:3:29
|
3 | fn foo(x: &str, y: &str) -> &str {
| ---- ---- ^ expected named lifetime parameter
|
= help: this function's return type contains a borrowed value, but the signature does not say whether it is borrowed from `x` or `y`
help: consider introducing a named lifetime parameter
|
3 | fn foo<'a>(x: &'a str, y: &'a str) -> &'a str {
| ^^^^ ^^^^^^^ ^^^^^^^ ^^^
error: aborting due to previous error
For more information about this error, try `rustc --explain E0106`.
error: could not compile `hello`.
The compiler is telling us that a lifetime annotation is missing.
Why? Because the return value depends on the inputs, and Rust needs to know how the returned reference is related to the lifetimes of those inputs.
A useful way to think about it is this: if a function returns a reference derived from its parameters, then the returned reference must live no longer than the overlap of the relevant input lifetimes.
This version compiles:
fn foo<'a>(x: &'a str, y: &'a str) -> &'a str {
if x > y {
x
} else {
y
}
}
fn main() {
let x = "a";
let y = "b";
println!("{}", foo(&x, &y));
}
Here, <'a> after the function name declares a lifetime parameter called 'a. The name itself is arbitrary: it could be 'b, 'hello_world, or anything else valid. It is just a label.
Both parameters, x and y, are annotated as living at least as long as 'a, and the return value is also tied to 'a. That tells the compiler the result cannot outlive the lifetime described by 'a, which makes the relationship explicit.
A return value that does not depend on the inputs
Consider this variation:
fn foo<'a>(x: &'a str, y: &'a str) -> &'a str {
"hello"
}
fn main() {
let x = "a";
let y = "b";
println!("{}", foo(&x, &y));
}
This still compiles, even though the function ignores both parameters.
That is because the return value here is the string literal "hello", which has type &'static str. A string literal is valid for the entire duration of the program, so its lifetime does not depend on either input parameter.
What happens when the input lifetimes differ
So far, the examples used the same lifetime 'a everywhere. Things get more interesting when the inputs have different lifetimes.
fn foo<'a, 'b>(x: &'a str, y: &'b str) -> &'a str {
if x > y {
x
} else {
y
}
}
fn main() {
let s1 = "a";
let s2 = "b";
println!("{}", foo(&s1, &s2));
}
In this function, 'a describes the lifetime of x, and 'b describes the lifetime of y.
The compiler rejects it:
error[E0623]: lifetime mismatch
--> src/bin/e.rs:7:9
|
3 | fn foo<'a, 'b>(x: &'a str, y: &'b str) -> &'a str {
| ------- -------
| |
| this parameter and the return type are declared with different lifetimes...
...
7 | y
| ^ ...but data from `y` is returned here
The problem is that the signature promises to return &'a str, but one branch returns y, which is only known to be &'b str. Rust cannot conclude on its own that a value valid for 'b is also valid for 'a.
To make that relationship explicit, more annotation is needed:
fn foo<'a, 'b: 'a>(x: &'a str, y: &'b str) -> &'a str {}
The notation 'b: 'a means that 'b outlives 'a. In other words, 'a is a subset of 'b: if something is valid for 'b, then it is certainly valid for 'a as well.
A simple analogy would be: if someone can score 100 points, then they can also score 60 points. But if their maximum is only 60, you cannot assume they can reach 100.
Once that relationship is stated, the lifetime constraints are clear enough for the compiler.
Lifetime annotations on structs
If a struct contains references, its definition must include explicit lifetime annotations.
struct Foo {
x: &i32
}
fn main() {
let y = &5;
let f = Foo { x: y };
println!("{}", f.x);
}
This does not compile, because the compiler has no explicit information about how long the struct instance may live relative to the value it references. For it to be safe, the struct instance must not outlive the referenced data.
Adding a lifetime parameter fixes that:
struct Foo<'a> {
x: &'a i32
}
fn main() {
let y = &5;
let f = Foo { x: y };
println!("{}", f.x);
}
Now the struct clearly states that x is a reference valid for 'a, and the compiler can enforce the necessary relationship.
Lifetime annotations in impl
With impl, methods can also be defined for a struct that carries lifetime parameters.
struct Foo<'a> {
x: &'a i32
}
impl<'a> Foo<'a> {
fn x(&self) -> &'a i32 { self.x }
}
fn main() {
let y = &5;
let f = Foo { x: y };
println!("{}", f.x());
}
Just like <'a> after a function name declares a lifetime parameter for that function, impl<'a> declares a lifetime parameter for the implementation block. Inside the block, annotating method lifetimes follows the same basic rules as ordinary functions.
That covers the basic idea of declaring and annotating lifetimes. Of course, lifetimes in Rust go much further than these introductory examples. They show up everywhere in the language once references and borrowing become part of daily coding. At this point, I only feel like I have sorted out the part I can currently understand, and there is still plenty more to learn.