What are non-lexical lifetimes?

ghz 21days ago ⋅ 16 views

Non-Lexical Lifetimes (NLL) is a feature in Rust's ownership and borrowing system that significantly improves the language's ability to infer lifetimes, particularly in situations where lifetimes are complex or hard to track with the old lexical lifetime system.

In simple terms, NLL allows Rust's compiler to be smarter about when references are considered to be valid, and extends the scope of references to the last use, rather than the entire block of code. This makes lifetime checks more flexible and allows more code to compile without requiring explicit lifetime annotations.

What Were Lexical Lifetimes?

Before the introduction of NLL, Rust used a system called lexical lifetimes to track the lifetimes of references. This system was based purely on the structure of the program — the compiler would track lifetimes based on the code's control flow, considering references valid throughout the entire scope in which they were created, even if they were no longer needed.

The limitation of lexical lifetimes was that they could over-constrain certain code, causing references to appear invalid in some cases where they were actually fine. In particular, it led to issues with references that were valid for a shorter time span than the entire scope in which they were declared, but the compiler couldn't deduce that based on its static rules.

How Non-Lexical Lifetimes Work

Non-Lexical Lifetimes (NLL) improve on this by tracking lifetimes based on actual usage of references, rather than just the lexical (textual) scope. With NLL, the Rust compiler tracks when references start and stop being used, and allows references to be valid only for the period in which they are needed.

Key Points of Non-Lexical Lifetimes:

  1. Lifetimes Extend to Last Use:

    • A reference’s lifetime is no longer bound by the scope in which it was created. Instead, the lifetime is extended until the reference is no longer needed.
    • For example, a reference created in a loop may not need to be valid beyond the loop iteration, even if the variable it references exists longer than that. NLL allows this.
  2. Smarter Borrow Checking:

    • With NLL, the borrow checker is able to track where a reference is used and adjust the lifetime restrictions accordingly. The reference doesn't need to be valid for the entire scope; it just needs to be valid until the point where it is last used.
  3. Improved Lifetime Inference:

    • The compiler becomes much better at inferring lifetimes in certain cases, where the old system would have required explicit annotations or would have rejected valid code.
  4. Use Case Example: Consider the following Rust code using a lexical lifetime:

    fn example() {
        let s = String::from("hello");
        let r;
        {
            let t = &s;
            r = t; // borrow t, but it dies here
        }
        println!("{}", r); // Error: t was dropped before this point
    }
    

    Under lexical lifetimes, t is considered valid for the entire scope in which it was defined, which leads to the error because r tries to use a reference after t has been dropped.

    With non-lexical lifetimes, the borrow checker can realize that the reference to t is only needed within the inner block, so r can be valid until the point where r is actually used, not until the lexical end of the outer block. With NLL, this code would compile because the borrow is extended only until the actual last use.

The Benefits of Non-Lexical Lifetimes

  1. More Natural and Flexible:

    • NLL makes Rust’s borrowing and reference system feel more natural by recognizing when a reference is genuinely no longer needed and allowing lifetimes to be more flexible. It resolves cases that were previously difficult for the compiler to analyze with lexical lifetimes.
  2. Fewer Lifetime Annotations:

    • Rust’s borrow checker becomes better at inferring lifetimes, so fewer lifetime annotations are required from the programmer, and in many cases, explicit lifetime annotations are unnecessary.
  3. Reduced Borrowing Restrictions:

    • NLL resolves many situations where references could be safely used, but the old lexical lifetime system would reject the code, leading to fewer false positives (errors that are not real problems).

Example of NLL in Action

Here’s an example demonstrating the difference between lexical lifetimes and non-lexical lifetimes:

fn main() {
    let mut x = String::from("hello");

    // Using lexical lifetimes, the borrow would need to last the whole function scope:
    let y = &x;  // Lexical lifetime: borrow x for the whole function scope
    x.push_str(", world!");  // Error: cannot borrow x mutably while it is borrowed immutably
    println!("{}", y);

    // With NLL, this would work:
    {
        let y = &x;  // borrow x
        // do something with y
    }
    x.push_str(", world!");  // okay, because y's borrow ended after its use in the block
    println!("{}", x);
}

In the example above, with lexical lifetimes, the compiler would not allow x to be borrowed mutably after it was borrowed immutably. However, with non-lexical lifetimes, the borrow checker understands that the reference y only needs to exist during the inner block, and thus, the mutable borrow later is allowed.

Formal Rationale

Non-lexical lifetimes were introduced as part of the Rust 2018 edition to make the borrow checking more intuitive and less restrictive. The primary rationale for introducing NLL was to reduce the friction between Rust’s ownership system and the programmer. In many cases, the old lexical lifetime rules were overly restrictive, requiring the programmer to explicitly manage lifetimes even in cases where the compiler could trivially deduce the correct behavior.

The introduction of NLL allows for more flexible, intuitive, and ergonomic lifetime inference. It is particularly important in idiomatic Rust code where references often live for shorter periods than their scope would suggest.

Summary

Non-Lexical Lifetimes (NLL) are a feature that improves Rust's borrow checker by tracking lifetimes based on when references are actually used, rather than being tied to their lexical scope. This allows the compiler to be more flexible and to permit references that were previously rejected under the old lexical lifetime rules. NLL makes the language more ergonomic and reduces the need for explicit lifetime annotations, making it easier to write safe, efficient Rust code without the restrictions of the older system.