I have read the concept of Rust lifetimes from many different resources and I'm still not able to figure out the intuition behind it. Consider this code:
#[derive(Debug)]
struct Example<'a> {
name: &'a str,
other_name: String,
}
fn main() {
let a: &'static str = "hello world";
println!("{}", a);
let b: Example = Example {
name: "Hello",
other_name: "World".into(),
};
println!("{:?}", b);
}
In my understanding, all things in Rust have a lifetime attached to them. In the line let a: &'static str = "hello world";
the variable a
is kept alive till the end of the program and the'static
is optional that is let a: &str = "hello world";
is also valid. My confusion is when we add custom lifetime to others such as struct Example
.
struct Example<'a> {
name: &'a str,
other_name: String,
}
Why do we need to attach a lifetime 'a
to it? What is a simplified and intuitive reasoning why we use lifetimes in Rust?
If you come from a background in garbage-collected languages (I see you're familiar with Python), the whole notion of lifetimes can feel very alien indeed. Even quite high level memory management concepts, such as the difference between stack and heap or when allocations and deallocations occur, can be difficult to grasp: because these are details that garbage collection hides from you (at a cost).
On the other hand, if you come from a language where you've had to manage memory yourself (like C++, for example), these are concepts with which you'll already be quite comfortable. My understanding is that Rust was primarily designed to compete in this "systems language" space whilst at the same time introducing strategies (like the borrow checker) to help avoid most memory management errors. Hence much of the documentation has been written with this audience in mind.
Before you can really understand "lifetimes", you should get to grips with the stack and the heap. Lifetime issues mostly arise with things that are (or might be) on the heap. Rust's ownership model is ultimately about associating each heap allocation with a specific stack item (perhaps via other intermediate heap items), such that when an item is popped from the stack all its associated heap allocations are freed.
Then ask yourself, whenever you have a reference to (i.e. the memory address of) something: will that something still be at the expected location in memory when the reference is used? One reason it might not be is because it was on the heap and its owning item has been popped from the stack, causing it to be dropped and its memory allocation freed; another might be because it has relocated to some other location in memory (for example, it's a Vec
that outgrew the space available in its previous allocation). Even mere mutations of the data can violate expectations about what’s held there, so they’re not allowed to happen from under you either.
The most important thing to grasp is that Rust's lifetimes have no impact whatsoever on this question: that is, they never affect how long something remains at a memory location—they are merely assertions that we make about the answers to that question, and the code won't compile if those assertions cannot be verified.
So, on to your example:
struct Example<'a>{
name: &'a str,
other_name: String,
}
Let's imagine we create an instance of this struct:
let foo = Example { name: "eggyal", other_name: String::from("Eka") };
Now suppose this foo
, a stack item, is at address 0x1000
. Delving into the implementation details for a typical 64-bit system, our memory might look something like this:
...
0x1000 foo.name#ptr = 0xabcd
0x1008 foo.name#len = 6
0x1010 foo.other_name#ptr = 0x5678
0x1018 foo.other_name#cap = 3
0x1020 foo.other_name#len = 3
...
0x5678 'E'
0x5679 'k'
0x567a 'a'
...
0xabcd 'e'
0xabce 'g'
0xabcf 'g'
0xabd0 'y'
0xabd1 'a'
0xabd2 'l'
...
Notice that, in foo
, name
is comprised of just a pointer and a length; whereas other_name
additionally has a capacity (which, in this example, is the same as its length). So what's the difference between &str
and String
? It's all about where responsibility for managing the associated memory allocation lies.
Since String
is an owned, heap-allocated string, foo.other_name
"owns" (is responsible for) its associated memory allocation—and hence, when foo
is dropped (e.g. because it is popped from the stack), Rust will ensure that those three bytes at address 0x5678
are freed and returned to the allocator (which ultimately happens through an implementation of std::ops::Drop
). Owning the allocation also means that String
can safely mutate the memory, relocate the value to another address, etc (provided that it's not currently on loan somewhere else).
By contrast, the memory allocation at 0xabcd
is not "owned" by foo.name
—we say that it's "borrowing" the allocation instead—but if foo.name
does not manage the allocation, how can it be sure that it contains what it's supposed to? Well, we programmers promise Rust that we will keep the contents valid for the duration of the borrow (which we give a name, in your case 'a
: &'a str
means that the memory holding the str
is being borrowed for lifetime 'a
), and the borrow checker ensures that we keep our promise.
But how long are we promising that lifetime 'a
will be? Well, it’ll be different for every instance of Example
: the period of time for which we promise "eggyal"
will be at 0xabcd
for foo
will in all likelihood be completely different to the period of time that we promise the name
value of some other instance will be at its address. So our lifetime 'a
is a parameter of Example
: this is why it’s declared as Example<'a>
.
Fortunately, we don’t ever need to explicitly define how long our lifetimes will actually last as the compiler knows everything's actual lifetime and merely needs to check that our assertions hold: in our example, the compiler determines that the provided value, "eggyal"
is a string literal and therefore of type &'static str
, so will be at its address 0xabcd
for the 'static
lifetime; thus in the case of foo
, 'a
is allowed to be "any lifetime up to and including 'static
"; in @Aloso's answer you can see an example with a different lifetime. Then wherever foo
is used, any lifetime assertions at that usage site can be checked and verified against this determined bound.
It takes some getting used to, but I find picturing the memory layout like this and asking myself "when does the memory allocation get freed?" helps me to understand the lifetimes in my code (sometimes I need to think about when the value might be relocated or mutated instead, but merely considering deallocation is often enough—and is usually a little bit easier to grasp).