Phillip England
when are we?
12/18/2024
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.
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?
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.
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 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}
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?
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.
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.
Here are a few common scenarios where lifetimes are found:
You may end up with a struct which has lifetime annotations like so:
1struct Car<'a> {
2 model: &'a str
3}
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}
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?
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.
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.