How I think about Rust Lifetimes

Eric Opines
8 min readApr 9, 2016

--

I was refactoring code a few weeks ago and I had that great moment when I start to understand a concept. Rust lifetimes finally started to click. And I had been strugg.uuuhhh.lllling with lifetimes. So that moment was especially sweet. I think I finally understand Rust lifetimes enough that I can write about them in my own words.

Before I get started, I want to point out the three sources of information I found most useful as I digested lifetimes.

  1. The Book (of course) — https://doc.rust-lang.org/book/lifetimes.html

I’ve probably read that section of the book 25 times. For unknown reasons my last review of that section made all the difference. Especially the Lifetime Elision section.

2. http://arthurtw.github.io/2014/11/30/rust-borrow-lifetimes.html

The Borrow Scope section is particularly useful.

3. http://www.charlesetc.com/rust/2015/10/29

This was dessert. It articulates many of the same ideas described by the first two pieces but it describes them in a different way. Sometimes that’s what I need to understand hard concepts.

Most of my current thoughts about Rust lifetimes are derived from those three sources plus a lot of time spent troubleshooting compilation errors. What follows is a mashup of everything I’ve read, everything I’ve thought, and the experience I’ve gained writing Rust.

The lifetime parameter is not for your use.

This may be the most important piece of the lifetime puzzle.

As a programmer I spend my days naming things so I can refer to them in a convenient way. I name my models (structs), the things my programs do (functions), and memory (bindings).

When I want to make code work against different models, I name the parameter I use to tell the compiler the model on which I want the code to work (generics). Once the parameter is named, I can assign a value to that name which conveys the information to the compiler. I control the value the parameter will take in the code’s final form.

The syntax for defining a lifetime is roughly the same syntax as that of defining a generic parameter.

For instance.

struct Warrior<WeaponOfChoice> {...}
struct Warrior<'timetolive> {...}

But lifetime and generic parameters aren’t used the same way and that is what caused problems in my Rust programming model (the one in my head).

For generics I name the type parameter so I can specify its value. That is, I specify the value of WeaponOfChoice and the compiler then writes a version of my code that makes it work with Warrior objects.

Because lifetimes are named the same way, syntactically, as generic parameters I viewed them working the same way as generics in my Rust programming model. But my Rust programming model was wrong with respect to lifetimes in one very important way.

I specify the final value for generic parameters.

The compiler specifies the final value of the lifetime parameters. (1)

I am not naming the lifetime parameters for my use. I’m naming them for use by the compiler. When the compiler analyzes my code it assigns a value to all of the lifetime parameters in use and then runs the borrow checker on the final code. In this respect it does the exact same thing it does for generics.

But what do I mean by “in use” and what value does the compiler assign the lifetime parameter?

The final value of a lifetime parameter is a scope.

To describe this concept I’ll use the following code.

struct Warrior<’a> {
name: &’a str,
}
fn main() { // Start of scope 1

let w1 = Warrior {
name: “Wolverine”
};

{ // Start of scope 2
let w2 = Warrior {
name: “Colossus”
};

println!(“{}”, w2.name);
} // End of scope 2

println!(“{}”, w1.name);
} // End of scope 1

In this example a lifetime parameter exists on the Warrior struct. The lifetime parameter specifies that the value assigned to the name member of Warrior instances must outlive the Warrior instances to which they were assigned.

When I create Warrior instances, making the lifetime parameters “in use”, the compiler assigns a value to the lifetime parameter of each instance just as I have assigned a value to the name member of each instance of a Warrior. And, just like the name member can have a different value for each instance of a Warrior, the lifetime parameter can have a different value for each instance of a Warrior.

The value assigned to a lifetime parameter is the value of the scope in which the Warrior instance is created. I don’t know how a scope is represented by the compiler but for the sake of ease I’ve assigned integer values to the scopes in my example.

The lifetime parameter of Warrior w1 is assigned the scope value 1. The lifetime parameter of Warrior w2 is assigned the scope value 2. Now the compiler has a way to reason about whether the value assigned to the name member of each Warrior instance outlives the Warrior instance to which it was assigned.

Which brings me to my next thought.

Lifetime parameters have nothing to do with run time execution. They are a tool used by the compiler, during compilation, and are not part of the final executable.

Lifetime checks are done by the borrow checker. Those checks have zero cost at run time. That is, the lifetime parameters only exist up to the final stages of compilation and assembly of the executable. By the time you run your program the borrow checker has already proved that your program will (probably) not suffer certain memory related errors. The final executable knows nothing about lifetimes. The only lifetime related construct that winds up in the final binary are bindings having ‘static lifetimes. Any data that has a ‘static lifetime gets written to the data section of the final executable. But again, the lifetime itself, ‘static, is gone by the time your program is compiled.

That’s not to say lifetimes don’t exist at run time. Every bit of memory that is allocated while your program is running has a lifetime. The point of the lifetime parameters and the borrow checker is to make sure that allocated memory is used correctly while it is allocated and that it gets freed at an appropriate time. But, again, lifetime checks are done at compile time, just as syntax errors or naming errors for example, and have no influence on run time performance.

With a few exceptions, the lifetime parameters should only be considered in the design you’re working on in the moment.

Once I got it through my head I’m not assigning values to the lifetime parameters, working with them became a little easier. It became clear that when I’m working on a particular component I need to concentrate on that component’s use of the lifetime parameters isolated from the rest of the program. This shrank the design surface in my head and made it easier to reason about lifetimes.

The most important change that resulted from this is that I’m now very, very conscious of how the component is going to use each and every piece of data. Now, every time I add a member to a struct, or a parameter to a function, or a return value I start with borrow semantics and work through that piece of memory’s usage, making changes to the lifetime parameters only when absolutely necessary. Designing components and their lifetimes this way has shown that most memory can and should be borrowed and most component’s only have a single lifetime parameter. If I wind up with more than one lifetime parameter I’ve found that something’s usually wrong with my design. That’s not to say my experience is normal, it’s just my experience.

This point seems so obvious now but I’m used to thinking in terms of how I want to use a component and then building the component to match the API in my head. I no longer consider the lifetimes in that API. I’m not saying lifetimes should never be considered in the design of a larger program. Certainly there are use cases that prescribe the ownership rules to which a component must adhere. Working with external crates first and foremost. I’m saying that when I add a member, parameter, or return value to some component I now consider how that component needs to use the lifetime parameters rather than how the user of that data uses the lifetime parameters.

Which brings me to my final thought about lifetimes.

The lifetime elision rules are really, really important.

This is a somewhat redundant point given that The Book is an important first step in learning Rust but I think it’s worth pointing out. The lifetime system is hard to understand. It was harder for me to learn lifetimes than it was for me to learn Rust’s type system. I had to study the lifetime elision rules a lot and work with a lot of throw away code, code outside of a real project, in order to reach my current understanding of lifetimes. Once lifetimes started becoming more clear in my mental model working with throw away code specific to the elision rules made a lot of difference in reaching my current understanding.

There are two omissions in the lifetime elision examples that I’d like to point out. They were really the last pieces of the lifetime parameter puzzle.

fn some_fn(&self, s: &str, until: u32) -> &str; // elided
fn some_fn<'a>(&'a self, s: &'b str, until: u32) -> &'a str; // expanded
fn bad_fn<'a>(&self, s: &'a str, until: u32) -> &'a str; // some elision
fn bad_fn<'a>(&'b self, s: &'a str, until: u32) -> &'a str; // elision

UPDATE: Corrected the lifetime on s: in the first example. Thanks to https://twitter.com/GolDDranks for pointing out the error.

The first example was something I just needed to see. It’s covered by the rules but until I typed it out and played with it a bit, it wasn’t clear to me.

The second example is interesting and was crucial for my understanding of the lifetime elision rules. It points out that if you add an explicit lifetime parameter to a function, the compiler will add another, distinct lifetime parameter to &self.

However, that code won’t compile. At least the cases tried didn’t compile. You’ll likely get the following error.

error: cannot infer an appropriate lifetime for autoref due to conflicting requirements

I still don’t quite understand why, if I’ve explicitly assigned the lifetimes parameters, there would be an ambiguity. But I also realized there’s no reason I would want to use that code. It’s just a curiosity that cleared up how lifetime parameters are assigned by the compiler.

Conclusion

At this point in my Rust experience I can say that I made lifetimes harder than they have to be. Like anything in software development it’s better to break down the problem and solve the pieces one at a time. Lifetimes are no different. Understanding the rules and then making the design surface for each component smaller really simplified working with the lifetime system for me.

  1. I should point out that I’m playing with semantics a little. When I create an instance of any struct I’m implicitly setting the scope so I still control the lifetime parameter. But, as the title suggests, this post describes my mental model.

--

--

Eric Opines

I talk to computers. My aura is a series of 1’s and 0’s. I’m a technologist. I’m a renaissance man. I’m Mr. Average trying to do things that are hard.