Rust borrow checker and lifetimes
- Last updated
- Prerequisites:
- Rust ownership basics
- Understanding of references and generics
- rust
- borrow-checker
- lifetimes
- ownership
- memory-safety
- miri
Read at your depth
The practical view
Every value in Rust has an owner; when the owner goes out of scope, the value is dropped. References let code use a value without taking ownership: &T (shared borrow, many readers) and &mut T (exclusive borrow, one writer). The borrow checker enforces: you cannot have a &mut T while any &T is live, and references must not outlive their referent. Lifetimes (usually elided: fn first<'a>(x: &'a str, y: &'a str) -> &'a str) tell the compiler how long a reference is valid — 'a is the intersection of the inputs' lifetimes. The compiler proves these rules at compile time, so most memory bugs become compile errors.
Legacy vs modern
Invalidating a borrow vs the borrow-checked version
The unsafe/mutable-alias pattern corrupts memory; Rust rejects it at compile time, forcing the shared-ownership design that is safe.
let mut v = vec![1, 2, 3];
let first = &v[0]; // shared borrow
v.push(4); // ERROR: cannot borrow v as mutable
println!("{first}"); // while `first` is still livelet len = v.len();
let first = v[0]; // copy the value out, no borrow held
v.push(4); // fine: no outstanding borrow
println!("{first}");Interview gotchas
Context
This checks precise knowledge of the borrow checker's evolution — the interviewer wants region-based analysis, not just 'NLL made the compiler smarter'.
The mechanical answer
Under lexical lifetimes (pre-2018), a borrow lasted until the end of the enclosing block, so a temporary reference 'poisoned' the whole scope even if its last use was earlier. NLL computes the actual live range: the set of program points where the borrow is used (computed on MIR), so the borrow ends at its last use, not at the block's end. This made patterns like `let x = &v[0]; v.push(1);` legal when x is never read after the push. What NLL still rejects: any use of the reference after the conflicting mutation, and any borrow outliving its referent's region — the fundamental aliasing rules remain. Two-phase borrows (allowing the first phase of a method call to be a shared borrow) are a separate refinement.
Trap
The trap answer is 'NLL lets you mutate while borrowing'. NLL only shortens borrows to their last use; if the reference is actually read after the mutation, the error remains. Another trap: 'NLL is a runtime feature' — it is purely a compile-time region analysis on MIR. Candidates who name RFC 2094, last-use analysis, and the persisting restrictions show they know the checker rather than a release note.
Context
This tests whether the candidate understands that the ownership model forbids cyclic references and what the standard escape hatches are.
The mechanical answer
A self-referential struct (e.g., a node holding a &'a Node to its parent) requires the reference to point into the same allocation that owns it — but moving the struct invalidates the pointer (Rust moves are byte copies, no pointer fixup), and the borrow checker cannot verify the invariant because the region of the referent is the struct's own memory. The standard patterns: indices into a shared arena (Vec of nodes, each storing parent: Option<usize>), Rc/Weak for shared ownership with non-owning back-references (Weak breaks cycles so refcounts can drop), or RefCell<Option<Rc<Node>>> for interior mutability. Each trades a runtime cost (refcount, borrow check) for the compile-time safety the direct pattern cannot provide.
Trap
The naive answer is 'use Box'. Box gives heap allocation but still strict ownership — a Box<Node> with a self-pointer has the same move-invalidation problem. The sharp answer names the actual constraints: moves don't fix up pointers (unlike GC languages), and the borrow checker requires the referent's region to outlive the reference, which a self-cycle violates. Mentioning Weak to break cycles (the 'parents in a tree' interview favorite) demonstrates practical Rust fluency.