What are non-lexical lifetimes? An introduction to Rust's major improvement
\n\nAnyone who has written Rust for even a couple of weeks has encountered a situation: the code is completely logical and safe, but the compiler (or rather, its strict borrow checker) refuses to compile it. You look at the error and think: "But I'm not using that reference anymore! Why is it complaining?"
\n\nBefore Rust version 1.31 (2018 edition), the answer was simple: "Because the lifetime of a variable is determined by its scope — lexically." This is lexical lifetimes. Starting with Rust 2018, the compiler became smarter. It gained non-lexical lifetimes (NLL).
\n\nIn this article, we'll figure out what this beast is, why its introduction was a revolution for Rust developers, and how it makes code cleaner and more intuitive. We won't delve into the depths of type theory but will focus on practical examples.
\n\nThe problem with lexical lifetimes: why the borrow checker was too strict
\n\nLet's recall how borrowing works in Rust. We have the rules:
\n- \n
- At any given time, there can be either one mutable reference (&mut T) or any number of immutable references (&T). \n
- References must always be valid (you cannot reference deleted data). \n
Before NLL, the compiler determined a reference's lifetime by its lexical scope. That is, from { to }, where the variable is declared. If a reference is created inside a block, it "lives" until the end of that block, even if you no longer use it.
Look at this classic example that did not compile in older versions of Rust:
\n\nfn main() {\n let mut data = vec![1, 2, 3];\n\n let x = &data[0]; // immutable reference\n\n data.push(4); // attempt at mutable borrowing\n\n println!("{}", x); // using the immutable reference\n}\n\nFrom a human perspective: we created a reference x, then modified the vector, then used x. But the vector data could have reallocated memory when calling push, making the reference x invalid. The compiler is right — this is dangerous.
But what if we use the reference before the modification? Here's the real pain:
\n\nfn main() {\n let mut name = String::from("Alice");\n\n let first_char = &name[0..1]; // immutable reference\n println!("First char: {}", first_char); // using it\n\n name.push_str(" Johnson"); // modifying the string\n println!("Full name: {}", name);\n}\n\nThis code is safe. We used the reference first_char, printed it, and then modified the string. But before Rust 2018, the compiler would give an error! Why? Because the lexical lifetime of the reference first_char lasted until the end of the main function, that is, until the closing curly brace. And a mutable borrowing (push_str) inside that interval is forbidden.
Developers had to use workarounds: introduce additional blocks {} to manually limit the reference's lifetime:
fn main() {\n let mut name = String::from("Alice");\n\n {\n let first_char = &name[0..1];\n println!("First char: {}", first_char);\n } // reference first_char dies here\n\n name.push_str(" Johnson");\n println!("Full name: {}", name);\n}\n\nThis worked, but it was inconvenient, cluttered the code with extra braces, and made reading difficult. Especially in complex functions with multiple borrowings.
\n\nNon-lexical lifetimes (NLL): how it works
\n\nThe idea of NLL is brilliant in its simplicity: a reference's lifetime is determined not by its scope, but by where it is actually used.