What are non-lexical lifetimes?
Asked Answered
M

1

154

Rust has an RFC related to non-lexical lifetimes which has been approved to be implemented in the language for a long time. Recently, Rust's support of this feature has improved a lot and is considered complete.

My question is: what exactly is a non-lexical lifetime?

Marcionism answered 9/5, 2018 at 10:45 Comment(0)
P
241

It's easiest to understand what non-lexical lifetimes are by understanding what lexical lifetimes are. In versions of Rust before non-lexical lifetimes are present, this code will fail:

fn main() {
    let mut scores = vec![1, 2, 3];
    let score = &scores[0];
    scores.push(4);
}

The Rust compiler sees that scores is borrowed by the score variable, so it disallows further mutation of scores:

error[E0502]: cannot borrow `scores` as mutable because it is also borrowed as immutable
 --> src/main.rs:4:5
  |
3 |     let score = &scores[0];
  |                  ------ immutable borrow occurs here
4 |     scores.push(4);
  |     ^^^^^^ mutable borrow occurs here
5 | }
  | - immutable borrow ends here

However, a human can trivially see that this example is overly conservative: score is never used! The problem is that the borrow of scores by score is lexical — it lasts until the end of the block in which it is contained:

fn main() {
    let mut scores = vec![1, 2, 3]; //
    let score = &scores[0];         //
    scores.push(4);                 //
                                    // <-- score stops borrowing here
}

Non-lexical lifetimes fix this by enhancing the compiler to understand this level of detail. The compiler can now more accurately tell when a borrow is needed and this code will compile.

A wonderful thing about non-lexical lifetimes is that once enabled, no one will ever think about them. It will simply become "what Rust does" and things will (hopefully) just work.

Why were lexical lifetimes allowed?

Rust is intended to only allow known-safe programs to compile. However, it is impossible to exactly allow only safe programs and reject unsafe ones. To that end, Rust errs on the side of being conservative: some safe programs are rejected. Lexical lifetimes are one example of this.

Lexical lifetimes were much easier to implement in the compiler because knowledge of blocks is "trivial", while knowledge of the data flow is less so. The compiler needed to be rewritten to introduce and make use of a "mid-level intermediate representation" (MIR). Then the borrow checker (a.k.a. "borrowck") had to be rewritten to use MIR instead of the abstract syntax tree (AST). Then the rules of the borrow checker had to be refined to be finer-grained.

Lexical lifetimes don't always get in the way of the programmer, and there are many ways of working around lexical lifetimes when they do, even if they are annoying. In many cases, this involved adding extra curly braces or a boolean value. This allowed Rust 1.0 to ship and be useful for many years before non-lexical lifetimes were implemented.

Interestingly, certain good patterns were developed because of lexical lifetimes. The prime example to me is the entry pattern. This code fails before non-lexical lifetimes and compiles with it:

fn example(mut map: HashMap<i32, i32>, key: i32) {
    match map.get_mut(&key) {
        Some(value) => *value += 1,
        None => {
            map.insert(key, 1);
        }
    }
}

However, this code is inefficient because it calculates the hash of the key twice. The solution that was created because of lexical lifetimes is shorter and more efficient:

fn example(mut map: HashMap<i32, i32>, key: i32) {
    *map.entry(key).or_insert(0) += 1;
}

The name "non-lexical lifetimes" doesn't sound right to me

The lifetime of a value is the time span during which the value stays at a specific memory address (see Why can't I store a value and a reference to that value in the same struct? for a longer explanation). The feature known as non-lexical lifetimes doesn't change the lifetimes of any values, so it cannot make lifetimes non-lexical. It only makes the tracking and checking of borrows of those values more precise.

A more accurate name for the feature might be "non-lexical borrows". Some compiler developers refer to the underlying "MIR-based borrowck".

Non-lexical lifetimes were never intended to be a "user-facing" feature, per se. They've mostly grown large in our minds because of the little papercuts we get from their absence. Their name was mostly intended for internal development purposes and changing it for marketing purposes was never a priority.

Yeah, but how do I use it?

In Rust 1.31 (released on 2018-12-06), you need to opt-in to the Rust 2018 edition in your Cargo.toml:

[package]
name = "foo"
version = "0.0.1"
authors = ["An Devloper <[email protected]>"]
edition = "2018"

As of Rust 1.36, the Rust 2015 edition also enables non-lexical lifetimes.

The current implementation of non-lexical lifetimes is in a "migration mode". If the NLL borrow checker passes, compilation continues. If it doesn't, the previous borrow checker is invoked. If the old borrow checker allows the code, a warning is printed, informing you that your code is likely to break in a future version of Rust and should be updated.

In nightly versions of Rust, you can opt-in to the enforced breakage via a feature flag:

#![feature(nll)]

You can even opt-in to the experimental version of NLL by using the compiler flag -Z polonius.

A sample of real problems solved by non-lexical lifetimes

Patinated answered 9/5, 2018 at 12:33 Comment(7)
I think it would be worth emphasizing that, perhaps counter-intuitively, Non-Lexical Lifetimes are not about the Lifetime of variables, but about the Lifetime of Borrows. Or, otherwise said, Non-Lexical Lifetimes is about decorrelating the lifetimes of variables from that of borrows... unless I am wrong? (but I don't think that NLL changes when a destructor is executed)Piper
"Interestingly, certain good patterns were developed because of lexical lifetimes"—I suppose, then, there's a risk that the existence of NLL may make future good patterns that much harder to identify?Fulkerson
@Fulkerson it's certainly a possibility. Designing within a set of constraints (even if arbitrary!) can lead to new, interesting designs. Without those constraints, we might fall back on our existing knowledge and patterns and never learn or explore to find something new. That being said, presumably someone would think "oh, the hash is being calculated twice, I can fix that" and the API would be created, but it may be harder for users to find the API in the first place. I hope that tools like clippy help those folk.Patinated
Perhaps a better refinement on the naming would be, sub-lexical lifetimes, considering that it specifically shortens the lifetime estimates of binds. Additionally, address stickiness, as mentioned, does not have anything to do with lifetimes since appending to a vector (push) can force reallocation and therefore a change of its address without loss of reference by it binding. To this newbie, it seems the lifetime system is all about the bind: owner, borrower, and observer (otherwise know as share). Come to think of it, the observer pattern in Rust could be interestingly simplistic.Legalese
I don't understand why the first example didn't compile back in the day when there were no non lexical lifetimes. Which borrow doesn't allow which variable to be borrowed as mutable?Paez
https://mcmap.net/q/159808/-returning-a-reference-from-a-hashmap-or-vec-causes-a-borrow-to-last-beyond-the-scope-it-39-s-in/155423 (The first of the supposed problems solved by NLL) still fails to compile in Rust 2021 1.67. This will be fixed by the polonius borrowchk.Ikhnaton
@Paez the score variable held an immutable reference to the vector, while scores.push required a mutable reference. You aren't allowed to have both kinds of reference at the same time.Patinated

© 2022 - 2024 — McMap. All rights reserved.