home  >  software  >  rusts-complexity  >  lifetimes

Exploring Rust’s Complexity

Lifetimes

Lifetimes are a way for rust to make sure that we do not get null pointer references (the billion dollar mistake). It makes sure that our references do not go out of scope too early and that we are left with invalid references.

Consider the following code:

let r;

{
    let x = 5;
    r = &x; // reference to x
} // x goes out of scope, r does not

println!("r: {}", r);

When we try to run it, we get the following error:

error[E0597]: `x` does not live long enough
  --> src/main.rs:7:13
   |
7  |         r = &x;
   |             ^^ borrowed value does not live long enough
8  |     }
   |     - `x` dropped here while still borrowed
9  | 
10 |     println!("r: {}", r);
   |                       - borrow later used here

For more information about this error, try `rustc --explain E0597`.
error: could not compile `rust` due to previous error

Note the line “borrowed value does not live long enough”. Since the reference to x goes out of scope after the block is closed with ”}“.

So if you have a function, that takes 2 or more parameters, you also have to make sure, that the parameters share the longest lifetime at minimum. For example, the following code will not compile, because there is no guarantee that one of the parameters will go have gone out of scope already when the function is called.

fn f<'a, 'b>(s: &'a str, t: &'b str) -> &'a str {
    if s.len() > 5 { s } else { t }
}

The correct version of the code above is this:

fn f<'a>(s: &'a str, t: &'a str) -> &'a str {
    if s.len() > 5 { s } else { t }
}

This makes sure, that all parameters, and the value returned by the function, live equally long.

This concept applies to all references in rust. This can get a little more tricky when we deal with structs. Consider the following code:

struct S {
    first: &i32,
    last: &i32,
}

This will not compile, because we can not guarantee that the struct will live as long as its properties. And since we are all about that memory safety, we should write the following code:

struct S<'a> {
    first: &'a i32,
    last: &'a i32,
}

But now, we have another tricky situation:

let x = 3;
let r;
{
    let y = 4;
    let point = Point { x: &x, y: &y };
    r = point.x
}

This is tricky, because &x and &y have different lifetimes. But since we denoted both of them with lifetime ‘a in the struct, the compiler assumes that they live equally long. Since they do not, the compiler will shout at us. But this is an easy fix, so we write:

struct S<'a, 'b> {
    first: &'a i32,
    last: &'b i32,
}

That is it for lifetimes. I think it is especially confusing when coming to rust, because it is a very novel feature, that I have not seen adressed so explicitly in any other language.

Accidental Or Unavoidable?

The concept of lifetimes of references is not avoidable, so it is not accidental. It is an inherent problem that must be dealt with, because of how memory works.

But what I do not understand, is why Rust has to be given these annotations explicitly. Let us look at the struct example again:

struct Point<'a> {
    x: &'a i32,
    y: &'a i32,
}

fn main() {
    let x = 3;
    let r;

    {
        let y = 5;
        let p = Point { x: &x, y: &y };
        r = p.x;
    }

    println!("r: {}", r);
}

This will give us the following error:

error[E0597]: `y` does not live long enough
  --> src/main.rs:13:35
   |
13 |         let p = Point { x: &x, y: &y };
   |                                   ^^ borrowed value does not live long enough
14 |         r = p.x;
15 |     }
   |     - `y` dropped here while still borrowed
16 | 
17 |     println!("r: {}", r);
   |                       - borrow later used here

For more information about this error, try `rustc --explain E0597`.
error: could not compile `rust` due to previous error

So we can adjust the lifetimes, like this:

struct Point<'a, 'b> {
    x: &'a i32,
    y: &'b i32,
}

fn main() {
    let x = 3;
    let r;

    {
        let y = 5;
        let p = Point { x: &x, y: &y };
        r = p.x;
    }

    println!("r: {}", r);
}

But I feel like the compiler already understood that there are different lifetimes at play, and which reference needs a different lifetime. If you read the documentation at https://doc.rust-lang.org/book/ch10-03-lifetime-syntax.html we see the following diagram for explanation:

{
    let r;                // ---------+-- 'a
                          //          |
    {                     //          |
        let x = 5;        // -+-- 'b  |
        r = &x;           //  |       |
    }                     // -+       |
                          //          |
    println!("r: {}", r); //          |
}                         // ---------+

So the borrow checker explores the different lifetimes, and can tell which lifetime would be necessary at minimum for any given reference. But then why does the borrow checker need us? Sure, being explicit is good sometimes, but in this case, I do not think it improves code readability or understanding.

I am sure this is a problem that smarter people than me could explain better and I could very well be missing or missunderstanding something. But this is just my intuition.