Learn You a Rust III - Lifetimes 101
This is the third part of the Learn You a Rust for Great Good! tutorial explainer series. If you’re coming to the series for the first time, I recommend starting at the first post linked above.
I’m back! Sorry for the month of hiatus - been very busy [citation needed] working on my personal audio project (more on that later!) and having a bit too much fun with OpenTTD. Anyway, without further ado…
EDIT I (18/4): Changed a few incorrect assumptions, with help from people on /r/rust.
Last time, we discussed the concept of borrowing - lending out our owned objects to functions and suchlike - to make the idea of ownership less painful. We had a few examples of terrible, shameful code that violated these rules, and explained why the code was so terrible and shameful.
This time, we will cover a concept that has befuddled many new Rustaceans (including me!): that of the dreaded lifetime.
Newbie vs. Lifetimes: Round I
Most Rustaceans encounter lifetimes for the first time via two ways. Let’s have a look at these.
Problem One: Storing a borrow in a struct
So you’ve just heard about borrows. They’re pretty nice; they make ownership easier. In particular, they let us write code like this:
The Multiplier
is a special object that provides a way to represent an object multiplied by a number without having to touch the
original object at all - something we couldn’t do with ownership (well, not without cloning the original object). Cool, right?
Unfortunately for us, this code will fail to compile, spitting out some weird lifetime error.
multiplier.rs:6:13: 6:20 error: missing lifetime specifier [E0106] multiplier.rs:6 object: &Object, ^~~~~~~ multiplier.rs:6:13: 6:20 help: run `rustc --explain E0106` to see a detailed explanation
Running the --explain
gives us this verbage:
This error indicates that a lifetime is missing from a type. If it is an error inside a function signature, the problem may be with failing to adhere to the lifetime elision rules (see below).
Strange. Let’s have a look at the other common lifetime problem, and then I’ll reveal what the hell is going on here.
Problem Two: Borrowed types in a function
Let’s use the power of borrowing to write another handy function: this time, to add an Object
to another mutable
Object
, without having to transfer ownership.
This gives us the same error as above. What the hell, Rust?!
multiplier.rs:10:53: 10:64 error: missing lifetime specifier [E0106] multiplier.rs:10 fn object_combinator(a: &mut Object, b: &Object) -> &mut Object { ^~~~~~~~~~~ multiplier.rs:10:53: 10:64 help: run `rustc --explain E0106` to see a detailed explanation
Birth of a Lifetime
Let’s take a step back from all these errors. There we go - clear your head. I’m now going to explain why lifetimes exist, and why they’ve come into our Rust programming careers just when we were starting to have some fun.
When we looked at borrows beforehand, we set down some ground rules about what you can and can’t do with them. Here they are:
- Any borrow must last for a scope no greater than that of the owner. (if I take away your object while you’re trying to use it, you’ll try to use it anyway and end up possibly using something else)
- You may have either 1+ immutable borrow(s) or exactly 1 mutable borrow at a time - never both. (if I edit your object while you’re trying to use it, you’ll get REALLY confused and bad things will happen)
The thing is, how is the compiler supposed to check if we’re abiding by these rules? Lifetimes, unsurprisingly, are the answer.
Lifetimes are used simply to describe how long objects live for. When you make a new variable, the compiler attaches a lifetime to it. Then, by comparing all of the lifetimes to each other, the compiler can know whether your code is correct.
To quote the Rustonomicon:
Rust enforces [the borrowing rules] through lifetimes. Lifetimes are effectively just names for scopes somewhere in the program. Each reference, and anything that contains a reference, is tagged with a lifetime specifying the scope it's valid for.
Remember that variables go away when your code leaves their scope (the function or code block where they were created) - in the opposite order that they were created (unless, of course, they are moved out of that scope). Look at this example:
What’s actually going on here is a little more nuanced than the above example, actually. Get this: when a variable is created, it
creates its own little mini-scope that lasts for as long as it is needed lasts until the scope containing it ends. For
example, d
goes away first because its mini-scope ends before b
’s does. Let me rewrite this example, making the scopes more
obvious:
Rust now looks at all the lifetimes:
- it sees that
'a
is bigger than'c
and'd
, and knows that there are no borrows lasting longer than what they borrow - it sees that
'c
and'd
are separate from each other, and knows that the rules regarding mutable borrows are not broken
Phew. Wasn’t that painful? Luckily, Rust automatically creates and manages these mini-scopes for us, so we never have to write them in and our code will just work in 99.9% of cases.
However, sometimes Rust can’t (or won’t, due to its philosophy of being explicit) fill these scopes in for us. Sometimes, it needs more information. That’s when we have to pencil some information about these scopes in for Rust to understand what we’re on about.
Fixing Our Problems
Right. Now we at least know what lifetimes are, I can show you how we use them to solve our problems.
Problem One: Storing a borrow in a struct
Recall that this code was giving us the following error:
multiplier.rs:6:13: 6:20 error: missing lifetime specifier [E0106] multiplier.rs:6 object: &Object, ^~~~~~~ multiplier.rs:6:13: 6:20 help: run `rustc --explain E0106` to see a detailed explanation
Rust is complaining about not being able to figure your lifetimes out for you. Let’s step through the logic.
- We introduce an
Object
type, containing a singlenumber
. Rust is happy with this idea, as it figures that thenumber
will live as long as theObject
does. - We introduce a
Multiplier
type, containing amult
and a borrow of anObject
. Rust is happy about themult
- it uses the same logic as above - but it doesn’t like theobject
borrow. - The thing is,
object
could live for any odd lifetime - possibly one smaller than theMultiplier
’s! Rust wants us to constrain theMultiplier
so that it can check your code.
Here’s the solution:
Argh! New, complicated syntax! What is this madness?!
Basically, this code says “I have an object here called Multiplier
that lives as long as the lifetime 'a
.
Given that lifetime, I have an object
inside me that lasts as least as long as 'a
.” (The less-than-greater-than bracket syntax is Rust’s way of specifying generics - you can read <'a>
as “for a given lifetime 'a
”)
This then “links” the lifetime of a Multiplier
to the lifetime of the Object
inside it, making sure the Object
won’t go
away before the Multiplier
does. The compiler is now happy with our definition of a Multiplier
, and the code will compile.
(This is also how the RefObject
we created whilst explaining mini-scopes gets to compile.)
Problem Two: Borrowed types in a function
Rust doesn’t like our object_combinator
here.
multiplier.rs:10:53: 10:64 error: missing lifetime specifier [E0106] multiplier.rs:10 fn object_combinator(a: &mut Object, b: &Object) -> &mut Object { ^~~~~~~~~~~ multiplier.rs:10:53: 10:64 help: run `rustc --explain E0106` to see a detailed explanation
Thankfully, the people behind Rust are helpful and kind people who put in a little tip for us confused programmers.
multiplier.rs:10:53: 10:64 help: this function's return type contains a borrowed value, but the signature does not say whether it is borrowed from `a` or `b`
The problem, as described above, is simple: you’re giving object_combinator
two borrows with possibly different lifetimes, and
returning one borrow. How is Rust supposed to figure out how long the borrow you returned lives for? It can’t just guess for you!
Rust squawks at us and asks us to help it figure out what’s going on.
We know that our object_combinator
adds the second Object
(b
)’s number to a
, and then returns a
. Therefore, it’s a
’s
lifetime we care about here, as that’s what we’re returning. Let’s annotate our function with lifetimes to clue Rust in.
This is saying to Rust: “I have an object_combinator
which takes in a borrow to an object that lives as long as the lifetime 'a
,
and one that lives as long as the lifetime 'b
. I’m going to return a borrow to an object that lives as long as the lifetime 'a
.”
Rust is happy with this definition, and lets our code compile.
‘static
The 'static
lifetime is a special lifetime which means “lives for the entire program”. You’ll
probably see it first when dealing with strings:
and, unsurprisingly, static
things:
You’ll also see it when trying to program things that are multithreaded. Sending borrows to things
that live for a lifetime that isn’t 'static
to another thread is disallowed, because it’s
possible for the thread you’re sending something to to outlive the thread you sent it from -
invalidating the reference.
Lifetime Elision
Lifetime elision is an example of Rust filling things in for you to make programming less unbearable - and it’s why we haven’t had to worry about lifetimes until now.
Basically, the rules are as follows:
- Lifetimes coming into a function are called input lifetimes.
- Lifetimes returned from a function are called output lifetimes.
- If there is one input lifetime, it is assigned to all output lifetimes. (Example:
fn foo<'a>(bar: &'a str) -> &'a str
) - If there are many input lifetimes, but one of them is a reference to
self
, the lifetime ofself
is assigned to all elided output lifetimes.
I’m just going to link you to the pertinent Rust Book section on this topic, as this article is getting too long.
It has examples and stuff! Read it now!
Yay, we’re done
So that’s what a lifetime is. Hopefully you’ve understood all my incoherent ramblings! If you’d like more information about lifetimes, have a look at the Rust Book (easy), Rustonomicon (slightly more advanced), and head on over to /r/rust if you want to ask us something about lifetimes. There are many nice people there willing to help!
Thanks for reading! Next time in the series, I’ll explain some more difficult lifetime shenanigans, methods for solving them, and I’ll share some problems I’ve personally encountered with lifetimes and how to fix them.