[rust lifetimes]
written 12/18/2024
when are we..?
Sandbox
The repo where I tested out these ideas is here.
The conversation I had with ChatGPT about the topic is here. I do a lot of my personal study with ChatGPT, so the conversation is worth a peek as the majority of this content is derived from my studies there.
Afterthoughts
As I was writing this article, I noticed generics are really important regarding lifetimes. I have a pretty solid understanding of generics already, but I think going back and doing a post about them would be good after this.
NOTE TO SELF: Did you write a post about generics in Rust?
It's About Time
I've been avoiding this topic for a bit because of all the concepts in Rust, lifetimes are the one I am most likely to just rely on compile-time errors to help me manage and correct.
In short, when I write lifetimes, I have no idea what I am doing.
The only thing I can remember is a quote from Tristram Oaten on his YouTube channel, No Boilerplate.
He said, "..lifetimes let us know when our data is."
So, let's checkout The Book and see what it has to say on lifetimes.
The Book on Lifetimes
Right off the bat, we read a lifetime is, "..a construct the compiler (or more specifically, its borrow checker) uses to ensure all borrows are valid."
Despite not feeling confident about lifetimes, I am pretty familiar with the borrow-checker. I used Rust pretty early on in my programming journey (which is why I've revisted the language 3 times now). Because of this, I've actually inherited some good coding practices regarding how I think about handling data within a program.
The book also points out that it can be easy to confuse lifetimes and scopes. This is because scopes and lifetimes are closely related.
The real thing I notice here is that it is just important to know when your data is valid in your Rust programs.
I think a pattern I came across when I first started Rust is the idea that you can pass a variable into a function, us it within, and then return it if you need it back.
The Book has a page on how to explicitly annotate lifetimes using some_var<'a>
Let me see what ChatGPT has to say about the topic.
ChatGPT on Lifetimes
ChatGPT makes it clear lifetimes are used to ensure references are valid for the shortest amount of time possible. They enable us to tell the compiler when a reference is no longer accessible.
Here is an example where we try to make use of a &x
when it is no longer available:
1fn dangling_reference() {
2 let r;
3 {
4 let x = 5;
5 r = &x;
6 }
7 // println!("{}", r); 💥 Dangling reference!
8}
Why Does This Matter?
Lifetimes become a practical problem in your code when you start passing references around.
Take the following function for example:
1fn longest<'a>(s1: &'a str, s2: &'a str) -> &'a str {
2 if s1.len() > s2.len() {
3 s1
4 } else {
5 s2
6 }
7}
In this function, we use -> &'a str
to state: "The returned reference will be valid for the duration of lifetime 'a"
What is the duration of lifetime 'a
you might ask?
The Shortest Lifetime
If a more than one reference is passed into a function, and the function returns a new reference, the new reference will mirror the shortest lifetime of the input references.
So, when we say state, (s1: &'a str, s2: &'a str) -> &'a str
, what we are really saying is:
"The lifetime of the return value will match s1 if s1 has the shortest lifetime, or it will match s2 if s2 has the shortest lifetime."
This means when we are in a scenario where a reference is being passed from one location to another, we need to be mindful of all the data points it comes into contact with.
If we pipe a reference into a function, and that function outputs another reference, our data is now "linked" from the compiler's perspective.
Every Reference Has a Lifetime
One thing to note is that all the references in a Rust program have a lifetime, even if it is not explicitly annotated. Sometimes, Rust can even infer the lifetime of a return value. These are called the Elision Rules.
In short, elision rules allow us to forgo explicitly writing out our lifetime annotations.
A good rule of thumb is if a function only has one reference passed into it, then the annotation can be excluded because the return value will always match that of the input reference.
I would take a peak at the Elision Rules though because I am not diving into all the minute details here.
Common Scenarios
Here are a few common scenarios where lifetimes are found:
Structs
You may end up with a struct which has lifetime annotations like so:
1struct Car<'a> {
2 model: &'a str
3}
Functions
As already discussed, functions may have lifetime annotations:
1fn longest<'a>(x: &'a str, y: &'a str) -> &'a str {
2 if x.len() > y.len() {
3 x
4 } else {
5 y
6 }
7}
In Trait Bounds
We can also use lifetimes when using trait bounds:
1fn print_with_lifetime<'a, T>(item: &'a T)
2where
3 T: std::fmt::Display + 'a,
4{
5 println!("{}", item);
6}
Of all the things I've listed so far, ^ that one looks the most archaic to me. I understand traits and their purpose, however, I need to brush up on actually using traits in real world scenarios.
NOTE TO SELF: Did you do a deep dive on trait bounds in Rust?
Lifetimes as Timelines
ChatGPT suggested thinking of lifetimes in terms of timelines. When approach a Rust program, we need to take special care to ensure we are being proactive about how we are thinking about our references.
Remember, each reference has it's own timeline, and if you borrow a reference, you need to make sure it lives long enough to be borrowed in the first place.
Conclusion
I think this first look at lifetimes is good, but I will definitely be back on this topic. All in all, lifetimes are really all about making sure that references are available for only the time they are needed and no more.
Lifetimes are Rusts way of avoiding common a pitfall like dangling references.
I have heard some complain about lifetimes "coloring" functions. Kind of similar to how when you mark a function async
in typescript, your whole codebase ends up being marked with async
.
I will be back on this one.