Lesson 6.8 — Places, mutability and references: l-values, exclusivity, borrow checking¶
Techniques: l-values and places — a judgment that classifies every expression as a value, a place or a mutable place, so that assignment and
&/&mutcan demand storage (C's lvalues, C++'s value categories [CLANG-ExprClass], Rust's places, Pebble's §9.1); exclusivity — no two overlapping accesses to the same storage unless both are reads (Swift's Law of Exclusivity [SE0176]; Pebble's per-call rule E0414; Rust's&mutuniqueness); borrow checking with non-lexical lifetimes — lifetimes as sets of control-flow points computed from liveness and outlives constraints, then loans in scope as a forward dataflow (Rust RFC 2094 [RFC2094], [RUSTC-Borrowck]), and its Datalog restatement Polonius [POLONIUS] · Pebble implements: places and categories (thevalue/place/mut-placecolumn of the typed dump), reference arguments (E0411–E0413) and the exclusivity rule E0414 — exercise E5; no borrow checker, because Pebble references cannot be stored or returned · Drills: none (quiz itemsloans-in-scope,nll-regionare computational) · Prerequisites: Lesson 6.3; Lesson 5.7 (dataflow on the AST) · Time: 3.5 hours
A type says what a value is; it does not say whether an expression denotes storage. x + 1 = 2 is well typed in every sense except that x + 1 is not somewhere you can store. And storage is where the hardest bugs live: two names for the same memory, one of them writing while the other assumes the contents are stable. This lesson takes the three steps compilers take, each stronger than the last: classify expressions as places (C's lvalues); forbid overlapping accesses to the same place during a call (Swift and Pebble); and track how long a reference lives so that no conflicting access happens while it may still be used (Rust's borrow checker). Pebble stops at the second step, deliberately: with references only as parameters, the second step already guarantees that a &mut parameter never aliases another.
1. Problem and motivation¶
L-values and places¶
The problem. Assignment, &, &mut, ++ and C++ reference binding need an expression that names storage; reading needs only a value. C calls the former lvalues ("left of =") and adds modifiable lvalues (not const, not an array); C++11 refined the split into lvalues, xvalues and prvalues so that move semantics could tell "storage about to die" from "storage in use" [CLANG-ExprClass]. Rust calls them place expressions and separates the question "is it a place?" from "may I mutate it?" — let k is a place but not a mutable one, and &mut k is E0596. Every checker needs this second judgment, next to the type, and a mistake in it is a soundness hole: storing through a temporary silently loses the write, and binding a mutable reference to a constant breaks immutability.
Exclusivity¶
The problem. If a function takes two &mut int (or inout) parameters and the caller passes the same variable twice, the callee's reasoning — "writing a does not change b" — is false. The optimizer's reasoning is false too: without aliasing guarantees it must reload b after every store to a. Swift made this a language rule in Swift 4, the Law of Exclusivity: "two accesses to the same variable are not allowed to overlap unless both accesses are reads" [SE0176], enforced statically for local variables and inout arguments and dynamically for class properties and globals. Pebble adopts the static half for calls: a root variable passed as &mut must not appear in any other argument (E0414). Rust's &mut has the same meaning, "the only way to reach this memory right now".
Borrow checking with non-lexical lifetimes¶
The problem. When references can be stored in variables, returned and kept in data structures, "during the call" is no longer the answer to how long an access lasts. Rust's original borrow checker made a reference's lifetime its lexical scope, which rejected obviously fine code: let first = &v[0]; println!("{}", first); v.push(4); — first is never used after the print, yet its scope covers the push. RFC 2094 redefined a lifetime as the set of control-flow points where a reference may still be used, computed from liveness, and then checks each access against the loans that are in scope there [RFC2094]; it shipped with Rust 2018. Polonius reformulates the same check in Datalog with loans flowing through origins instead of points flowing into regions, which accepts some programs NLL rejects [POLONIUS].
2. Definitions and algorithms¶
L-values and places¶
Definition 6.8.1 (Places and categories)
Each expression has a category \(\kappa \in \{\mathsf{value}, \mathsf{place}, \mathsf{mutplace}\}\) as well as a type; the judgment is \(\Gamma \vdash e : \tau \mathrel{@} \kappa\). In Pebble (pebble-spec §9.1), with \(\mathrm{mut}(x)\) true for var bindings and &mut parameters (§6.4):
and every other expression (literals, operators, calls, fields or elements of a non-place) has category \(\mathsf{value}\). The root of a place is the variable of its P-Var leaf. Category is inherited from the root: "mutability belongs to the binding".
Algorithm 6.8.2 (Checking reference arguments)
- Input: a call argument \(a\) and the parameter's mode \(m \in \{\text{by value}, \&, \&\mathsf{mut}\}\) and type \(\tau\).
- Output: nothing, or one of E0411, E0412, E0413, E0401 (pebble-spec §13.3).
- Precondition: names are resolved (Chapter 5); categories are computed by Definition 6.8.1.
- Postcondition: accepted with \(m = \&\mathsf{mut}\) ⇒ \(a\) is
&mut pwith \(p\) a mutable place of type exactly \(\tau\) (Theorem 6.8.9). - Invariant: the place operand is typed in synthesis mode — a reference never converts (no literal rule, no expected type).
function CheckArgument(a, m, τ):
if m = by value:
if a is &p or &mut p: report E0411 at a; return
check a against τ # Lesson 6.4
return
if a is not (&p or &mut p) with the same mode m: report E0411 at a; return
(σ, κ) ← Synth(p) # type and category
if κ = value: report E0412 at p; return
if m = &mut and κ ≠ mutplace: report E0413 at root(p), note at root's declaration; return
if σ ≠ τ and σ, τ are not <error>: report E0401 at p
Exclusivity¶
Definition 6.8.3 (Access; conflict; the Law of Exclusivity)
An access is a read or a write of a place over an interval of execution: instantaneous for x = e or reading x, and non-instantaneous for a &mut/inout argument (the whole call), a mutating method on a value (the whole method) or a live reference (its lifetime). Two accesses conflict if they overlap in time, touch overlapping storage, and at least one is a write. The Law of Exclusivity forbids conflicts [SE0176]. A checker is conservative if it rejects every program in which a conflict can happen; it may reject some that are conflict-free.
Algorithm 6.8.4 (Pebble's per-call exclusivity check, E0414)
- Input: a call \(f(a_1, \ldots, a_n)\) whose arguments passed Algorithm 6.8.2.
- Output: at most one E0414 per
&mutroot variable of the call, at the first other occurrence, with the note "'x' is passed as '&mut' here" (pebble-spec §9.3, §13.3). - Precondition: every name refers to a resolved binding; two different bindings never share storage in the caller's frame (they may if the caller's own reference parameters alias — excluded by induction, Theorem 6.8.10).
- Postcondition: accepted ⇒ the root of each
&mutargument occurs in no other argument (not as a place, not in an index, not by value). - Invariant:
reportedholds the roots already diagnosed in this call.
function CheckExclusivity(a1..an):
reported ← ∅
for i in 1..n where ai = &mut p:
x ← root(p)
if x ∈ reported: continue
for j in 1..n, j ≠ i, in order; within aj in source order:
if some name occurrence o in aj refers to x:
report E0414 at o, note at the root name of ai; reported ← reported ∪ {x}; break
The rule compares root variables, not places: swap(&mut a[0], &mut a[1]) is rejected although the elements are disjoint, as in Rust (§7), because the indices are not known statically in general.
Borrow checking with non-lexical lifetimes¶
Definition 6.8.5 (Points, loans, regions; liveness and outlives constraints [RFC2094])
A point is a location in the MIR control-flow graph (a statement of a basic block). A loan is created by each borrow r = &'a p or &'a mut p at point \(P\): the tuple \((\text{region } {'a}, \text{shared} \mid \text{mut}, p)\). A region (lifetime) is a set of points; 'a contains \(Q\) iff references of that lifetime may be used on entry to \(Q\). Regions are the least sets satisfying two kinds of constraints: liveness — if a variable whose type mentions 'r is live at \(Q\) (its current value may be used later), then \(Q \in {'r}\); and outlives at a point — \(({'a} : {'b}) \mathrel{@} P\), generated when a reference with lifetime 'a is copied into a location of lifetime 'b at \(P\) (including \(r = \&'a\,p\) assigning the loan's lifetime into \(r\)'s), requires 'a to include every point of 'b reachable from \(P\) without leaving 'b.
Algorithm 6.8.6 (Region inference by fixed point [RFC2094, Layer 1])
- Input: the CFG; the liveness of every variable at every point; the outlives constraints.
- Output: the least region for every region variable.
- Precondition: liveness is computed (backward dataflow, Chapter 14).
- Postcondition: the regions satisfy all constraints and are pointwise least.
- Invariant: each region only grows, and every point it contains is forced by some constraint.
Definition 6.8.7 (Loans in scope; relevant access)
A loan \(L\) is in scope at point \(Q\) if some path from its borrow reaches \(Q\) with every point on it in \(L\)'s region and no kill of \(L\) on it; an assignment lv = … kills the loans whose borrowed path has lv as a prefix (the old value is gone, so the borrow no longer protects anything). An access at \(Q\) is illegal if a loan in scope at \(Q\) is relevant to the accessed path (the same path, a prefix of it, or an extension of it, [RFC2094, Layer 5]) and the access and the loan are not both reads.
Algorithm 6.8.8 (Borrow check: loans in scope, then errors [RFC2094, Layer 5])
- Input: the MIR CFG; the loans and their regions from Algorithm 6.8.6.
- Output: the set of illegal accesses (E0499, E0502, E0506, …).
- Precondition: regions are computed.
- Postcondition: an access is reported iff a relevant, conflicting loan is in scope at its point (Definition 6.8.7).
- Invariant: \(\mathit{IN}[Q]\) ⊇ the loans in scope on entry to \(Q\) along the paths already propagated; at the fixed point it is exact.
# phase 1: forward gen/kill dataflow over bit sets of loans
IN[entry] ← ∅; IN[Q] ← ∅ for all other Q
repeat
for each point Q in reverse post-order:
IN[Q] ← ∪ OUT[pred] over predecessors
S ← { L ∈ IN[Q] | Q ∈ region(L) } # loans whose region ends are killed
if Q is a borrow creating L: S ← S ∪ {L}
if Q assigns lv: S ← S \ { L | lv is a prefix of path(L) }
OUT[Q] ← S
until no OUT changes
# phase 2: check each access against the loans in scope
for each point Q and each access (path p, read|write) at Q:
for each L ∈ IN[Q] with Q ∈ region(L) and path(L) relevant to p:
if not (access is read and L is shared): report at Q, citing L's borrow and a later use
rustc's Borrows dataflow [RUSTC-Borrowck] is phase 1, with the kill "loans whose region does not include \(Q\)" precomputed as kill_loans_out_of_scope_at_location.
3. Worked examples¶
L-values and places¶
The type table of ch06-typecheck (pebble-spec §14.4) prints the category after every expression's type. For var xs = [1, 2]; let k = 1; it prints index : int mut-place for xs[k] (root xs is a var), name k : int place for k, and binary + : int value for xs[k] + 1. So bump(&mut xs[k]) is accepted, bump(&mut k) is E0413 at k (a place, not mutable), and bump(&mut (xs[0] + 1)) is E0412 (a value). C++ makes the same distinctions under other names — the clang++ box in §7 shows x + 1 = 2 ("expression is not assignable") and inc(x + 1) ("expects an lvalue").
Exclusivity¶
Algorithm 6.8.4 on swap(&mut xs[0], &mut xs[1]): \(a_1 = \&\mathsf{mut}\ xs[0]\) has root xs; scanning \(a_2\) finds xs at its first occurrence — E0414 there, note at the xs of \(a_1\); reported = {xs}, so \(a_2\)'s own &mut root is skipped and the call gets one error, not two. On add(&mut xs[0], &xs[1]), a shared reference to the same root is also rejected: the callee's src would change when it writes dst. For the program excl.pbl
fn swap(a: &mut int, b: &mut int) { let t = a; a = b; b = t; }
fn add(dst: &mut int, src: &int) { dst += src; }
fn main() -> int {
var xs = [1, 2];
let k = 3;
swap(&mut xs[0], &mut xs[1]); // one root, two &mut arguments
add(&mut xs[0], &xs[1]); // &mut and & of the same root
add(&mut k, &xs[0]); // k is immutable
return xs[0];
}
the reference solution prints (output of pebblec --emit=typed-ast excl.pbl, standard error, complete):
excl.pbl:7:27: error[E0414]: 'xs' is passed as '&mut' and must not appear in another argument of the same call
7 | swap(&mut xs[0], &mut xs[1]); // one root, two &mut arguments
| ^~
excl.pbl:7:15: note: 'xs' is passed as '&mut' here
7 | swap(&mut xs[0], &mut xs[1]); // one root, two &mut arguments
| ^~
excl.pbl:8:22: error[E0414]: 'xs' is passed as '&mut' and must not appear in another argument of the same call
8 | add(&mut xs[0], &xs[1]); // &mut and & of the same root
| ^~
excl.pbl:8:14: note: 'xs' is passed as '&mut' here
8 | add(&mut xs[0], &xs[1]); // &mut and & of the same root
| ^~
excl.pbl:9:14: error[E0413]: cannot pass immutable 'k' as '&mut'
9 | add(&mut k, &xs[0]); // k is immutable
| ^
excl.pbl:6:9: note: 'k' declared here
6 | let k = 3;
| ^
Borrow checking with non-lexical lifetimes¶
Number the statements of the §7 program nll.rs by their line: 3 first = &v[0] (loan \(L_1\), shared, path v), 4 println!(first), 5 v.push(4) (a mutable borrow of v: a deep write), 6 second = &v[1] (loan \(L_2\)), 7 v.push(5) (deep write), 8 println!(second).
| Step | Result |
|---|---|
Liveness of first |
live at 4 only (its last use) |
Liveness of second |
live at 7, 8 |
| Algorithm 6.8.6 | 'first = {4}; \(({'L_1} : {'first}) \mathrel{@} 3\) gives 'L1 = {4}; 'second = {7, 8}, 'L2 = {7, 8} |
| Algorithm 6.8.8, phase 1 | IN[4] = {L1}; at 5, \(5 \notin\) 'L1: killed, IN[5] = ∅; IN[7] = {L2}, IN[8] = {L2} |
| Phase 2 at 5 | deep write of v, no loan in scope — accepted |
| Phase 2 at 7 | deep write of v; \(L_2\) is in scope, relevant (path v), shared vs write — error E0502 citing the borrow at 6 and the use at 8 |
A lexical checker would make 'L1 the whole scope of first (lines 3–9) and reject line 5 as well. rustc's message (§7) names exactly the three points of the table's last row.
4. Invariants and correctness¶
L-values and places¶
Theorem 6.8.9 (Accepted &mut arguments denote mutable storage of the parameter type)
If \(\Gamma \vdash p : \sigma \mathrel{@} \kappa\) by Definition 6.8.1 and Algorithm 6.8.2 accepts &mut p for a parameter &mut τ, then \(p\) denotes a storage location of type \(\tau\) that belongs to a var binding or to storage the caller received as &mut; so every write the callee makes through the parameter writes storage that the program is allowed to modify.
Proof
Algorithm 6.8.2 requires \(\kappa = \mathsf{mutplace}\) and \(\sigma = \tau\). By induction on the derivation of the category. P-Var: \(\kappa = \mathsf{mutplace}\) only if \(\mathrm{mut}(x)\), i.e. \(x\) is a var binding (its own storage) or a &mut parameter (by pebble-spec §9.3 an alias for a place the caller passed as &mut, which by the induction hypothesis applied to that call denotes modifiable storage). P-Field and P-Index: the premise \(\kappa \ne \mathsf{value}\) with the same \(\kappa\) means the base is a mutable place, so it denotes modifiable storage of an aggregate type, and a field or element of such storage is modifiable storage of the field's or element's type (value semantics, §9.2: the aggregate is stored inline, not shared). P-Paren: the same place. Every other expression is a value, which Algorithm 6.8.2 rejects (E0412). Since the category is computed with the type in one traversal, \(\sigma\) is the type of that storage.
Exclusivity¶
Theorem 6.8.10 (Pebble's rule makes &mut parameters unaliased)
In a Pebble program accepted by the checker, during every call, the storage denoted by a &mut parameter is disjoint from the storage denoted or read by every other parameter of the same call.
Proof
By induction on the depth of the call stack. Consider a call \(f(a_1, \ldots, a_n)\) in a function \(g\), with \(a_i = \&\mathsf{mut}\ p\), \(x = \mathrm{root}(p)\). By Algorithm 6.8.4, no other argument mentions \(x\). Pebble has no pointers and no heap references, and references exist only as parameters (E0420 forbids them elsewhere), so the storage an argument \(a_j\) denotes or reads is contained in the storage of the root variables it mentions. Distinct bindings of \(g\) have disjoint storage: locals are distinct slots of \(g\)'s frame; a by-value parameter is a copy; a reference parameter of \(g\) denotes a caller's place, and by the induction hypothesis (applied to the call of \(g\), one level shallower) a &mut parameter of \(g\) is disjoint from \(g\)'s other parameters, while two shared & parameters may overlap but are never written. The only case left is two shared parameters of \(g\) that overlap, one passed on as &mut — impossible, since &mut q needs a mutplace and a & parameter is not one (Theorem 6.8.9). Hence the storage of \(a_i\) is disjoint from that of every \(a_j\), \(j \ne i\). The base case is main, which has no parameters.
Borrow checking with non-lexical lifetimes¶
Theorem 6.8.11 (Loans stay in scope until the last use of the reference)
Let a loan \(L\) be created at \(P\) as \(R = \&p\) (or \(\&\mathsf{mut}\,p\)), and let the value of \(R\) — or a copy of it — be dereferenced at \(Q\). Then on every CFG path from \(P\) to \(Q\) along which that value is carried and on which no assignment kills \(L\), \(L\) is in scope at every point after \(P\) up to and including \(Q\). Consequently, if Algorithm 6.8.8 reports nothing, no conflicting access to \(p\) happens between the borrow and any use of the reference.
Proof
Fix such a path \(P = Q_0, Q_1, \ldots, Q_k = Q\) and follow the value: at each \(Q_i\) (\(i \ge 1\)) it is held by some variable \(V_i\), with \(V_1 = R\), changing only at copies \(V_{i+1} = V_i\). Every \(Q_i\) is in 'L. The variable holding the value at \(Q_i\) is live there (its value is used later, at \(Q\) at the latest), so by liveness \(Q_i \in \mathrm{region}(V_i)\). The borrow generates \(({'L} : \mathrm{region}(R)) \mathrel{@} P\), and each copy at \(Q_j\) generates \((\mathrm{region}(V_j) : \mathrm{region}(V_{j+1})) \mathrel{@} Q_j\). By induction along the path, \(Q_i \in {'L}\): the points \(Q_1 \ldots\) up to the first copy are reachable from \(P\) inside \(\mathrm{region}(R)\) (they are all in it by liveness), so Algorithm 6.8.6 adds them to 'L; after a copy at \(Q_j\), the points up to the next copy are reachable from \(Q_j\) inside \(\mathrm{region}(V_{j+1})\), so they are added to \(\mathrm{region}(V_j)\) and, by the induction hypothesis's constraint chain from 'L to \(\mathrm{region}(V_j)\), to 'L. \(L\) is in the IN set at every \(Q_i\). Phase 1 generates \(L\) at \(P\), keeps it where the point is in 'L (all \(Q_i\), just shown), and removes it only by a kill, which the path avoids; the dataflow's union at joins keeps it when other paths do not. Errors. An access to \(p\) at some \(Q_i\) that conflicts with \(L\) (a write, or any access if \(L\) is mutable) is relevant to \(L\) and is reported by phase 2. The kill case is sound for a different reason: an assignment to a prefix of the borrowed path replaces the value the loan pointed into, so later accesses touch new storage [RFC2094, Layer 5]. Soundness of the full Rust type system, including libraries that use unsafe, needs a semantic proof [JJKD18].
5. Complexity¶
| Technique | Time (worst) | Time (typical) | Space | Variables |
|---|---|---|---|---|
| Categories (Definition 6.8.1) with type checking | \(O(n)\) | linear | \(O(1)\) per node | \(n\) AST nodes |
| Exclusivity (Algorithm 6.8.4) per call | \(O(k \cdot s)\) | \(O(s)\): \(k\) is usually 0 or 1 | \(O(k)\) | \(k\) &mut arguments, \(s\) total argument size |
| Region inference (Algorithm 6.8.6) | \(O(c \cdot \lvert\mathcal{P}\rvert)\) per round, at most \(r \cdot \lvert\mathcal{P}\rvert\) growing steps | a few rounds | \(O(r \cdot \lvert\mathcal{P}\rvert)\) bits | \(c\) constraints, \(r\) regions, \(\lvert\mathcal{P}\rvert\) points |
| Loans in scope (Algorithm 6.8.8) | \(O(\lvert\mathcal{P}\rvert \cdot \ell / w)\) per pass, \(d + 2\) passes | 2–3 passes | \(O(\lvert\mathcal{P}\rvert \cdot \ell)\) bits | \(\ell\) loans, \(w\) word size, \(d\) loop nesting |
| Polonius (naive Datalog) | polynomial; the transitive subset per point is \(O(o^3 \cdot \lvert\mathcal{P}\rvert)\) |
slower than NLL; optimized variants prune | \(O(o^2 \cdot \lvert\mathcal{P}\rvert)\) | \(o\) origins |
Justification. Categories are one extra field computed in the same traversal. Exclusivity scans every other argument once per &mut argument. Region inference: every round either adds a point to a region (at most \(r \cdot \lvert\mathcal{P}\rvert\) times in total) or stops, and a round costs one bounded DFS per constraint. Loans in scope is a gen/kill bit-vector problem, which converges in (loop-connectedness + 2) passes in reverse post-order (Chapter 14). Polonius R2 closes subset transitively at each point: at most \(o^2\) facts per point, each derived from \(o\) pairs.
Pathological family. Region inference on a function with \(r\) references all copied into one another in a long chain across \(\lvert\mathcal{P}\rvert\) points grows every region to nearly all points: \(\Theta(r \cdot \lvert\mathcal{P}\rvert)\) bits and as many insertions — large generated functions (big match tables, macro expansions) are where rustc's borrow checker time concentrates. For exclusivity, a call with \(k\) &mut arguments to distinct roots and long index expressions costs \(k \cdot s\).
Scale. Pebble's checks are linear and invisible in the timing of Lesson 6.1 §5. rustc runs the borrow checker on MIR after type checking; it is one query per function body, so its cost parallels function size.
6. Variants and refinements¶
L-values and places¶
- C++ value categories: lvalue / xvalue ("expiring",
std::move(x)) / prvalue;const T&binds to prvalues by materializing a temporary — the "temporary: allowed" of Rust's&mut (v[0] + 1)in §7 is the same idea. - Modifiable places via types (C
const, Rustletvslet mut): mutability on the binding (Rust, Pebble) or on the type (C, C++), whereconst int *restricts one path to the storage but not others.
Exclusivity¶
- Place-sensitive exclusivity: disjoint fields (
&mut s.a,&mut s.b) are accepted by Rust and by Swift for local structs, because field paths are static; array elements are not, because indices are not — the library functionsplit_at_mut(§7) proves disjointness withunsafeinside. - Dynamic enforcement [SE0176]: class properties, globals and escaping closures in Swift record
begin_access/end_accessat run time and trap on a conflict; [SWIFT-Exclusivity] is the static half.
Borrow checking with non-lexical lifetimes¶
- Two-phase borrows (
v.push(v.len())): the&mut vof a method call is reserved first and activated at the call, so the argument may readvin between. -
Polonius [POLONIUS]: origins contain loans rather than points; a loan is live where it is contained in a live origin. The naive rules R4–R8 (quoted from the Polonius book,
rules/loans.md):// R4: the issuing origins are the ones initially containing loans origin_contains_loan_on_entry(Origin, Loan, Point) :- loan_issued_at(Origin, Loan, Point). // R5: propagate loans within origins, at a given point, according to subsets origin_contains_loan_on_entry(Origin2, Loan, Point) :- origin_contains_loan_on_entry(Origin1, Loan, Point), subset(Origin1, Origin2, Point). // R6: propagate loans along the CFG, according to liveness origin_contains_loan_on_entry(Origin, Loan, TargetPoint) :- origin_contains_loan_on_entry(Origin, Loan, SourcePoint), !loan_killed_at(Loan, SourcePoint), cfg_edge(SourcePoint, TargetPoint), (origin_live_on_entry(Origin, TargetPoint); placeholder(Origin, _)). // R7: compute whether a loan is live at a given point, i.e. whether it is // contained in a live origin at this point loan_live_at(Loan, Point) :- origin_contains_loan_on_entry(Origin, Loan, Point), (origin_live_on_entry(Origin, Point); placeholder(Origin, _)). // R8: compute illegal access errors, i.e. an invalidation of a live loan errors(Loan, Point) :- loan_invalidated_at(Loan, Point), loan_live_at(Loan, Point).R6 is phase 1 of Algorithm 6.8.8 with "region contains the point" replaced by "the origin holding the loan is live"; because
subsetis per point (R1–R3 in the book), a loan flows only along the paths where it was actually copied, which accepts the conditional-return case NLL rejects ("problem case #3" of [RFC2094]).
7. In real compilers¶
L-values and places¶
Clang computes categories in one function, ClassifyImpl, and Expr::isModifiableLvalue answers "may this be assigned" [CLANG-ExprClass]; rustc's MIR building distinguishes place and value expressions directly.
Not a place, not mutable: clang++ 23 and rustc 1.94
Reproduce (clang 23.1.2, rustc 1.94.1):
mkdir -p rs && cat > lvalue.cpp <<'EOF'
int f();
void inc(int &r) { ++r; }
int main() {
int a[3] = {0, 0, 0};
const int k = 1;
int x = 0;
a[1] = 5; // a[1] is an lvalue: fine
x + 1 = 2; // x + 1 is a prvalue: not assignable
f() = 3; // a call returning int is a prvalue
k = 4; // an lvalue, but const
inc(x + 1); // a non-const reference cannot bind to a prvalue
inc(x); // fine
}
EOF
cat > rs/places.rs <<'EOF'
fn bump(n: &mut i32) { *n += 1; }
fn main() {
let k = 1;
let mut v = [1, 2, 3];
bump(&mut k); // an immutable binding cannot be borrowed as mutable
bump(&mut (v[0] + 1)); // a temporary: allowed, but the change is lost
v[0] += 1;
println!("{:?}", v);
}
EOF
clang++-23 -fsyntax-only -fno-color-diagnostics lvalue.cpp
rustc --edition 2021 rs/places.rs -o /dev/null 2>&1 | grep -E '^(error|help)| *-->'
Output (complete):
lvalue.cpp:8:9: error: expression is not assignable
8 | x + 1 = 2; // x + 1 is a prvalue: not assignable
| ~~~~~ ^
lvalue.cpp:9:7: error: expression is not assignable
9 | f() = 3; // a call returning int is a prvalue
| ~~~ ^
lvalue.cpp:10:5: error: cannot assign to variable 'k' with const-qualified type 'const int'
10 | k = 4; // an lvalue, but const
| ~ ^
lvalue.cpp:5:13: note: variable 'k' declared const here
5 | const int k = 1;
| ~~~~~~~~~~^~~~~
lvalue.cpp:11:3: error: no matching function for call to 'inc'
11 | inc(x + 1); // a non-const reference cannot bind to a prvalue
| ^~~
lvalue.cpp:2:6: note: candidate function not viable: expects an lvalue for 1st argument
2 | void inc(int &r) { ++r; }
| ^ ~~~~~~
4 errors generated.
error[E0596]: cannot borrow `k` as mutable, as it is not declared as mutable
--> rs/places.rs:5:10
help: consider changing this to be mutable
error: aborting due to 1 previous error
What to notice: Clang separates "not a place" (lines 8, 9: prvalues) from "a place, not modifiable" (line 10: a const lvalue, with a note at the declaration — the same shape as Pebble's E0413 note). rustc's E0596 is Pebble's E0413; but &mut (v[0] + 1) is accepted by Rust, which materializes a temporary place, where Pebble reports E0412.
Exclusivity¶
Swift's DiagnoseStaticExclusivity.cpp runs a dataflow over begin_access/end_access markers in SIL [SWIFT-Exclusivity]; rustc reports the same conflicts through the borrow checker as E0499.
Two &mut borrows of one array in rustc 1.94
Reproduce (rustc 1.94.1):
mkdir -p rs && cat > rs/excl.rs <<'EOF'
fn swap(a: &mut i32, b: &mut i32) { std::mem::swap(a, b); }
fn main() {
let mut v = [1, 2];
swap(&mut v[0], &mut v[1]); // two &mut borrows of v at once
let (x, y) = v.split_at_mut(1); // the library proves the halves disjoint
swap(&mut x[0], &mut y[0]);
println!("{:?}", v);
}
EOF
rustc --edition 2021 rs/excl.rs -o excl 2>&1
Output (complete):
error[E0499]: cannot borrow `v[_]` as mutable more than once at a time
--> rs/excl.rs:4:21
|
4 | swap(&mut v[0], &mut v[1]); // two &mut borrows of v at once
| ---- --------- ^^^^^^^^^ second mutable borrow occurs here
| | |
| | first mutable borrow occurs here
| first borrow later used by call
|
= help: use `.split_at_mut(position)` to obtain two mutable non-overlapping sub-slices
error: aborting due to 1 previous error
For more information about this error, try `rustc --explain E0499`.
What to notice: v[_] — rustc forgets the index, exactly like Algorithm 6.8.4 comparing roots: the two elements are disjoint, but the checker cannot know that for arbitrary indices. Its three labels are the three ingredients of Definition 6.8.3 (the first access, the overlapping one, and the use that makes the first last long enough). Line 5–6's split_at_mut is accepted: the disjointness proof lives in the library.
Borrow checking with non-lexical lifetimes¶
rustc builds MIR, infers regions (RegionInferenceContext::solve) and runs the Borrows dataflow of phase 1 [RUSTC-Borrowck]; the dev guide describes the pipeline [RUSTC-DevGuide-Typeck].
Non-lexical lifetimes in rustc 1.94, and the MIR they are computed on
Reproduce (rustc 1.94.1):
mkdir -p rs && cat > rs/nll.rs <<'EOF'
fn main() {
let mut v = vec![1, 2, 3];
let first = &v[0]; // shared borrow of v
println!("{}", first); // last use of `first`: the borrow ends here (NLL)
v.push(4); // accepted since Rust 2018: no live shared borrow
let second = &v[1];
v.push(5); // rejected: `second` is used below
println!("{}", second);
}
EOF
cat > rs/mir.rs <<'EOF'
pub fn first_then_push(v: &mut Vec<i32>) -> i32 {
let r = &v[0];
let x = *r;
v.push(x);
x
}
EOF
rustc --edition 2021 rs/nll.rs -o nll 2>&1 | head -10
rustc --edition 2021 --crate-type=lib --emit=mir -o - rs/mir.rs | sed -n '/^fn/,$p'
Output (complete):
error[E0502]: cannot borrow `v` as mutable because it is also borrowed as immutable
--> rs/nll.rs:7:5
|
6 | let second = &v[1];
| - immutable borrow occurs here
7 | v.push(5); // rejected: `second` is used below
| ^^^^^^^^^ mutable borrow occurs here
8 | println!("{}", second);
| ------ immutable borrow later used here
fn first_then_push(_1: &mut Vec<i32>) -> i32 {
debug v => _1;
let mut _0: i32;
let _2: &i32;
let mut _3: &std::vec::Vec<i32>;
let _4: ();
scope 1 {
debug r => _2;
scope 2 {
debug x => _0;
}
}
bb0: {
_3 = &(*_1);
_2 = <Vec<i32> as Index<usize>>::index(move _3, const 0_usize) -> [return: bb1, unwind continue];
}
bb1: {
_0 = copy (*_2);
_4 = Vec::<i32>::push(copy _1, copy _0) -> [return: bb2, unwind continue];
}
bb2: {
return;
}
}
What to notice: only line 7 is an error; line 5 is accepted because first's region ends at line 4 — the §3 table exactly, including the three points rustc cites (borrow at 6, conflicting access at 7, later use at 8). In the MIR, the loan is _3 = &(*_1) in bb0; _2 (the reference r) is last used by _0 = copy (*_2) in bb1, before push takes _1 mutably, so the loan's region ends before the conflicting access and the function is accepted. This --emit=mir output is the optimized MIR; the borrow checker runs on an earlier version of the same body (-Zdump-mir=nll on a nightly compiler shows it with the regions).
8. Comparison¶
| Technique | Power / precision | Speed (asymptotic · practical) | Output / error quality | Implementation effort | Typical use |
|---|---|---|---|---|---|
| L-values and places | Decides where storage is needed and whether it may be modified; sound for &mut arguments (Theorem 6.8.9) |
\(O(n)\) with the type checker | Separates "not a place" from "not mutable", with a note at the declaration | Low: one more attribute per expression | Every compiler: C/C++ value categories, Rust places, Pebble §9.1 |
| Exclusivity | Conservative per-call rule on roots; no aliasing between a &mut parameter and others (Theorem 6.8.10); rejects disjoint elements |
\(O(k \cdot s)\) per call | Points at the conflicting occurrence and the &mut it conflicts with |
Low for calls on roots; medium for field-sensitive or dynamic enforcement | Swift (static + dynamic), Pebble E0414, Rust's &mut |
| Borrow checking with non-lexical lifetimes | References may be stored, returned and copied; accepts until the last use (Theorem 6.8.11); Polonius accepts more | Fixed point over regions + bit-vector dataflow; linear-ish in practice | Borrow, conflicting access and later use, each labelled | High: MIR, liveness, region inference, two-phase borrows | Rust (NLL since 2018, Polonius in development) |
Choose places and per-call exclusivity (Pebble, Swift's inout) when references are only passed down to callees: two small checks give aliasing freedom. Choose a borrow checker when references are first-class values that live in variables and data structures and you want that without a garbage collector — and budget for region inference and for users learning to read its errors.
9. Assessment¶
| Technique | Quiz ids | Drill | Flashcard tag | Exercises |
|---|---|---|---|---|
| L-values and places | place-category, lvalue-cpp-bind |
— | lvalues |
E5 (E5_ReferencesNeedPlacesOfTheParameterType, E5_MutableReferencesNeedMutablePlaces) |
| Exclusivity | exclusivity-roots, e0414-once |
— | exclusivity |
E5 (E5_Exclusivity) |
| Borrow checking with non-lexical lifetimes | nll-region, loans-in-scope |
— | borrowck |
— |
References¶
See the chapter references.