Lifetimes are perhaps the hardest thing to understand when first approaching Rust. Today I’m going to create a lifetime error and demonstrate two strategies for fixing it. If you’re very new to rust, you may wish also to read the (beautifully titled) post Rust Lifetimes for the Unitialised first, as well as Strategies for Returning References in Rust.
To get started, we’re going to consider two Ruby implementations that are equivalent:
# first version | |
my_collection = SomeCollection.new | |
(1..10).each do |item| | |
value = "value number #{item + 1}" | |
my_collection.insert(value) | |
end | |
# second version | |
my_collection = SomeCollection.new | |
values = [] | |
(1..10).each do |item| | |
value = "value number #{item + 1}" | |
values.push(value) | |
end | |
values.each { |v| my_collection.insert(v) } |
At the end of both Ruby snippets, my_collection
is full of values like “value number 1”. Not a terribly interesting program, but it will help us illustrate lifetimes in Rust. Below is an attempt to write the same program in Rust, but it won’t compile. Today we’re going to talk about why it won’t compile, and discuss two changes that would make it compile. Here’s the Rust program:
struct SomeCollection<'a> { | |
strings: Vec<&'a str> | |
} | |
impl<'a> SomeCollection<'a> { | |
pub fn new() -> Self { | |
SomeCollection { strings: Vec::new() } | |
} | |
pub fn insert(&mut self, s: &'a str) { | |
self.strings.push(s); | |
} | |
} | |
fn main() { | |
let mut my_collection = SomeCollection::new(); | |
(0..10).for_each( |item| { | |
let value = format!("value number {}", item + 1); | |
my_collection.insert(&value); | |
}); | |
} |
If we try to build the above program, we get the following issue:
Compiling playground v0.0.1 (file:///playground)
error[E0597]: `value` does not live long enough
--> src/main.rs:19:31
|
19 | my_collection.insert(&value);
| ^^^^^ borrowed value does not live long enough
20 | });
| - `value` dropped here while still borrowed
21 | }
| - borrowed value needs to live until here
error: aborting due to previous error
error: Could not compile `playground`.
To learn more, run the command again with --verbose.
This seems like a very simple program, and it’s at first discouraging to see errors like this when learning Rust. But let’s dive into this error.
Rust tells us that the “borrowed value does not live long enough.” What this means is that my_collection
, which is owned by the main()
function, cannot have a reference to value
which is owned by the anonymous function passed to for_each()
on line 17, because value
goes out of scope on line 20, and my_collection
has to be valid to the end of main()
.
In Rust terms, “lifetime a” begins at line 16, when my_collection
is created. my_collection
stores a collection of borrowed strings of lifetime a. That’s what Vec<&'a str>
means in line 2: “A vector of borrowed strings of lifetime a”. The value created by &value
in line 19 has a different lifetime: it lives from line 19 to line 20. Call it “lifetime b”. Because lifetime b ends before lifetime a, we can’t use a value with lifetime b where the compiler expects a value of lifetime a. We can almost think of this as type checking: you can’t put an &'b str
in a vector of &'a str
unless b outlives a.
But if the strings don’t live long enough in Rust, how do the Ruby programs work?
The reason the Ruby program in example 1 above is able to work is that Ruby has a runtime garbage collector, which will notice that value
needs to live as long as my_collection
has a reference to it. Rather than having lifetimes, Ruby has a lifeguard that keeps track of how long different values need to be around and keeps them alive that long. This language feature prevents programmers from having to worry about lifetimes, but it also has a significant runtime cost. The same thing is true in most garbage-collected programming languages.
To avoid this cost, Rust doesn’t have a garbage collector. At runtime, &value
is little more than a pointer, and nothing is telling it to live as long as my_collection
, so it won’t. Fortunately, the Rust compiler is smart enough to detect that the reference doesn’t live long enough and complain at compile time, rather than letting you dereference a no-longer-valid pointer at runtime.
Now that we have a basic notion of Rust lifetimes, we can understand two basic ways of solving the borrow checker error that we had above.
The first solution to this problem is to put the strings somewhere that does live long enough:
struct SomeCollection<'a> { | |
strings: Vec<&'a str> | |
} | |
impl<'a> SomeCollection<'a> { | |
pub fn new() -> Self { | |
SomeCollection { strings: Vec::new() } | |
} | |
pub fn insert(&mut self, s: &'a str) { | |
self.strings.push(s); | |
} | |
pub fn print_all(&self) { | |
self.strings.iter().for_each(|item| println!("{}", item)); | |
} | |
} | |
fn main() { | |
let mut values : Vec<String> = Vec::new(); | |
let mut my_collection = SomeCollection::new(); | |
(0..10).for_each( |item| { | |
let value = format!("value number {}", item + 1); | |
values.push(value); | |
}); | |
values.iter().for_each(|value| my_collection.insert(&value)); | |
my_collection.print_all(); | |
} |
What this does is give your borrowed elements a home. Now, when you make a new string, you give ownership of it to the vector values
. Now objects who have references (who “borrow”) these values may do so as long as they go out of scope after the vector does.
There are two small gotchas with this approach.
The first is that the vector must own the strings. That is, it must have the type Vec<String>
not Vec<&str>
. (If you try to add values to a Vec<&str>
, you’ll encounter the same problem we were originally trying to solve: vector
will be asking to borrow something that goes out of scope before it does.)
The second gotcha I only caught because of an amazing compiler error message (I mean it – the rust compiler is super helpful):
note: values in a scope are dropped in the opposite order they are created
What that means is that, in this example, you have to declare values
above my_collection
; you’ll see the above error message if you switch lines 16 and 17 in the gist above.
That covers the first solution to the lifetime issue. In short, you have to put the values somewhere that they do live long enough.
The second solution is for the collections to take ownership of the values. Note that you can employ this solution only if you are able to change the type signature of the method that’s complaining about the lifetime.
struct SomeCollection { | |
strings: Vec<String> | |
} | |
impl SomeCollection { | |
pub fn new() -> Self { | |
SomeCollection { strings: Vec::new() } | |
} | |
pub fn insert(&mut self, s: String) { | |
self.strings.push(s); | |
} | |
pub fn print_all(&self) { | |
self.strings.iter().for_each(|item| println!("{}", item)); | |
} | |
} | |
fn main() { | |
let mut my_collection = SomeCollection::new(); | |
(0..10).for_each( |item| { | |
let value = format!("value number {}", item + 1); | |
my_collection.insert(value); | |
}); | |
my_collection.print_all(); | |
} |
The solution here is not trying to use borrows at all. Because my_collection
is a collection of strings, and not of borrowed strings, they will live as long as it does.
The lesson here is that we can’t arbitrarily return values from a narrower scope. In garbage collected languages, it’s perfectly normal to build up a value in a local variable and then return it to the calling scope. In Rust, we can do this to, but we have to return ownership of the local variable. If we try to return a reference (in Rust terminology, a “borrow”) of a variable from a smaller scope into a larger scope, Rust will remind us that we’re breaking the rules about lifetimes.
Till next time, happy learning!
-Will