A Journey Through Rust Lifetimes
I wanted to write an article about one aspect of Rust I really put off for a long while — lifetimes. They are one of the hardest parts about Rust to wrap one’s brain around. Many of us are simply not used to a compiler with a paradigm around memory ownership where such things are needed.
Lifetimes help the compiler make your code safer (i.e. less prone to crashing by using unexpected places in memory). Even if we don’t write them in our code, the compiler is smart enough to figure out your lifetimes without you under the covers. They are often times your secret allies, so let's learn a bit about them.
We’ll start with what lifetimes mean. What does it mean to say two variables x
and y
have a lifetime 'a
. Lifetimes are used to describe:
- the relationship of parameters of functions to their output
- relationship of structures to their children
In Rust, we need to be able to answer questions like “How long is this functions output guaranteed to be allocated in memory?” and “How long is this struct’s member guaranteed to be allocated in memory?”
What I want to do today with you is run through a set of examples to demonstrate how the compiler is unable to figure out the lifetimes of your data, and show how you can use lifetime annotations to help the compiler figure out whats happening. Let’s begin with a simple struct generating function:
struct Foo {
bar: i32
}fn main() {
let b = create();
println!("{}",b.bar);
}fn create() -> Foo {
let f = Foo { bar: 100 };
f
}
In this simple example, Rust is very clear about the lifetime of your data. Because the struct Foo
is being allocated and moved out of the function it is created in, we have much more knowledge about how long the variable b
lasts in it’s scope. Rust knowledge of your lifetimes tends to break down when you start using references. If you really don’t like the idea of telling Rust about lifetimes, your surest bet is to avoid references as much as possible. Sometimes we do not always have that luxury. What makes references difficult is that they traditionally have no knowledge of the scope of what is being referred to.
struct Foo {
bar: i32
}fn main() {
let b = create();
println!("{}",b.bar);
}fn create() -> &Foo {
let f = Foo { bar: 100 };
&f
}
In this example, Rust gets confused that your method is returning a reference from a function with no inputs. Let’s take a look at the error.
help: consider giving it a 'static lifetime: `&'static`
Whoa, okay, first, what is this 'static
annotation? This is a unique lifetime identifier that represents a variable that has static scope (i.e. its allocated only once and never goes away). Rust is confused that we have a function that returns a reference but has no inputs. Since local scoped variables are deallocated unless we are moving out data, returning a reference must mean the reference being returned has a larger scope than the function! Let’s try adding the static lifetime annotation.
struct Foo {
bar: i32
}fn main() {
let b = create();
println!("{}",b.bar);
}fn create() -> &'static Foo {
let f = Foo { bar: 100 };
&f
}
Ah, okay, a more useful error returns a reference to data owned by the current function
. Let’s change this code to return something that won’t be immediately deallocated to satisfy the compilers guarantees.
struct Foo {
bar: i32
}fn main() {
let b = create();
println!("{}",b.bar);
}static FOO : Foo = Foo{ bar: 100 };fn create() -> &'static Foo {
&FOO
}
Great! It compiles. Now, we’ve seen an example of how the compiler needs to understand the lifetime of a function that returns a reference that takes in no inputs. Let’s create a function that returns a reference of an input reference,
struct Foo {
bar: i32
}fn main() {
let b = create();
let r = get_bar(&b);
println!("{}",r);
}static FOO : Foo = Foo{ bar: 100 };fn create() -> &'static Foo {
&FOO
}fn get_bar(f:&Foo) -> &i32 {
&f.bar
}
We’ve created a function get_bar
that takes in a reference &Foo
and returns an integer reference of its member get_bar
. Let’s compile this code.
No errors! What happened? I thought lifetimes were supposed to be hard?
Rust is smart enough to figure some scenarios of references out on its own. Because there is only one parameter to the function get_bar
it deduces that the only lifetime it’s output could be based upon is it’s input. In other words, the output will last at least as long as it’s sole input. This is called lifetime elision (i.e. Rust figured out what to do without us based on some rule). If we REALLY wanted to be explicit about this, we could annotate it unnecessarily.
fn get_bar<'a>(f:&'a Foo) -> &'a i32 {
&f.bar
}
Notice how we declare lifetimes using the <
and >
syntax similar to how we declare generic types. The 'a
annotation goes on the &
of the input and output to describe the relationship that the output will last as long as the reference parameter f
.
Now, let’s change our structure a bit. Instead of containing an integer, let’s make our structure contain a reference to an integer.
struct Foo {
bar: &i32
}
fn main() {
let i = 100;
let b = create(&i);
let r = get_bar(&b);
println!("{}",r);
}fn create(i:&i32) -> Foo {
Foo{bar:i}
}fn get_bar(f:&Foo) -> &i32 {
f.bar
}
Compiling this code we immediately get an error bar: &i32 | expected lifetime parameter
. Rust is unable to know the relation between the struct Foo
and it’s child member bar
. Does bar have a static lifetime? If there were other children, does it have the same lifetime as the other children? And other such questions. Let’s change the definition a bit to help Rust out.
struct Foo<'a> {
bar: &'a i32
}
Here we are declaring another lifetime ‘a
that allows us to state that the child bar will last at least as long as the structure that holds it. We aren’t making any other guarantees such as static lifetime or anything fancy. In Rust we have flexibility how specific or general we want our lifetime annotations to be.
Compiling the code we get another error! this function's return type contains a borrowed value, but the signature does not say which one of `f`'s 2 lifetimes it is borrowed from
Rust was not able to infer the lifetime of the output of
fn get_bar(f:&Foo) -> &i32 {
f.bar
}
Our newly added lifetime definitions to our struct Foo
have created ambiguity around whether the output is based on the member of our Foo
struct or the parameter f
(which might not be the same). This might be a bit confusing at first, but remember that in our struct the lifetime annotations state that the reference of bar
is at least as long as the owning structure. bar
might be static while f
might be local for instance. This ambiguity is what Rust wants us to clear up.
For now let’s just solve this simply by saying the output lasts as long as the parameter.
struct Foo<'a> {
bar: &'a i32
}
fn main() {
let i = 100;
let b = create(&i);
let r = get_bar(&b);
println!("{}",r);
}fn create(i:&i32) -> Foo {
Foo{bar:i}
}fn get_bar<'a>(f:&'a Foo) -> &'a i32 {
f.bar
}
Great! It compiles again.
Now, lets change this a bit so that get_bar
is a method of Foo
struct Foo<'a> {
bar: &'a i32
}
fn main() {
let i = 100;
let b = create(&i);
let r = b.get_bar();
println!("{}",r);
}fn create(i:&i32) -> Foo {
Foo{bar:i}
}impl<'a> Foo<'a> {
fn get_bar(&self) -> &i32 {
self.bar
}
}
Notice, how our implementations of Foo
now require a lifetime to be declared and associated with the struct. But wait, why is our get_bar
now working without having to specify lifetimes!? Similar to the single parameter function earlier, Rust has a lifetime elision rule that says if your function has a &self
, it assumes that’s what the output reference’s lifetime is based on &self
, allowing us to not have to specify lifetimes on the method.
Now let’s create one last function! get_bigger_bar
that takes in two references of a struct Foo
and returns the reference to the member bar
that is largest.
fn get_bigger_bar(a:&Foo,b:&Foo) -> &i32 {
if a.bar < b.bar {
a.bar
} else {
b.bar
}
}
Now we immediately have a problem with this function is that the output could be allocated as long as it’s input a
or b
. It could also be the lifetime of the reference in the member bar
. Rust doesn’t like this ambiguity so let’s try giving it some help.
struct Foo<'a> {
bar: &'a i32
}
fn main() {
let i = 100;
let j = 100;
let a = create(&i);
let b = create(&j);
let r = get_bigger_bar(&a,&b);
println!("{}",r);
}fn create(i:&i32) -> Foo {
Foo{bar:i}
}fn get_bigger_bar<'a>(a:&'a Foo,b:&'a Foo) -> &'a i32 {
if a.bar < b.bar {
a.bar
} else {
b.bar
}
}
After compiling, Rust seems satisfied! We are saying that the output of our function is allocated at least as long as whatever lifetime is shortest of parameters a
or b
. This type of annotation is crude, but gets the job done. It’s important to note that there may be some scenarios though where the annotations we have given to Rust is not complete enough for it to know what to do. Consider a similar bit of code.
struct Foo<'a> {
bar: &'a i32
}fn main() {
let i = 100;
let j = 100;
let a = create(&i);
let mut r = &i;
{
let b = create(&j);
r = get_bigger_bar(&a,&b);
} println!("{}",r);
}fn create(i:&i32) -> Foo {
Foo{bar:i}
}fn get_bigger_bar<'a>(a:&'a Foo,b:&'a Foo) -> &'a i32 {
if a.bar < b.bar {
a.bar
} else {
b.bar
}
}
We get the error `b` does not live long enough
due to it being deallocated it the manually created scope. If you notice though, both integer i
and j
live long enough! We can specify that the lifetime of the returned reference is based on the member bar
of the parameters, not the parameters themselves.
struct Foo<'a> {
bar: &'a i32
}fn main() {
let i = 100;
let j = 100;
let a = create(&i);
let mut r = &i;
{
let b = create(&j);
r = get_bigger_bar(&a,&b);
}println!("{}",r);
}fn create(i:&i32) -> Foo {
Foo{bar:i}
}fn get_bigger_bar<'a>(a:&Foo<'a>,b:&Foo<'a>) -> &'a i32 {
if a.bar < b.bar {
a.bar
} else {
b.bar
}
}
This compiles succesfully!
Hopefully this article has given some clarity around lifetimes and the various ways that we can help the compiler figure out how long things are really being allocated.
As a reminder, many people find ways around having to specify lifetimes by not using references at all, but references are a powerful tool for not having to pass around copies of data. Only you know what’s best for your code.
Whatever you choose, the magic of Rust is that it can figure out how long data is allocated for so we never have to worry about mistakenly using unitialized or deallocated places of memory. While lifetimes may seem complicated at first, they are the secret sauce that makes segfaults much harder to generate compared to other languages like C so we can sleep easier at night.
Have fun out there!