Ownership and Lifetimes
And now we come to one of the things that differentiates Rust from other systems languages. From other languages in general, really. And that is ownership and the borrow checker.
As we went over earlier, Rust does not have a garbage collector. Instead, the compiler is tracking when memory should be allocated and deallocated, and ensuring that your references remain valid.
How it does that is by keeping track of the lifetime of variables as well as their ownership. To unpack those, in short:
- A value has one owner at a time, and ownership is used for tracking when memory is valid and when it is dropped.
- The lifetime of a variable is the time during which references to it are valid.
These two concepts are highly related. The lifetime of a reference is linked to the lifetime of its owner.
To make it concrete, let's look at an example of a function which explains why we need lifetimes. This code example will not compile:
#![allow(unused)] fn main() { { // create an outer scope // define a reference to a u32, but do not initialize it let outer_ref: &u32; { // create an inner scope // declare and initialize a u32 let inner_val: u32 = 10; // try to assign a reference to the inner val to our ref outer_ref = &inner_val; // <-- this line will fail to compile } // <-- inner_val goes out of scope here // <-- but the compiler needs outer_ref to be valid here! println!("outer_ref value: {}", outer_ref); } }
Here's another example, where we try to return a reference to a local variable inside a function.
#![allow(unused)] fn main() { fn naughty_function() -> &u32 { let x: u32 = 10; &x } }
Rust rightfully presents us with an error there if we try to compile it. Whoops!
This might be your first interaction with the borrow checker. It's a vital piece of Rust machinery which helps prevent major issues. Notably, you could get both of the previous examples to compile in languages like C or C++, leading to use-after-free errors. With Rust, you can't do that1.
Lifetimes
Every reference is a borrow, and each borrow has a lifetime. That lifetime spans from when the variable is created to when it is destroyed.
What the borrow checker does is it ensures that each reference's lifetime is wholly contained by the borrowed value's lifetime.
Lifetimes can be explicitly given names.
These are typically 'a
, 'b
, etc. but you can also use longer descriptive names.
#![allow(unused)] fn main() { fn example<'a>(x: &'a u32) { let y: &'a u32 = &x; } }
This example also introduces generics, which we will only use for lifetimes until we cover them in more depth.
But basically, the <'a>
is for generics, and here it's giving a generic lifetime.
This says that we have some lifetime, 'a
, and our parameter x
is a reference of that lifetime.
It doesn't say specifically how long that lifetime is, because we don't know anything about that lifetime until it's filled in as a parameter of the generic function.
The lifetime 'static
means "referred to data will live for the duration of the program".
This is often used for string constants:
#![allow(unused)] fn main() { let msg: &'static str = "hello, world!"; }
Anywhere where you write a type annotation for a reference, you can also include an explicit lifetime. We've seen one example of this above. You'll see it often for structs, enums, and other data structures when they contain references. You will not see it often for functions, because of lifetime elision.
Lifetime elision is when we're allowed to omit the explicit lifetimes and just let the compiler take a guess (based on a few rules). The above example would be better written using implicit lifetimes:
#![allow(unused)] fn main() { fn example(x: &u32) { let y: &u32 = &x; } }
We won't unpack the lifetime elision rules here, but in general you can omit explicit lifetime names in most places. If you can't, the compiler will tell you, and you can try adding them!
Ownership
Ownership is related to lifetimes, and we can see a few examples of it. Each variable has an owner, and this ownership can be moved to another place. This happens if you pass something by value: the new place receives the value and promises that it will deallocate it when it needs to. But since the new place owns it, the old place is not allowed to use it anymore!
Here's one example that shows a value moving to a new owner, one that new Rust programmers often encounter:
#![allow(unused)] fn main() { let xs = vec![1,2,3]; for x in xs { println!("{x}"); } println!("total len: {}", xs.len()); }
This looks totally reasonable, but there's a problem:
When we iterated over xs
, since we used the value of xs
, we moved it, and we don't own it anymore!
This example does not compile.
Instead, we need to use a reference to let the for loop borrow the Vec, and then we can use it in both places:
#![allow(unused)] fn main() { let xs = vec![1,2,3]; for x in &xs { println!("{x}"); } println!("total len: {}", xs.len()); }
Now it works, woohoo!
That's the basics of ownership. Just remember that when you pass a value around, it moves the value to the other place.
Well, that's not true. It moves it if it can't copy it.
There are some variables, the simplest ones, which "are Copy", which means they implement the Copy trait. If something is Copy, then it will get copied instead of moved, and you can keep using it.
As an example, we can use a primitive u32
twice:
#![allow(unused)] fn main() { let x: u32 = 10; println!("x is {x}"); println!("x*2 is {}", x*2); }
Are you a little confused? If so, you're not alone! This isn't very clear, and the same syntax doing two different things implicitly can be tricky. But rest assured that this is understandable, and the compiler is here to help. If we go back to the first Ownership example and try to compile it, here's part of the error message:
3 | let xs = vec![1,2,3];
| -- move occurs because `xs` has type `Vec<i32>`, which does not implement the `Copy` trait
4 |
5 | for x in xs {
| -- `xs` moved due to this implicit call to `.into_iter()`
...
In here, it clearly tells us that the Vec was moved, why it was moved, and later on in the error message (which I cut off) it gives a suggestion for how to fix it (using a reference). We'll talk more about ownership in the next section on closures.
The borrow checker places a lot of restrictions on you. As a result, it's common to hear people refer to "fighting with the borrow checker." I like to think of it as getting a code review from an eager and extremely pedantic partner. It can be quite frustrating the first few times you run into it, but with time it becomes an invaluable part of your workflow. It's catching legitimate issues, and you should be scared working in languages without it!