Non-Lexical Lifetimes (NLL) & Control Flow Graph Analysis
Explore the evolution from AST lexical scopes to MIR Control Flow Graph analysis, live variable sets, and fixed-point region constraint solving.
In early versions of Rust, lifetimes were strictly tied to the lexical curly-brace blocks ({ ... }) of the Abstract Syntax Tree. This produced frustrating false-positive compiler rejections even when code was completely memory safe.
With the introduction of Non-Lexical Lifetimes (NLL), the borrow checker shifted from analyzing static syntax blocks to analyzing the Control Flow Graph (CFG) of Mid-Level Intermediate Representation (MIR).
Non-Lexical Lifetimes analyze variable liveness across basic blocks rather than lexical curly braces.
1. The Lexical Scope Limitation#
Under old lexical borrow checking, a reference remained active until the closing curly brace } of its enclosing block, even if it was never touched again:
1234567891011121314fn main() { let mut data = vec![1, 2, 3]; // Reference 'slice' borrows 'data' let slice = &data[0]; println!("First element: {}", slice); // Under old lexical rules: 'slice' lived until the end of main()! // Calling data.push(4) here was an ERROR! // Under Non-Lexical Lifetimes (NLL): // 'slice' is never used again after the println! statement. // The borrow checker ends the lifetime immediately right here: data.push(4); // Perfectly VALID in modern Rust! }
2. Formal Mechanics: CFG Liveness Sets#
Let represent the program control flow graph consisting of basic blocks and directed control flow edges .
A variable is defined as live at point if there exists an execution path starting at that leads to a future use of without intermediate reassignment or drop. The NLL region is defined as the set of program points where is live:
A borrow violation occurs if and only if an invalidating action (such as mutating data while an immutable borrow exists) occurs at a program point .
3. The 4-Stage MIR NLL Pipeline#
The modern rustc borrow checking engine operates through four rigorous stages:
Step 1: Construct MIR Basic Blocks
Lower High-Level Intermediate Representation (HIR) into Mid-Level Intermediate Representation (MIR), organizing statements into basic blocks with unambiguous single-entry, single-exit jump targets.
Step 2: Compute Variable Liveness
Traverse the CFG backwards to calculate which variables and temporaries are live before and after every individual MIR statement.
Step 3: Fixed-Point Region Constraint Solving
Generate inclusion constraints for all reference regions (e.g. 'a: 'b). Iterate the constraint solver until the region sets reach a mathematical fixed point.
Step 4: Emit Diagnostics on Conflict
Check whether any write instruction conflicts with an active region. If so, format precise diagnostics with visual source spans showing where the borrow originated and where it was invalidated.
4. Real-World Ergonomics: Hash Map Entry#
The power of NLL is readily apparent in common collection patterns like checking and inserting into a HashMap:
12345678910111213use std::collections::HashMap; fn process_cache(cache: &mut HashMap<String, u32>, key: String) -> u32 { // Under lexical lifetimes, borrowing from cache in 'get' // would lock the entire cache for the rest of the function! if let Some(&val) = cache.get(&key) { return val; } // With NLL, the borrow from cache.get() dies before this line! cache.insert(key, 100); 100 }
5. Concept Verification#
Test your understanding of Non-Lexical Lifetimes and Control Flow Graph analysis:
How does Non-Lexical Lifetimes (NLL) determine when a reference's lifetime ends?
On which compiler intermediate representation does the NLL borrow checker perform its analysis?