Closures & Lambdas
Function Pointer Variables
Lambda expressions are assigned to typed variables using function pointer syntax:
(int32)(int32):identity = int32(int32:x) { pass x; };
(int32)(int32, int32):add = int32(int32:a, int32:b) {
int32:sum = a + b;
pass sum;
};
The variable type is (ReturnType)(ParamTypes) — return type first, then parameter types.
Passing as Arguments
Higher-order functions accept function pointers as parameters:
func:apply = int32((int32)(int32):f, int32:x) {
int32:result = f(x) ? -1i32;
pass result;
};
(int32)(int32):dbl = int32(int32:x) { int32:v = x + x; pass v; };
int32:r = apply(dbl, 21i32) ? -1i32;
Note: Lambdas must be assigned to a named variable first, then passed by name. There are no anonymous inline lambdas at call sites.
Capturing Outer Variables (v0.31.14.x+)
Lambdas capture outer-scope locals automatically. Module-level
fixed constants and use-imported names stay direct references
(no env slot). Wild / wildx / coro-frame captures are deferred
to v0.32.x.
func:main = int32() {
int32:seed = 42i32;
string:label = "ok";
int64:handle = 0xCAFEBABEi64;
(int32)():report = int32() {
// `seed`, `label`, and `handle` are all captured.
// They live in an env struct on the GC heap; the
// lambda value is a fat pointer `{fn_ptr, env_ptr}`.
if (label != "ok") { fail 2i32; }
if (handle != 0xCAFEBABEi64){ fail 3i32; }
pass seed;
};
int32:r = report() ? -1i32;
exit r; // 42
};
Capture-mode rule (CLOSURE-DEC-003)
For each captured outer variable the analyzer picks ONE mode:
- BY_REF — the lambda body mutates the variable, or takes
its address with
@. - BY_VALUE — primitive scalar, or a small struct whose
getTypeAllocSize() ≤ 16bytes, and the type has noDrop, and the type has no internal pointers.string(NpkString fat pointer, exactly 16 bytes) andint64handles qualify. - BY_BORROW — otherwise (large struct, Drop-bearing type,
internal-pointer-bearing type). The captured borrow extends
the source
$iwindow across every call site of the lambda.
Drop-bearing types are never BY_VALUE — that would silently
clone the underlying resource.
ARIA-060 — INVALID_CLOSURE_CAPTURE
A capture is rejected at compile time when it is both mutated
and has its address taken inside the lambda body
(CLOSURE-DEC-009b, the $m-alias-live case):
func:main = int32() {
int32:n = 0i32;
(int32)():f = int32() {
n = n + 1i32; // mutation
int32:p = @n; // address taken — ARIA-060
pass p;
};
int32:r = f() ? -1i32;
exit r;
};
error[ARIA-060]: INVALID_CLOSURE_CAPTURE: capture of 'n' is both
mutated and address-taken inside the lambda body
Fixes:
- Drop the
@nif you only need the value (BY_REF still gives observable mutation). - Drop the assignment to
nif you only need the address (thennis BY_BORROW, address-stable for the lifetime of the enclosing scope). - Rewrite the body to use a local:
int32:tmp = n; ... = @tmp;.
Stack-escape captures (a lambda holding a $$i/$$m borrow
escapes its enclosing frame) keep being caught by the existing
return-borrow checker — they re-use the same ARIA-060 identity
but the message is emitted by the borrow checker, not the
closure analyzer.
What is not yet supported
- Capturing across module boundaries — closures stay unit-of-compilation local.
- Move-only captures — every capture is borrow-or-copy.
- Capturing values that live in
wild/wildx/ coro-frame regions — deferred to v0.32.x; current behaviour is a borrow- checker rejection (no ARIA-060 path).
Related
- declaration.md — named function syntax
- generics.md — generic function syntax
- ../borrow/ — borrow-checker chapters; capture borrow lifetimes flow through the same ledger.