Closures

A closure is a lambda that reads variables from the function scope in which it is written. No explicit capture list is needed — the compiler detects which outer variables the lambda body uses and packages them automatically. How a variable travels into the closure depends on what it is: a scalar or a text is COPIED, and a collection or struct is SHARED. Both rules are below, and the difference between them is the thing to remember on this page.

Integer capture

A lambda may read any integer variable in scope. Store the lambda, then call it by name.

Multiple integer captures

A lambda can read more than one outer variable at once. All are captured when the lambda is written.

Text capture

Text values are captured by deep-copy, so they are independent of the original variable after capture.

Capture timing

Scalars and text are copied when the lambda is written (definition time), not when it is called. Changing such a variable afterwards does not change what the lambda sees.

Collections and structs are shared

Every collection — vector, hash, sorted, index, spatial — and every struct is captured by reference: the closure and the enclosing scope work on the same value, so changes travel BOTH ways.

Writing to a captured variable

A closure may assign to a captured scalar, and those writes are visible in the enclosing scope.

Cross-scope closures

A function can return a capturing lambda to the caller. The captured values travel with the lambda — no dangling references.

Closures with higher-order functions

A capturing closure can be called directly, and it can also be handed to map or filter — the captures travel with it.

Non-capturing lambdas with higher-order functions

Lambdas that use only their own parameters (no capture) also work fine.

Capturing a & parameter

A closure inside a function can use the function's & parameters. What it captures is what the parameter points at, by the same two rules as above.

What a closure cannot do

Two shapes the compiler refuses, and what replaces each.

struct Counter {
  hits: integer,
}
struct Stepper {
  advance: fn(integer) -> integer,
}

Zero every even element, from inside a closure, then append a marker.

fn clear_evens(v: &vector<integer>) {
  wipe = fn(i: integer) { if v[i] % 2 == 0 { v[i] = 0; } };
  for i in 0..len(v) { wipe(i); }
  v += [9];
}
fn make_adder(base_val: integer) -> fn(integer) -> integer {
  fn(n: integer) -> integer { base_val + n }
}
fn main() {

Integer capture

  offset = 100;
  shift = fn(x: integer) -> integer { x + offset };
  assert(shift(5) == 105, "shift(5): {shift(5)}");
  assert(shift(-3) == 97, "shift(-3): {shift(-3)}");

Multiple integer captures

  lo = 10;
  hi = 20;
  clamp_val = fn(x: integer) -> integer {
    if x < lo { lo } else if x > hi { hi } else { x }
  };
  assert(clamp_val(5) == 10, "clamp below: {clamp_val(5)}");
  assert(clamp_val(15) == 15, "clamp mid: {clamp_val(15)}");
  assert(clamp_val(25) == 20, "clamp above: {clamp_val(25)}");

Text capture

The closure holds its own copy of the text, so reassigning greeting afterwards leaves the message it builds unchanged.

  greeting = "Hello";
  make_msg = fn(name: text) -> text { "{greeting}, {name}!" };
  greeting = "Goodbye";
  assert(make_msg("World") == "Hello, World!", "greeting capture");
  assert(make_msg("Loft") == "Hello, Loft!", "greeting capture 2");

Capture timing

base is 10 when the lambda is written; reassigning it to 20 afterwards does not affect the captured value.

  base = 10;
  add_base = fn(n: integer) -> integer { base + n };
  base = 20;
  assert(add_base(5) == 15, "sees base=10 at definition time: {add_base(5)}");

Collections and structs are shared

A collection is NOT copied the way the scalar above is: the closure works on the caller's vector. Appending to items after the lambda is written changes what the lambda counts, and a write made inside the lambda is visible outside it. That sharing is what lets a closure gather results into a vector the enclosing function keeps.

  items = [1, 2, 3];
  total = fn() -> integer {
    sum = 0;
    for e in items { sum += e; }
    sum
  };
  assert(total() == 6, "before the append: {total()}");
  items += [10];
  assert(total() == 16, "the append reaches the closure: {total()}");

A struct behaves the same way, in the other direction: the closure writes through to the record the enclosing scope holds.

  tally = Counter { hits: 0 };
  record_hit = fn() { tally.hits += 5; };
  record_hit();
  assert(tally.hits == 5, "the closure's write is visible outside: {tally.hits}");

Writing to a captured variable

Assigning to a captured scalar shares it instead of copying it, so the closure's writes are visible for the rest of the function. This is how an accumulator is written. One restriction: a scalar a closure WRITES to may be captured by only one closure — two of them is refused, and the cure is to hold the shared state in a struct and capture that, as tally does above.

  count = 0;
  bump = fn() { count += 1; };
  bump();
  bump();
  assert(count == 2, "the closure's writes reach the enclosing scope: {count}");

Cross-scope closures

make_adder returns a lambda that captured base_val from its parameter.

  add10 = make_adder(10);
  add100 = make_adder(100);
  assert(add10(5) == 15, "add10(5): {add10(5)}");
  assert(add100(5) == 105, "add100(5): {add100(5)}");

Closures with higher-order functions

  factor = 3;
  scale = fn(x: integer) -> integer { x * factor };
  assert(scale(5) == 15, "scale(5): {scale(5)}");

The same closure passed to map — factor travels with it.

  scaled = map([1, 2, 3], scale);
  assert(scaled[0] == 3, "scaled[0]: {scaled[0]}");
  assert(scaled[2] == 9, "scaled[2]: {scaled[2]}");

A capturing lambda written inline works too, in filter as well as map.

  limit = 2;
  big = filter([1, 2, 3, 4], |x| { x > limit });
  assert(len(big) == 2, "len(big): {len(big)}");
  assert(big[0] == 3, "big[0]: {big[0]}");

Non-capturing lambdas with higher-order functions

No capture needed here — the lambda uses only its own parameter.

  nums = [1, 2, 3, 4, 5];
  doubled = map(nums, |x| { x * 2 });
  assert(doubled[0] == 2, "doubled[0]: {doubled[0]}");
  assert(doubled[4] == 10, "doubled[4]: {doubled[4]}");
  evens = filter(nums, |x| { x % 2 == 0 });
  assert(evens[0] == 2, "evens[0]: {evens[0]}");
  assert(evens[1] == 4, "evens[1]: {evens[1]}");

Capturing a & parameter

A &vector or a & struct is SHARED, so writes from inside the closure reach the caller's value. A &integer or a &text is COPIED when the closure is created, like any scalar: the closure can read it, and a write to it is refused (see below).

  evens = [1, 2, 3, 4];
  clear_evens(evens);
  assert("{evens}" == "[1,0,3,0,9]", "writes through a captured '&vector' reach the caller: {evens}");

What a closure cannot do

A closure cannot WRITE a captured &integer or &text parameter: it holds a copy, so the write would not reach the caller, and the compiler says so. Copy the parameter into a local, let the closure update the local, and write it back before returning.

A capturing closure cannot be stored in a COLLECTION: vector<fn(…)> and the keyed collections take non-capturing lambdas only. A struct FIELD holds one without trouble, which is the shape to reach for.

  step = 7;
  stepper = Stepper { advance: fn(x: integer) -> integer { x + step } };
  assert(stepper.advance(10) == 17, "a struct field holds a capturing closure: {stepper.advance(10)}");
}