Lifetimes
Lifetimes are, in my opinion, one of the biggest sources of confusion to folks starting out writing Rust. They also tend to look scary when reading some Rust code for the first time. I think this is because of two reasons:
- they look very terse syntactically
- they are a pretty foreign concept to folks coming from languages like JavaScript
Before we talk about what they really are and how to use them, let's walk through an example to encounter them: compiler error driven learning, if I may.
Earlier in the course, we learnt how to define a struct. We'll replace the age
field in it to location
for these examples and write a simple main
function to print some text.
struct Person {
name: String,
location: String,
}
fn main() {
let me = Person {
name: String::from("Sid"),
location: String::from("London"),
};
let jon = Person {
name: String::from("Jon"),
location: String::from("St. Petersburg"),
};
println!("Hello, {} from {}!", me.name, me.location);
println!("Hello, {} from {}!", jon.name, jon.location);
}
Everything above should look pretty familiar so far. We've created a few String instances since our struct takes an owned String
.
Now, Jon just happened to move to London. So if I edit his struct it'll look something like this.
fn main() {
let me = Person {
name: String::from("Sid"),
location: String::from("London"),
};
let jon = Person {
name: String::from("Jon"),
location: String::from("London"),
};
println!("Hello, {} from {}!", me.name, me.location);
println!("Hello, {} from {}!", jon.name, jon.location);
}
What sticks out above is how we're creating two identical String instances with the contents, "London". One might be tempted to move that over to a single variable. Let's try that.
struct Person {
name: String,
location: String,
}
fn main() {
let london = String::from("London");
let me = Person {
name: String::from("Sid"),
location: london,
};
let jon = Person {
name: String::from("Jon"),
location: london,
};
println!("Hello, {} from {}!", me.name, me.location);
println!("Hello, {} from {}!", jon.name, jon.location);
}
This doesn't compile and if you worked through the last couple of lessons, you'll know why! When we initialise me,
when move ownership of the london
variable into the struct.
error[E0382]: use of moved value: `london`
--> src/main.rs:15:19
|
7 | let london = String::from("London");
| ------ move occurs because `london` has type `String`, which does not implement the `Copy` trait
...
10 | location: london,
| ------ value moved here
...
15 | location: london,
| ^^^^^^ value used here after move
The compiler does give us a really nice error message saying the same thing.
If String
implemented the Copy
trait, this would just work because it would be automatically copied (cloned) under the hood instead of moved.
The move occurs because String
doesn't. More on traits soon.
Okay, let's try to fix this with using references in the struct instead.
struct Person {
name: String,
location: &String,
}
fn main() {
let london = String::from("London");
let me = Person {
name: String::from("Sid"),
location: &london,
};
let jon = Person {
name: String::from("Jon"),
location: &london,
};
println!("Hello, {} from {}!", me.name, me.location);
println!("Hello, {} from {}!", jon.name, jon.location);
}
What we've done now is that instead of including an owned String, the Person
struct has a field location
which is a reference to a String instead, &String
. This should work and we should be able to use the same variable with two immutable references as we've learned, right?
Go ahead and try compiling it.
Spoilers below:
error[E0106]: missing lifetime specifier
--> src/main.rs:3:15
|
3 | location: &String,
| ^ expected named lifetime parameter
|
help: consider introducing a named lifetime parameter
|
1 ~ struct Person<'a> {
2 | name: String,
3 ~ location: &'a String,
|
What I love about the error message is that it actually fixes our issue entirely. So let's edit it in first and then understand what just happened.
struct Person<'a> {
name: String,
location: &'a String,
}
fn main() {
let london = String::from("London");
let me = Person {
name: String::from("Sid"),
location: &london,
};
let jon = Person {
name: String::from("Jon"),
location: &london,
};
println!("Hello, {} from {}!", me.name, me.location);
println!("Hello, {} from {}!", jon.name, jon.location);
}
This compiles perfectly with the output:
Hello, Sid from London!
Hello, Jon from London!
Now, let's understand what just happened.
What is a Lifetime?
We learnt about the Rust compiler's borrow checker before and how it prevents uncertainty in your programs by disallowing mutations by more than one code path at a time. It also ensures that there aren't immutable references along with mutable ones so that one code path doesn't try to read some memory while another writes to it.
There's one more aspect of memory safety that the Rust compiler tries to ensure and this is the lifetime of a value. Typically the lifetime of a value is its surrounding scope.
fn main() {
let x = 1;
{
let y = 2;
}
}
The lifetime of x
in the snippet above is till the end of the main
function and the lifetime of y
is till the end of the block scope it's in, that is the closing }
. The word lifetime here refers to how long the memory referenced by the variable lives. In the snippet above, the compiler was able to infer this.
Lifetime annotations
Sometimes the compiler isn't able to infer the lifetime of a value though. Let's go back to our Person
struct. Before we added the magic 'a
, the compiler printed an error message saying error[E0106]: missing lifetime specifier
.
This is because the compiler was not able to infer a certain lifetime and needed your help.
When we changed the type of the location
key in the Person
struct to a &String
(a reference to a String
and not a String
itself), the lifetime of an instance of this struct is no longer just a function of the scope it is created in.
It is now also a function of the scope that the value referenced in location
is created in. This relationship is what we're trying to signify with a lifetime annotation.
struct Person<'a> {
name: String,
location: &'a String,
}
A lifetime annotation looks like 'a
and it is typical in Rust land to use lower case single alphabets for lifetime annotations. What the above snippet is telling us is that the Person
struct's lifetime is now a function of its own scope and also the lifetime what location references. An instance of the struct itself cannot outlive a value it is referencing.
An important point here to remember is that you are not changing behaviour or the lifetime of a value by annotating it. You are simply giving the compiler information for it to then make assertions based on.
Multiple lifetimes
This is where things start to get interesting. When your function or struct depends on multiple references, you'll likely have to annotate multiple lifetimes and the relationships between them. Let's refactor our code from before a little.
So far we were storing the name of a city in the location
field on Person
. We'll swap that out for a HashMap
. A HashMap
is a data structure in the Rust std
library that lets you store key value pairs.
Think of it as a JavaScript Map
. They both have:
- key value pairs where a key must be unique
However, like all things in Rust and unlike the JavaScript Map
, a HashMap
is typed. All keys and values must conform to their specific types respectively.
Let's start with adding a use std::collections::HashMap;
at the top of our file so that HashMap
is in scope. We'll initialise our HashMap
using its ::from
function which takes a slice of tuples (of key and value pairs):
use std::collections::HashMap;
fn main() {
let airport_codes = HashMap::from([
("PIE", "St. Petersburg"),
("LHR", "London"),
("BOM", "Mumbai"),
]);
}
Now, we'll be able to lookup values in airport_codes
using the get
function on a HashMap
. The get
function returns an enum called Option
which we mentioned earlier. Let's take a quick detour to understand what Option
is.
The Option
enum has two possible values: Some
and None
. In JavaScript, to represent an optional value, we set a variable to undefined
or null
and then are forced to have conditionals everywhere to ensure a value is set.
In Rust, the optionality of a value is built into the type system. Think of the Option
enum as a wrapper for an optional value. When a value is present, we wrap it using one of the Option enum types: Some
and when it is not set, we use None
. Let's take a look at an example:
struct Person {
name: Name,
age: u8,
}
struct Name {
first_name: String,
middle_name: Option<String>,
last_name: String,
}
fn main() {
let john = Person {
name: Name {
first_name: String::from("John"),
middle_name: Some(String::from("Richard")),
last_name: String::from("Doe"),
},
age: 30,
};
let mary = Person {
name: Name {
first_name: String::from("Mary"),
middle_name: None,
last_name: String::from("Poe"),
},
age: 30,
};
}
Our Person
struct now uses another struct for its name
field, our brand new Name
struct. Now because we want to make the middle_name
optional, we've wrapped a String
with Option
so its type is now Option<String>
. Valid values for this are either a value wrapped in Some
or None
as the examples illustrate.
Some
and None
are possible values for the Option
enum so really they're Option::Some
and Option::None
. We're able to get away with just Some
and None
because Rust includes them in scope by default in what is called its prelude.
Okay, now that we understand what Option
is, let's get back to our HashMap
. When looking up a value from a HashMap
, if a value isn't found for a key, the get
function returns None
, otherwise a value wrapped in Some
.
use std::collections::HashMap;
fn main() {
let airport_codes = HashMap::from([
("PIE", "St. Petersburg"),
("LHR", "London"),
("BOM", "Mumbai"),
]);
println!("{:?}", airport_codes.get("PIE"));
println!("{:?}", airport_codes.get("ADD"));
}
You'll notice the snippet above prints:
Some("St. Petersburg")
None
We get a None
for our second call because our airport_codes
HashMap
doesn't include the key, "ADD".
So back to our refactor, let's use &str
instead of references to String
in our Person
struct to clean up the initialisation code:
use std::collections::HashMap;
struct Person<'a> {
name: &'a str,
location: &'a str,
}
fn main() {
let airport_codes = HashMap::from([
("PIE", "St. Petersburg"),
("LHR", "London"),
("BOM", "Mumbai"),
]);
let me = Person {
name: "Sid",
location: "LHR",
};
}
Notice how both the name
and location
fields have the same lifetime annotation. They were created in the same scope and hence have the same lifetime.
Finally, let's write a function to look up a city name from an airport code and use it for our greeting.
fn get_city(code: &str, airport_codes: &HashMap<&str, &str>) -> &str {
airport_codes
.get(code)
.expect("We don't know this location!")
}
Take a moment to read through the function above. It takes two arguments, a code
which is an &str
and an immutable reference to a HashMap
of type, <&str, &str>
since both the key and the value are &str
. The other interesting bit is that we call expect
on the Option that is returned by get
. expect
is function used to unwrap an Option
and returns the value unwrapped from a Some
and panics if the returned value is None
(with the message passed in as an argument to expect
).
You don't want to be using expect
in production unless there is a situation where your program must need stop running and panic if an Option
is None
. Ideally, we would be handling this case here and returning an error but we're keeping it simple to focus on the topic at hand.
All together, we now have:
use std::collections::HashMap;
struct Person<'a> {
name: &'a str,
location: &'a str,
}
fn get_city<'a>(code: &str, airport_codes: &HashMap<&str, &str>) -> &str {
airport_codes
.get(code)
.expect("We don't know this location!")
}
fn main() {
let airport_codes = HashMap::from([
("PIE", "St. Petersburg"),
("LHR", "London"),
("BOM", "Mumbai"),
]);
let me = Person {
name: "Sid",
location: "LHR",
};
println!(
"Welcome, {} from {}!",
me.name,
get_city(&me.location, &airport_codes)
);
}
This doesn't compile and throws the following:
error[E0106]: missing lifetime specifier
--> src/main.rs:8:65
|
8 | fn get_city(code: &str, airport_codes: &HashMap<&str, &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 `code` or one of `airport_codes`'s 3 lifetimes
help: consider introducing a named lifetime parameter
|
8 | fn get_city<'a>(code: &'a str, airport_codes: &'a HashMap<&str, &str>) -> &'a str {
| ++++ ++ ++ ++
Now, we've seen this error before and we could go ahead and do what the compiler says and that'll fix it.
Let's understand what fn get_city<'a>(code: &'a str, airport_codes: &'a HashMap<&str, &str>) -> &'a str
is:
- The function
get_city
takescode
which has a lifetime of'a
and airport_codes
which has a lifetime of'a
as well and- therefore the returned value will also have a lifetime of
'a
This happens to be correct here because both the passed in code
and the HashMap
do indeed have the same lifetime.
What if they didn't? What if the HashMap
was created earlier and lived longer whereas the Person
was just initialised on demand via an API call or something?
Then, we'd need two separate lifetime annotations.
fn get_city<'a, 'b>(code: &'a str, airport_codes: &'b HashMap<&str, &str>) -> &'b str {
airport_codes
.get(code)
.expect("We don't know this location!")
}
Here the return value has the same lifetime as the HashMap
which makes sense since that's where the value is coming from.
Outlives
Sometimes, it might be helpful to annotate relationships between certain lifetimes. We won't dive too deep into this just about now, but if we wanted to annotate the fact that we expect the HashMap
to "outlive" the passed in code, we'd write it with a where
clause:
fn get_city<'a, 'b>(code: &'a str, airport_codes: &'b HashMap<&str, &str>) -> &'b str
where
'b: 'a,
{
airport_codes
.get(code)
.expect("We don't know this location!")
}
This is representing the fact that 'b
outlives 'a
.
'static
Finally, a special lifetime annotation we should talk about is the 'static
lifetime. The 'static
means that the value has the lifetime of the running program itself and this is useful for constants or any values that we expect to be inlined in our binaries.
The most typical of these are variables declared with the static
keyword.
static VERSION: u8 = 1;
fn main() {
println!("v{}", VERSION);
}
Here VERSION
has the 'static
lifetime. You don't need to annotate it in this case because it is inferred but if it were used as a reference in a struct for instance, you could annotate it with the 'static
lifetime.
Remember: the 'static
lifetime outlives all other lifetimes because it is valid till the very end of the execution of your code.