ExamplesBy LevelBy TopicLearning Paths
539 Intermediate

Non-Lexical Lifetimes (NLL)

Functional Programming

Tutorial Video

Text description (accessibility)

This video demonstrates the "Non-Lexical Lifetimes (NLL)" functional Rust example. Difficulty level: Intermediate. Key concepts covered: Functional Programming. Before Rust 2018's Non-Lexical Lifetimes (NLL), the borrow checker used lexical scopes to determine when borrows ended — a borrow lasted until the end of the enclosing block, even if the borrowed value was never used after its last access point. Key difference from OCaml: 1. **Scope vs use**: Pre

Tutorial

The Problem

Before Rust 2018's Non-Lexical Lifetimes (NLL), the borrow checker used lexical scopes to determine when borrows ended — a borrow lasted until the end of the enclosing block, even if the borrowed value was never used after its last access point. This caused many correct programs to be rejected: you could not borrow from a Vec, compute something, then push to the Vec in the same block, even though the first borrow logically ended before the push. NLL (stabilized in Rust 2018) makes borrows end at their last use, not at the end of their enclosing scope.

🎯 Learning Outcomes

  • • What NLL changed: borrows end at last use, not end of block
  • • How let first = v[0]; v.push(6); now compiles with NLL (borrow ends after v[0])
  • • How NLL enables conditional borrows and mutation in the same function
  • • How nll_match demonstrates split borrows with pattern matching
  • • Where NLL matters most: iterative algorithms, in-place mutations, parser loops
  • Code Example

    pub fn nll_basic() -> Vec<i32> {
        let mut v = vec![1, 2, 3];
        let first = v[0];  // borrow ends here (NLL)
        v.push(6);         // OK: borrow already ended
        v
    }
    
    // Pre-NLL: error! borrow lasted until end of block

    Key Differences

  • Scope vs use: Pre-NLL Rust used lexical scope boundaries; post-NLL Rust uses last-use points; OCaml has no borrow scope concept at all.
  • Conditional borrows: NLL makes Rust's borrow checker understand that borrows in one branch don't affect other branches; OCaml allows unrestricted mutation in all branches.
  • Ergonomics impact: NLL significantly reduced false rejections from the Rust borrow checker, making common patterns like read-then-mutate work without workarounds.
  • Polonius: NLL is followed by Polonius (an even more precise borrow checker) that will handle additional cases NLL cannot, such as borrowing in one loop iteration and releasing in another.
  • OCaml Approach

    OCaml has no borrow checker — all mutation is safe through GC-managed references. The equivalent patterns work without restriction:

    let nll_basic () =
      let v = ref [1; 2; 3; 4; 5] in
      let first = List.hd !v in
      v := !v @ [6];
      (first, !v)
    

    There is no concept of a borrow ending — the GC handles everything.

    Full Source

    #![allow(clippy::all)]
    //! Non-Lexical Lifetimes (NLL)
    //!
    //! Modern borrow checker: borrows end at last use, not end of block.
    
    /// NLL allows mutation after last borrow use.
    pub fn nll_basic() -> Vec<i32> {
        let mut v = vec![1, 2, 3, 4, 5];
        let first = v[0]; // borrow ends after this line
        v.push(6); // OK with NLL
        assert_eq!(first, 1);
        v
    }
    
    /// NLL enables conditional borrows.
    pub fn nll_conditional(data: &mut Vec<i32>, add: bool) {
        let first = data.first().copied();
        if add {
            data.push(42); // OK: first borrow ended
        }
        if let Some(f) = first {
            println!("First was: {}", f);
        }
    }
    
    /// NLL with match arms.
    pub fn nll_match(opt: &mut Option<String>) -> Option<&str> {
        match opt {
            Some(s) => Some(s.as_str()),
            None => None,
        }
    }
    
    #[cfg(test)]
    mod tests {
        use super::*;
    
        #[test]
        fn test_nll_basic() {
            let v = nll_basic();
            assert_eq!(v, vec![1, 2, 3, 4, 5, 6]);
        }
    
        #[test]
        fn test_nll_conditional() {
            let mut data = vec![1, 2, 3];
            nll_conditional(&mut data, true);
            assert_eq!(data.len(), 4);
        }
    
        #[test]
        fn test_nll_match() {
            let mut opt = Some(String::from("hello"));
            let result = nll_match(&mut opt);
            assert_eq!(result, Some("hello"));
        }
    }
    ✓ Tests Rust test suite
    #[cfg(test)]
    mod tests {
        use super::*;
    
        #[test]
        fn test_nll_basic() {
            let v = nll_basic();
            assert_eq!(v, vec![1, 2, 3, 4, 5, 6]);
        }
    
        #[test]
        fn test_nll_conditional() {
            let mut data = vec![1, 2, 3];
            nll_conditional(&mut data, true);
            assert_eq!(data.len(), 4);
        }
    
        #[test]
        fn test_nll_match() {
            let mut opt = Some(String::from("hello"));
            let result = nll_match(&mut opt);
            assert_eq!(result, Some("hello"));
        }
    }

    Deep Comparison

    OCaml vs Rust: Non-Lexical Lifetimes

    OCaml

    (* No concept of borrows — GC manages memory *)
    let example () =
      let v = [1; 2; 3] in
      let first = List.hd v in
      (* No restrictions on v after "borrowing" *)
      first
    

    Rust (NLL - Rust 2018+)

    pub fn nll_basic() -> Vec<i32> {
        let mut v = vec![1, 2, 3];
        let first = v[0];  // borrow ends here (NLL)
        v.push(6);         // OK: borrow already ended
        v
    }
    
    // Pre-NLL: error! borrow lasted until end of block
    

    Key Differences

  • OCaml: No borrow tracking, GC handles all
  • Rust NLL: Borrows end at last use, not scope end
  • Rust: Enables more natural code patterns
  • Rust: Conditional borrows work correctly
  • Both: Prevent use-after-free, different mechanisms
  • Exercises

  • Pre-NLL workaround: Write the same logic as nll_basic using the pre-NLL workaround (explicit scope with {}) and verify both versions compile correctly.
  • Loop borrow: Write a loop that reads the minimum element of a Vec<i32>, removes it (using retain), and appends its square — demonstrate NLL allows this without explicit scoping.
  • Match arms with NLL: Implement fn first_or_default<'a>(v: &'a Vec<String>, default: &'a str) -> &'a str that returns the first element or default, using a match expression on v.first().
  • Open Source Repos