Lifetimes in Rust
New to Rust? Check out my YouTube channel or my free introduction course on Egghead!
Lifetimes are one of the most important, yet probably one of the least understood topics in the Rust programming language. That’s no surprise, because they can be hard to grok, especially if there’s lack of understanding in how Rust manages memory. What are they, why do they exists and when do we need them? We’re going to explore all that in this article.
Let’s start with a little scenario
The first time I heard about the term “lifetime” in the context of Rust, was when I tried implement some struct that holds a reference to another struct instance. If you’re coming from other languages, it is in a way the equivalent of having some object holding another object or reference to it. In my case, I wanted to share a reference to some Config
across different places in my application, so the code looked something like this:
struct Config {
...
}
struct App {
config: &Config
}
If the ampersand syntax (&Config
) doesn’t make a lot of sense to you, you might want to read my article on References in Rust first and come back once you’re done. Either way, if we try to compile this code, we’ll get the following error:
error[E0106]: missing lifetime specifier
--> src/lib.rs:6:13
|
6 | config: &Config
| ^ expected named lifetime parameter
|
help: consider introducing a named lifetime parameter
|
5 | struct App<'a> {
6 | config: &'a Config
Apparently the compiler wants something that’s called a named lifetime parameter, whatever that is, and it also tells us exactly what that needs to look like. If we change our App
struct accordingly, the code compiles.
struct App<'a> {
config: &'a Config
}
Well… cool so we’ve added a bunch of weird syntax to our code and the compiler is happy, but what exactly is going on here?
The short answer is that we tell the compiler, that config
, which is of type &Config
, has the same lifetime as App
. That lifetime happens to be called a
but could be called really anything else. Because they both have the same lifetime, App
is not allowed to outlive the referene to Config
, otherwise it would be a dangling pointer, which Rust doesn’t allow by design.
Uff.. what? Yea, right. Let’s roll back a bit.
On Reference Safety
Rust claims to be memory safe and we’ve talked about that to some extend in this article on Ownership in Rust. In there, we’ve discussed that Rust “drops” values from memory that go “out of scope” and a scope is pretty much anything that introduces a new block (a lexical block, a statement, an expression etc). Take for example this function:
fn greeting() {
let s = "Have a nice day".to_string();
println!("{}", s); // `s` is dropped here
}
The variable s
is defined inside of greeting
and a soon as the function is done doing its work, s
is no longer needed so Rust will drop its value, freeing up the memory. We could say that s
“lives” as long as the execution of greeting
.
This is an important concept, especially when it comes to using references in Rust. Whenever we use references, Rust tries to assign them a lifetime that matches the constraints by how their used. Here’s a simple example of that:
fn main() {
let r;
{
let x = 1;
r = &x;
}
println!("{}", r)
}
We introduce a variable r
, that receives a reference to x
in the following block. Notice that the square brackets really just introduce a new block. We probably wouldn’t use them like that in real world applications. After that we print the value of r
.
However, we’ll quickly learn that the compiler isn’t really happy about this code:
error[E0597]: `x` does not live long enough
--> src/main.rs:6:13
|
6 | r = &x;
| ^^ borrowed value does not live long enough
7 | }
| - `x` dropped here while still borrowed
8 | println!("{}", r)
| - borrow later used here
This makes sense. We’re printing the value of r
which holds a reference to x
, but x
is dropped from memory before that. In other words, r
outlives x
.
fn main() {
let r; –––––––––––––––+
{ –––––––––––––+ |
let x = 1; | <––– // Lifetime of `&x`
r = &x; | |
} –––––––––––––+ | <––– // Lifetime of `r`
println!("{}", r) –––––+
}
In other words, if the reference doesn’t live at least as long as the variable does, r
will be a dangling pointer at some point.
This might feel like an annoying characteristic of the Rust compiler, but it’s actually an extremely powerful feature. Let this sink in for a second: The compiler is able to derive from your code if and where you’re trying to access a variable that potentially points to nothing. 🤯
To make the code above work, we obviously have to move x
in a way that its lifetime encloses the one of r
:
fn main() {
let x = 1;
{
let r = &x;
println!("{}", r)
}
}
Sweet. We understand the concept of lifetimes now, but we still don’t know when and why we need to apply this weird syntax (<'a>
) from earlier. Let’s dig into that by looking at lifetimes and function arguments.
Lifetimes and Arguments
Given what we’ve learned so far, we might wonder how these concepts apply to functions that take references as arguments. You might have already guessed that there’s no magic mechanism that removes the idea of lifetimes in Rust, just because a reference is passed to a function. Let’s take a look at the function below, which takes any argument that’s of type &i32
:
fn some_function(val: &i32) {
...
}
What we don’t see here, is that Rust actually expands this code as if it was written like this:
fn some_function<'a>(val: &'a i32) {
...
}
Look, there’s the <'a>
again! What’s going on here? We’ve just written out explicitly what Rust allows us to omit. 'a
is a lifetime parameter of our some_function
. It doesn’t really matter at this point that we call it 'a
, we might as well call it 'foo
or 'lifetime
, however it’s convenient to give it some name that is enumerable (like the alphabet), because there could be more than one lifetime parameter, but more on that later. Keep in mind however, that lifetimes are a compile-time feature and don’t exist at runtime.
With this signature we’re basically saying: some_function
takes a reference to an i32
with any given lifetime 'a
. This is enough information for the compiler to know that some_function
won’t save val
anywhere that might outlife the call. It would be different if some_function
took a lifetime parameter of 'static
, which is Rust’s lifetime for anything that is considered global. In such a case, whatever is passed to the function, needs to live at least as long. By definition, only `static values would match that constraint.
The same applies to functions that return references as well!
Returning References
Fairly often, functions take references to data structures and return a reference that points into that structure. The following function illustrates this case:
fn smallest_number(n: &[i32]) -> &i32 {
let mut s = &n[0];
for r in &n[1..] {
if r < s {
s = r;
}
}
s
}
smallet_number
takes a reference to a vector of numbers and returns a reference to a number. By default, Rust will assume that these two references have the same lifetime. So the function signature will be expanded to:
fn smallest_number<'a>(n: &'a [i32]) -> &'a i32 {
...
}
Again, we’re basically saying: For any lifetime 'a
, smallest_number
takes a &[i32]
and returns a &i32
that has the same lifetime. This ensures that we can’t borrow the returned reference from smallest_number
if it doesn’t life at least as long as the variable we’ve assigned it to. In other words, if we try to compile the this code:
let s;
{
let numbers = [2, 4, 1, 0, 9];
s = smallest_number(&numbers);
}
println!("{}", s)
Rust will tell us that numbers
doesn’t live long enough:
error[E0597]: `numbers` does not live long enough
--> src/main.rs:5:29
|
5 | s = smallest_number(&numbers);
| ^^^^^^^^ borrowed value does not live long enough
6 | }
| - `numbers` dropped here while still borrowed
7 | println!("{}", s)
| - borrow later used here
Coming back to the scenario we started out with, namely structs that contain references, things make much more sense now.
Structs with references
When it comes to actual type definitions, like the ones we’ve started out with in this article, as soon as it contains references, we have to write out their lifetimes. The code below won’t compile.
struct Config {
...
}
struct App {
config: &Config
}
While this one does:
struct Config {
...
}
struct App<'a> {
config: &'a Config
}
You might be wondering: Why can’t the compiler simply expand our types with lifetimes just like it does with functions?
Good question! Turns out, earlier versions of the compiler actually did exactly that. However, developers found that part confusing and preferred it to know exactly when one value borrows something from another.
One last thing to note here, if App
was borrowed in another type, that type will have to define its lifetime parameters as well:
struct Platform<'a> {
app: App<'a>
}
Lifetime parameters of different lifetimes
Of course, it’s also possible for functions and types to contain references of different lifetimes. Say we have a struct Point
that takes x
and y
which are both &i32
:
struct Point {
x: &i32,
y: &i32
}
We’ve already learned that we have to write out the lifetimes of the references, so we could go ahead and define them like this:
struct Point<'a> {
x: &'a i32,
y: &'a i32
}
That’s fine, as long as x
and y
have indeed the same lifetime. For example, the following code would compile:
fn main() {
let x = 3;
let y = 4;
let point = Point { x: &x, y: &y };
}
However, as soon as x
and y
have different lifetimes and we’re trying to reference one of them outside of the smallest scope, things are not going to fly.
fn main() {
let x = 3;
let r;
{
let y = 4;
let point = Point { x: &x, y: &y };
r = point.x
}
println!("{}", r);
}
Rust will have to find a lifetime that works for point.x
and point.y
but also encloses r
’s lifetime. Since this code doesn’t satisfy that constraint we’ll get a compile error:
error[E0597]: `y` does not live long enough
--> src/main.rs:12:39
|
12 | let point = Point { x: &x, y: &y };
| ^^ borrowed value does not live long enough
13 | r = point.x
14 | }
| - `y` dropped here while still borrowed
15 | println!("{}", r);
| - borrow later used here
To get around this, we can simply say that Point
has multiple distinct lifetime parameters by extending the type definition like this:
struct Point<'a, 'b> {
x: &'a i32,
y: &'b i32
}
Conclusion
Alright, so we’ve talked about the fact that Rust keeps its references under control by assigning lifetimes to them. Lifetimes are a compile-time only feature and don’t exist at runtime. They ensure that types containing references don’t outlive their them, which basically prevents us from writing code that produces dangling poitners. We also learned that in many cases, lifetime definitions can be omitted and Rust fills in the gaps for us. It’s also possible to have types with multiple distinct lifetime parameters.
I hope this article gave you a better idea of what lifetimes are, how they work and when and why they are needed.