NLL Drop (last-use early-drop)
New in v0.31.7.x. Function-level opt-in mechanism that retimes auto-emitted RAII drop calls from lexical scope end (the default) to the last use of each RAII-managed binding, computed by backward control-flow liveness.
Motivation
The default Drop policy is lexical: every RAII binding's
auto-emitted destructor fires at the closing } of the scope
that introduced it. This is predictable and easy to read, but
it pins resources for the full lifetime of the enclosing block
even when nothing actually uses them past a certain point.
use "drop.npk".*;
use "handle.npk".*;
func:hot_loop = NIL() {
int64:arena = HandleArena.create();
Handle<int64>:scratch = raw HandleArena.alloc(arena, 64i64);
use_scratch_then(pass scratch); // last use of scratch
expensive_pure_computation(); // scratch STILL pinned here
expensive_pure_computation();
expensive_pure_computation();
}; // drops finally fire here
The scratch handle is unused for the entire tail of the
function but still consumes a handle slot until }. For hot
loops carrying many scratch resources, that pressure is
measurable.
#[nll_drop] flips the policy:
#[nll_drop]
func:hot_loop = NIL() {
int64:arena = HandleArena.create();
Handle<int64>:scratch = raw HandleArena.alloc(arena, 64i64);
use_scratch_then(pass scratch); // last use of scratch
// ↑↑↑ npk_handle_free(scratch) fires HERE under #[nll_drop]
expensive_pure_computation(); // scratch already returned
expensive_pure_computation();
expensive_pure_computation();
};
Functions without the annotation are byte-for-byte unchanged.
Opt-in: #[nll_drop] on a function
#[nll_drop]
func:compute = int32() {
wildx int8->:scratch = wildx_alloc(1024i64);
use_scratch(pass scratch);
// npk_wildx_free(scratch) fires here, not at };
pass 0;
};
The attribute attaches to the function declaration directly.
It takes no arguments and stacks with other attributes
(#[destroys_arena(_)], #[derive(_)], etc.) in any order.
Effects of #[nll_drop]:
- Every RAII-enrolled binding in the function gets a backward liveness query.
- The auto-drop is emitted at the earliest program point after which the binding is dead on all outgoing paths.
- The scope-end emission for that binding is suppressed (the
DropEntryis marked consumed; the scope-end walker skips consumed entries). - Bindings not enrolled in RAII (primitives, raw pointers, non-Drop structs) are untouched — they have nothing to retime.
Per-binding opt-out: #[lexical_drop]
Inside an #[nll_drop] function, a single binding can opt
back into lexical timing:
#[nll_drop]
func:mixed = NIL() {
#[lexical_drop]
Handle<int64>:long_lived = raw HandleArena.alloc(arena, 64i64);
Handle<int64>:short = raw HandleArena.alloc(arena, 16i64);
use(pass short); // short drops here (NLL)
// ... lots of code that doesn't touch long_lived ...
record_metric(long_lived); // long_lived still alive
}; // long_lived drops here (lexical)
#[lexical_drop] is a no-op outside an #[nll_drop] function
and a no-op on bindings not enrolled in RAII.
It opts that one binding out of NLL timing; the binding
still participates in RAII (still drops at }). To opt a
binding out of RAII enrolment itself, use the orthogonal
nodrop wrapper-keyword from v0.31.6.x — see
regions.md.
Conditional last-use
When the last use of a binding lives inside one arm of an
if, the drop point is the join point after the if,
not inside the branch:
#[nll_drop]
func:branched = NIL(bool:flag) {
Handle<int64>:h = raw HandleArena.alloc(arena, 8i64);
if (flag) {
consume(pass h); // last use in the then-arm
}
// Both arms converge here — h drops at this join point,
// not inside the if. The else-arm would otherwise skip the drop.
other_work();
};
This avoids the "one branch drops, the other branch leaks" hazard by always emitting the drop on a path that dominates both branches' continuation.
Loop last-use
Inside a loop or for, a binding that is read across the
back-edge stays live for the entire loop. NLL only fires the
drop after the loop exits:
#[nll_drop]
func:iter = NIL() {
Handle<int64>:buffer = raw HandleArena.alloc(arena, 256i64);
for (i = 0; i < 100; i += 1) {
write_to(buffer, i); // buffer used every iteration
}
// buffer drops here, immediately after the loop, not at };
pure_tail();
};
The back-edge liveness merge that powers this is the same fixpoint the borrow checker already used (Gemini-Report path); v0.31.7.2 simply populated its previously-empty use-set.
pass v; (move) wins
pass v; is a move, not a use. It transfers ownership to
the caller and the originating frame's auto-drop is skipped
entirely (this rule predates NLL — DROP-DEC-004). NLL changes
nothing here:
#[nll_drop]
func:donor = Handle<int64>() {
Handle<int64>:owned = raw HandleArena.alloc(arena, 16i64);
pass owned; // moves to caller — NO drop emitted
};
The caller is now responsible for owned's eventual drop (at
its own scope end, or under its own #[nll_drop] policy).
Error / unwind paths
On fail or panic, Drop still fires for every binding that is
live at the fail point. Bindings that were early-dropped
under NLL before the fail are not re-dropped — the consumed
flag carries through the unwind walker:
#[nll_drop]
func:fallible = Result<NIL>() {
Handle<int64>:early = raw HandleArena.alloc(arena, 8i64);
Handle<int64>:late = raw HandleArena.alloc(arena, 8i64);
use(pass early); // early drops here (NLL)
if (some_condition()) {
fail "boom"; // unwind: late drops, early does NOT
}
use(pass late); // late drops here (NLL)
pass;
};
Interaction with nodrop (v0.31.6.x)
nodrop and #[lexical_drop] target different axes:
| Opt-out | What it changes | Where it lives |
|---|---|---|
nodrop |
RAII enrolment — the binding never gets an auto-drop at all | wrapper keyword on the initializer |
#[lexical_drop] |
NLL timing — the binding's auto-drop stays at scope end | attribute on the binding |
They compose: a nodrop binding inside an #[nll_drop]
function simply has nothing for NLL to retime, so the
attribute is moot for that binding.
#[nll_drop]
func:both = NIL() {
// Manually managed — neither NLL nor lexical Drop touches this.
Handle<int64>:manual = nodrop raw HandleArena.alloc(arena, 8i64);
HandleArena.free(manual);
// NLL-managed — drops at last use.
Handle<int64>:auto = raw HandleArena.alloc(arena, 8i64);
use(pass auto);
};
See regions.md for
the nodrop surface.
Borrow lifetime tightening (NLL-DEC-014)
Under #[nll_drop], borrow lifetimes derived from a RAII
binding end at the NLL drop point, not at }. This is a
tightening — code that borrowed past last-use under
lexical Drop may fail to compile under #[nll_drop]:
#[nll_drop]
func:tight = NIL() {
Handle<int64>:h = raw HandleArena.alloc(arena, 8i64);
$i Handle<int64>:b = $i h;
use_handle(pass h); // last use of h → drops here
use_borrow(b); // ERROR: ARIA-014 — h dropped
};
Removing the #[nll_drop] attribute makes this code compile
again under lexical Drop. The tightening is gated by the
attribute, so no existing code regresses.
Troubleshooting: ARIA-052
If you annotate a function with #[nll_drop] but the function
has no RAII-enrolled bindings to retime, the compiler emits:
warning: [ARIA-052] function 'name' has no RAII bindings; #[nll_drop] has no effect
Common causes:
- The function uses primitives only.
- The function uses raw pointers (
wild T->:p) which keep the explicit-cleanup contract. - All RAII bindings in the function carry
nodrop(in which case nothing is enrolled). - The function is empty.
Remove the attribute or add at least one RAII binding to silence the warning.
When not to use #[nll_drop]
- Drop impls with ordering-sensitive side effects. NLL
retimes drops earlier, which changes the observable order
relative to other code. If a
T_dropwrites to a log file whose ordering matters relative to surrounding statements, stick with lexical Drop or use#[lexical_drop]on the affected binding. - Short functions. If the function fits on a screen and
every binding is used near
}, NLL adds no benefit. - Bindings whose last use IS at the end of the function. NLL emits the drop at the same point lexical Drop would — no observable change, just a slightly slower compile.
See also
- README.md — Drop overview.
- surface.md —
impl:Drop:for:Tsurface. - regions.md — per-region Drop semantics;
nodropper-binding opt-out. - pitfalls.md — Drop gotchas.
- faq.md — Drop FAQ.
guide/borrow/— borrow lifetime model that NLL tightens.