DEV Community

[Advanced Rust] 1.11. Lifetimes (Advanced) Pt.1 - Review, Borrow Checker, Generic Lifetimes

1.11.1. Review

In the beginner tutorial, we mentioned that every reference in Rust has a lifetime. A lifetime is the scope in which the reference remains valid, and in most cases it is implicit and inferred by the compiler. When you take a reference to a variable, the lifetime begins. When the variable is moved or goes out of scope, the lifetime ends. In other words, for a reference, a lifetime is the name of the code region in which it must remain valid. Lifetimes usually overlap with scopes, but not always.

1.11.2. Borrow Checker

Whenever a reference with some lifetime 'a is used, the borrow checker checks whether 'a is still alive. The process is:

  • Trace the path back to where 'a began - that is, where the reference was obtained.
  • From there, check whether there are conflicts along that path.
  • Ensure that the reference points to a value that can be accessed safely.

This example uses the rand crate. Add the following dependency to Cargo.toml:

[dependencies]
rand = "0.8"

Consider this example:

use rand::random;

fn main() {
    let mut x = Box::new(42);
    let r = &x;
    if random::<f32>() > 0.5 {
        *x = 84;
    } else {
        println!("{}", r);
    }
}
  • x is of type Box<i32>.
  • Declaring r as a reference to x means the reference’s lifetime begins on that line (line 5).
  • On line 7, the value of x is modified through dereferencing. That requires a mutable reference to x. At this point, the borrow checker looks for a mutable reference to x and checks whether its use conflicts with anything else. In this example there is no conflict, so the code is valid.

You may ask: line 7 is inside the scope of r. Since *x needs a mutable reference to x, shouldn’t having both the immutable reference r and the mutable reference *x in the same scope violate the borrowing rules and produce an error? In fact, Rust is smart enough to know that if the if branch is taken, the else branch cannot be taken. r is never used in the if branch at all, so using the mutable reference *x in the if branch is fine. In other words, the lifetime of r does not extend into the if branch. This is an example of how lifetimes do not always exactly match scopes.

Let’s look at another example:

fn main() {
    let mut x = Box::new(42);
    let mut z = &x;
    for i in 0..100 {
        println!("{}", z);
        x = Box::new(i);
        z = &x;
    }
    println!("{}", z);
}
  • x is of type Box<i32>.
  • z is a reference to x, so the lifetime begins on this line (line 4).
  • On line 6, z is printed inside the loop. Using z naturally triggers a borrow-checker check. There is no problem here, so the borrow checker does not report an error.
  • On line 7, x is reassigned.
  • On line 8, z is reassigned. Rust treats the newly assigned reference as a different reference, so line 8 effectively starts a new lifetime, and the original lifetime ends at line 7.
  • Each subsequent loop iteration starts a new lifetime at z = &x;. Therefore the borrow checker does not report an error.

Features of the Borrow Checker

The borrow checker is conservative: if it is not sure whether a borrow is valid, it rejects that borrow. Sometimes the borrow checker needs help understanding why a borrow is valid, which is one of the reasons Unsafe Rust exists.

1.11.3. Generic Lifetimes

Sometimes we need to store references inside our own types. Then we need to annotate those references with lifetimes so that the borrow checker can verify their validity. One example is returning a reference from a method where the returned reference lives longer than self. Rust lets you make a type generic over one or more lifetimes.

Two Reminders

If a type implements the Drop trait, then dropping the type counts as using the lifetimes or types that the type is generic over. If the type does not implement Drop, then dropping it does not count as using the lifetime, and the references inside the type can be ignored. For example, when an instance of some type is about to be dropped, the borrow checker checks whether it is still legal to use the lifetimes that the type is generic over, because the code in your drop function might use those references.

A type can be generic over multiple lifetimes, but usually there is no need to make the type signature more complex. You should use multiple lifetime parameters only when the type contains multiple references, and the returned reference should be tied only to one of those lifetimes.

Look at this example:

fn longest<'a>(x: &'a str, y: &'a str) -> &'a str {
    if x.len() > y.len() {
        x
    } else {
        y
    }
}

'a denotes a lifetime called a. x, y, and the return type all share this lifetime a, which means that x, y, and the return value all have the same lifetime.

Comments

No comments yet. Start the discussion.