Blog post originally published in French in 2022.
There is something truly magical about using Rust and seeing the compiler dutifully and tirelessly inform us of everything we are doing wrong. While the concepts are excellently covered in The Book, Rustonomicon, Rust by Example, and The Rust Reference, it still took me a while before everything clicked together and I could demystify one essential element of Rust: lifetimes, or the lifespans of references. The compiler was pretty patient about it, which is nice.
This is due to multiple factors. On the one hand, while lifetimes seem simple on the surface, I was missing something to truly grasp how central they are to making Rust work. On the other hand, Rust dresses up its syntax a lot to make writing and reading easier, but that made understanding the underlying logic a bit more difficult for me. Finally, although all the things discussed here are also explained in the excellent resources listed earlier, the order in which those concepts are introduced didn’t match the way I naturally understand concepts.
All this led me to think, recently, that someone might appreciate this particular order of explanation, which is what motivated the writing of this article.
A simple example
This article doesn’t aim to re-explain all of Rust, but since we’re talking about reference lifetimes, it’s not useless to recall the borrowing rules:
- You can have as many immutable references to the same data as you want
- You cannot have a mutable reference and an immutable reference to the same data at the same time
- References must always be valid
Let’s consider a simple example:
fn main() {
let mut integers = vec![1,2,3,4]; // -----------+ 'i
// |
let r = ℤ // ----- 'r |
// |
} // -----------+
First, the compiler calculates the lifetimes of each variable. Here, integers
must live until the end of the scope, where it will be implicitly dropped, according to the principles of RAII. For the reference &integers
in r
to be valid, it cannot be used after integers
is dropped. In this example, it is used nowhere, so the lifetime of that reference is ephemeral – and everything is fine.
However, this code does not compile:
fn main() {
let mut integers = vec![1,2,3,4]; // -----------+ 'i
// |
let r = ℤ // ----+ 'r |
// | |
drop(integers); // -----------+
// |
println!("ref: {:?}", r); // ----+
}
The error is:
error[E0505]: cannot move out of `integers` because it is borrowed
--> src/main.rs:4:10
|
3 | let r = ℤ
| --------- borrow of `integers` occurs here
4 | drop(integers);
| ^^^^^^^^ move out of `integers` occurs here
5 | println!("ref: {r:?}");
| - borrow later used here
Here the compiler sees that the reference &integers
must live until the println!
. At the same time, it sees that drop
takes ownership of integers
to destroy it while a reference to it still exists. The compiler refuses this.
Anecdote, not necessary for the rest of the article: the fact that drop
moves out the values passed to it has the elegant consequence that types implementing Copy
are not destroyed by drop
, since a move out for such a type is just a copy.
Lifetimes are everywhere
Now that this is established, let’s look at the following example, which is a near-copy from the Rustonomicon:
fn main() {
let mut integers = vec![1, 2, 3, 4];
let int0 = &integers[0];
integers.push(5);
}
Here, a mutable vector of integers named integers
is created. A reference, int0
, is created and points to the first integer in the vector. Then, we push the integer 5
to the vector. This compiles just fine.
The magic of the compiler appears when we try this:
fn main() {
let mut integers = vec![1, 2, 3, 4];
let int0 = &integers[0];
integers.push(5);
println!("int0 is {:?}:", int0);
}
And this fails:
error[E0502]: cannot borrow `integers` as mutable because it is also borrowed as immutable
--> src/main.rs:4:5
|
3 | let int0 = &integers[0];
| -------- immutable borrow occurs here
4 | integers.push(5);
| ^^^^^^^^^^^^^^^^ mutable borrow occurs here
5 | println!("int0 is {:?}:", int0);
| ---- immutable borrow later used here
The simple fact of printing int0
causes a compilation error.
One might think it’s because Vec
might reallocate on push and thus invalidate the reference. That would be beautiful – but it’s not the reason.
To understand how the compiler sees this, let’s rewrite some lines:
fn main() {
let mut integers: Vec<i32> = vec![1, 2, 3, 4];
let int0: &i32 = Index::index(&integers, 0);
Vec::push(&mut integers, 5);
println!("int0 is {:?}:", int0);
}
When calling index
(from the Index
trait), an immutable reference to integers
is passed as the first argument. The method returns another reference, stored in int0
.
To push 5
, the compiler must create a mutable reference to integers
.
So it seems that the immutable reference &integers
, created for the index
call, is still alive when we try to create the mutable one for push
.
But how?
After all, &integers
seems ephemeral – created only for the duration of the index
call.
The missing piece is the lifetime in the signature of index
:
fn index<'a>(&'a self, index: Idx) -> &'a Self::Output;
Εὕρηκα! This signature tells us that the return value’s lifetime is tied to that of the reference passed in.
So the compiler concludes that the reference returned by index
(stored in int0
) must remain valid until it is last used (here, the println!
). Because of that, the original &integers
reference must also live that long – and thus it conflicts with the mutable borrow.
And that is exactly what the compiler is trying to tell us with the error message.
The version without println!
compiles because the lifetime of int0
ends right after creation.
As for push
, its signature is:
pub fn push<'a>(&'a mut self, value: T)
Meaning that the mutable reference’s lifetime is limited to the call itself. Thus, multiple calls to push
work fine. The lifetime of the mutable reference passed as a parameter – since there is no return value whose lifetime might extend the constraint – is limited to that of the method itself. And mutable reference, it lived as live the mutable references: for the space of a method.
Can we fool the compiler?
Let us now take a slightly more elaborate example. Here we have a FortuneCookie
that has a method generate
. This method takes a string that it does nothing with and always returns the static string “Home, sweet home”. What interests us here are the lifetime declarations:
struct FortuneCookie;
impl FortuneCookie {
fn generate<'a>(&'a self, saying: &'a str) -> &'a str {
"Home, sweet home"
}
}
fn main() {
let cookie = FortuneCookie;
let mut fortune = String::from("Every cloud has a silver lining");
let preach = cookie.generate(&fortune);
fortune.push_str("... so it will get better, kiddo");
println!("I have to say: {}", preach);
}
Here, the method generate
declares that the implicit reference &cookie
, the reference saying
, and the return value all share the same lifetime: the return value is only valid as long as all those other references are. This code does not compile because it implies that the reference &fortune
must live until the println!
, but a mutable reference to fortune
is created by push_str
. That breaks the borrowing rules, so it’s forbidden.
And yet, in truth, generate
does absolutely nothing with &fortune
to construct its return value. The most amusing part is that the compiler knows this, because it tells us so:
warning: unused variable: `saying`
--> src/main.rs:4:29
|
4 | fn generate<'a>(&'a self, saying: &'a str) -> &'a str {
| ^^^^^^ help: if this is intentional, prefix it with an underscore: `_saying`
This illustrates both the power and the limits of lifetimes. Lifetimes are only meant to indicate relationships between lifespans to the compiler, which cannot always infer them – either due to ambiguity or limited capability. But they do not change the actual lifetime of any reference.
If we change the signature of generate
to the following, the code compiles:
fn generate<'a, 'b>(&'a self, saying: &'b str) -> &'a str {
"Home, sweet home"
}
Now, the compiler understands that saying
(&fortune
) does not need to remain valid for the return value to be valid. Only &self
(&cookie
) must remain valid. So the lifetime of saying
can end right after its declaration.
This seems reasonable, but can we trick the compiler once? Or a thousand times?
Let us try this:
fn generate<'a, 'b>(&'a self, saying: &'b str) -> &'a str {
saying
}
This tells the compiler: the return value has lifetime 'a
(same as &self
), but it comes from a reference with lifetime 'b
.
And the compiler complains:
error: lifetime may not live long enough
--> src/main.rs:5:9
|
4 | fn generate<'a, 'b>(&'a self, saying: &'b str) -> &'a str {
| -- -- lifetime `'b` defined here
| |
| lifetime `'a` defined here
5 | saying
| ^^^^^^ associated function was supposed to return data with lifetime `'a` but it is returning data with lifetime `'b`
|
= help: consider adding the following bound: `'b: 'a`
The error tells us clearly: the function is expected to return a reference with lifetime 'a
, but the actual data (saying
) has lifetime 'b
. The compiler has no way of knowing if 'b
lives at least as long as 'a
. It proposes a solution: declare that 'b: 'a
– that is, 'b
must live at least as long as 'a
. We’ll come back to this.
Lifetimes and subtyping
But first, a detour: why, when we were returning the string “Home, sweet home”, was there no issue?
fn generate<'a, 'b>(&'a self, saying: &'b str) -> &'a str {
"Home, sweet home"
}
The reason lies in the fact that the string literal has a special lifetime, called 'static
, which is practically infinite – or rather, it lasts for the entire program.
There is no problem returning something with a longer lifetime than expected: it will never cause issues.
In Rust – counterintuitive as it may seem – longer lifetimes can be considered subtypes of shorter ones (Subtyping and Variance). One way to think about it is this: a subtype implements all the constraints of its supertype, and perhaps more. Likewise, a longer lifetime satisfies all the constraints of a shorter one.
This is why the compiler was happy to return a 'static
string when 'a
was expected: 'static
lives longer than 'a
, so it’s totally safe.
In practice, it means the actual usable lifetime is capped by 'a
, but since 'static
lives longer, all is good.
This is, in fact, the very fix the compiler suggested: declare a bound 'b: 'a
– in other words, tell the compiler that 'b
is a subtype of 'a
. That is, 'b
must live at least as long as 'a
.
fn generate<'a, 'b: 'a>(&'a self, saying: &'b str) -> &'a str {
saying
}
However, in our example, we know 'b
cannot live as long as 'a
, because 'a
must live until the println!
, and we cannot have both a mutable and immutable reference to fortune
at the same time.
So, no – we cannot lie to the compiler:
error[E0502]: cannot borrow `fortune` as mutable because it is also borrowed as immutable
--> src/main.rs:13:5
|
12 | let preach = cookie.generate(&fortune);
| -------- immutable borrow occurs here
13 | fortune.push_str("... so it will get better, kiddo");
| ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ mutable borrow occurs here
14 | println!("I have to say: {}", preach);
| ------ immutable borrow later used here
Once again, this is the key: lifetime declarations do not change the actual lifetime of references. They only inform the compiler of the intended relationships between lifetimes, in contexts where it cannot infer them. This can happen in function signatures, or struct definitions. The core issue is that the rules of lifetime elision are limited, and some seemingly obvious cases still need to be explicitly written – perhaps one day, the language will be smart enough to handle them all.