Closures and Lambda Expressions

Part of: Reef Language Reference Last reviewed on version: 0.8.0 Status: Implemented


Overview

Reef supports closures (anonymous functions that can capture variables from their enclosing scope). Closures enable functional programming patterns like higher-order functions, callbacks, and data transformations.

Key Features:

  • Lambda expressions with fn (returns value) and proc (no return)
  • Variable capture from enclosing scope
  • Both mutable and immutable capture support
  • Function types with Fn[params..., return] syntax
  • Escape analysis optimization (stack allocation for non-escaping closures)

Lambda Expressions

Function Lambdas (fn)

Return a value. Use fn(params): return_type => expr for expression bodies:

// Single expression body
let double = fn(x: int): int => x * 2
let add = fn(a: int, b: int): int => a + b

// Type inference for return type
let triple = fn(x: int) => x * 3

// Usage
print_int(double(5))    // 10
print_int(add(3, 4))    // 7

Block-Body Lambdas

For multi-statement bodies, use fn(params): return_type ... end fn:

let complex = fn(x: int): int
    let y = x * 2
    let z = y + 1
    return z
end fn

// Implicit return (last expression)
let square_plus_one = fn(n: int): int
    let sq = n * n
    sq + 1    // Last expression is returned
end fn

Procedure Lambdas (proc)

No return value. Use proc(params) => expr or block body:

// Expression body
let printer = proc(x: int) => print_int(x)

// Block body
let logger = proc(msg: string)
    print("LOG: ")
    println(msg)
end proc

// Usage
printer(42)           // Prints: 42
logger("Starting")    // Prints: LOG: Starting

Variable Capture

Lambdas can capture variables from their enclosing scope:

Immutable Capture

Variables declared with let are captured by value (copied):

let factor = 10
let scale = fn(x: int): int => x * factor

print_int(scale(5))    // 50

Mutable Capture

Variables declared with mut are captured by reference (boxed):

mut counter = 0
let inc = fn(): int
    counter = counter + 1
    counter
end fn

print_int(inc())       // 1
print_int(inc())       // 2
print_int(inc())       // 3
print_int(counter)     // 3 (mutated by closure)

Which Binding Is Captured

A closure captures the innermost binding visible at the point where the closure is written — the ordinary scoping rule, and nothing else. Capture decides how a value is stored (copied, or boxed), never which binding a name means. A capture and a direct read of the same name at the same point always refer to the same binding:

let v = "outer"

if true
    let v = "inner"          // shadows the outer v
    let f = fn(): string => v
    println(v)               // inner
    println(f())             // inner  — the same binding the line above reads
end if

Two blocks that do not enclose each other are unrelated, as usual: a closure written in one never sees the other's bindings.

A binding declared inside the lambda is a local of the lambda, and shadows a capture of the same name for the rest of the lambda body:

let msg = "outer"
let g = fn(): string
    let msg = "inner"        // a local of g, not the capture
    return msg               // inner
end fn

Closures nest to any depth, and an inner lambda may capture from any enclosing scope, not just the one immediately around it:

let base = 7
let mid = fn(): int
    let inner = fn(): int => base + 1
    return inner()
end fn

print_int(mid())             // 8

self is captured like any other binding, so a lambda inside a method may read the receiver's fields:

type Counter = struct
    n: int

    fn doubled(): int
        let f = fn(): int => self.n * 2
        return f()
    end fn
end Counter

A closure captures a mut binding BY REFERENCE, and the binding's storage is kept alive for you. A mut binding that any closure captures is moved out of the enclosing frame into a garbage-collected cell, so a closure you store, return, or pass somewhere that outlives the declaring block keeps reading and writing the same live storage. The declaring scope and every closure share that one cell, so writes from either side are visible to both — which is what by-reference capture has always meant, and now what it actually does.

Three consequences worth knowing:

  • It is the BINDING's mutability that decides, not the closure's use. A closure that only reads a mut binding captures it by reference (and gets the cell) exactly like one that assigns to it. If you want a copy, declare the binding let, or introduce a let alias in front of the closure.
  • A mut binding declared inside a loop body gets a fresh cell per iteration, so closures made in different iterations observe different bindings rather than sharing the last iteration's value.
  • The cell is shared, unsynchronised, mutable heap state. A closure that captures a mut binding and then escapes into a spawn gives two threads a well-defined shared cell with no locking of its own. That is a data race you own; if the value crosses threads, put it behind an Active Object instead.

Before Reef 0.9 the reference was an address in the declaring stack frame, so an escaping closure read and wrote memory that had already been reused — silently for scalars, and with a crash for strings, arrays and objects. If you are reading older code written around that defect, the workarounds it used are no longer necessary.

A closure may capture a passive object, but capture copies the reference — it does not transfer ownership. Calling methods on that widget from another thread (spawn, an Active Object run(), a callback delivered off the owner) is a cross-owner access. Default builds do not diagnose it; reefc --owner-harness aborts. See Passive objects — owner-check harness.

Inside a finalize() body, a captured mut binding allocates. The cell is a heap object, so declaring a mut binding that a closure captures inside a finalizer is an allocation from the finalizer — which the finalizer no-allocation rule forbids, and which hangs the program with no diagnostic. See Active Objects — finalize. A closure that captures anything at all already allocates its environment there, so this is the same rule rather than a new one.

Await Conditions Are Not Closures

An await condition inside an Active Object's exclusive method reads enclosing locals too, but it is a snapshot, not a closure: the values it names are copied once when the wait begins and are never refreshed, so self is the only term that can change between wakes. Because of that, an await condition may not name a binding that is shadowed at the await site — the wait would be either instantly true or never true. That is a compile error; rename the inner binding, or make the condition depend on self. See Active Objects — await.

Mixed Captures

Lambdas can capture both mutable and immutable variables:

let multiplier = 5       // immutable - copied
mut total = 0            // mutable - boxed

let add_scaled = proc(x: int)
    total = total + (x * multiplier)
end proc

add_scaled(1)
add_scaled(2)
add_scaled(3)
print_int(total)       // 30 (5 + 10 + 15)

Function Types

Function types use the Fn[params..., return] syntax where the last type is the return type:

// Fn[int, int] = function taking int, returning int
type Mapper = Fn[int, int]

// Fn[int, int, int] = function taking two ints, returning int
type Reducer = Fn[int, int, int]

// Fn[int, bool] = function taking int, returning bool
type Predicate = Fn[int, bool]

// Fn[int] = procedure taking int, no return (unit)
type Consumer = Fn[int]

Syntax Rationale: Square brackets are used to avoid ambiguity with function parameter lists.


Higher-Order Functions

Closures can be passed as arguments to functions:

Basic Example

proc apply_twice(x: int, f: Fn[int, int])
    let result = f(f(x))
    print_int(result)
end apply_twice

proc main()
    apply_twice(5, fn(n: int): int => n * 2)    // 20
end main

Common Patterns

map - Transform each element:

fn list_map(items: [int], f: Fn[int, int]): [int]
    let len = items.length()
    mut result = new [int](len)
    for i in 0 to len
        result[i] = f(items[i])
    end for
    result
end list_map

// Usage
let doubled = list_map([1, 2, 3], fn(x: int): int => x * 2)
// doubled = [2, 4, 6]

Note the let len = items.length() step: for i in 0 to ... bounds must be a literal or a variable, not a call expression, so the length is bound to a local before the loop rather than written as for i in 0 to items.length().

filter - Keep elements matching predicate:

fn list_filter(items: [int], pred: Fn[int, bool]): [int]
    // ... implementation
end list_filter

let evens = list_filter([1, 2, 3, 4, 5], fn(x: int): bool => x % 2 == 0)
// evens = [2, 4]

fold - Reduce to single value:

fn list_fold(items: [int], start: int, f: Fn[int, int, int]): int
    mut acc = start
    let len = items.length()
    for i in 0 to len
        acc = f(acc, items[i])
    end for
    acc
end list_fold

let sum = list_fold([1, 2, 3, 4, 5], 0, fn(a: int, b: int): int => a + b)
// sum = 15

foreach - Execute for side effects:

proc list_foreach(items: [int], f: Fn[int])
    let len = items.length()
    for i in 0 to len
        f(items[i])
    end for
end list_foreach

list_foreach([1, 2, 3], proc(n: int) => print_int(n))
// Prints: 1, 2, 3

Storing Closures

Closures can be stored in variables and passed around:

// Store in variable
let my_func = fn(x: int): int => x * 2

// Pass to another function
apply_twice(5, my_func)

// Store in array
let operations: [Fn[int, int]] = [
    fn(x: int): int => x + 1,
    fn(x: int): int => x * 2,
    fn(x: int): int => x * x
]

Closure Optimization

Reef performs escape analysis to optimize closure allocation:

Non-Escaping Closures (Stack-Allocated)

When a lambda is passed directly to a function and doesn't escape:

// This lambda is stack-allocated (zero heap overhead)
list_foreach(items, proc(n: int) => print_int(n))

Escaping Closures (Heap-Allocated)

When a lambda is stored in a variable or returned:

// This lambda is heap-allocated
let printer = proc(n: int) => print_int(n)
list_foreach(items, printer)

Performance Tip: Prefer inline lambdas for HOF calls when the closure doesn't need to be reused.


Complete Example

proc main()
    // Data to process
    let numbers = [1, 2, 3, 4, 5, 6, 7, 8, 9, 10]

    // Filter: keep evens
    let evens = list_filter(numbers, fn(x: int): bool => x % 2 == 0)

    // Map: square each
    let squared = list_map(evens, fn(x: int): int => x * x)

    // Fold: sum all
    let sum = list_fold(squared, 0, fn(a: int, b: int): int => a + b)

    println("Sum of squares of evens: ${sum}")
    // Output: Sum of squares of evens: 220

    // Accumulator pattern with mutable capture
    mut running_total = 0
    list_foreach(numbers, proc(n: int)
        running_total = running_total + n
        println("Running total: ${running_total}")
    end proc)
end main

Syntax Summary

Pattern Syntax Description
Expression lambda fn(x: int): int => x + 1 Single expression, explicit return type
Inferred return fn(x: int) => x + 1 Return type inferred from expression
Block lambda fn(x: int): int ... end fn Multi-statement body
Procedure lambda proc(x: int) => println(x) No return value (expression)
Procedure block proc(x: int) ... end proc No return value (block)
Function type Fn[int, int] Function taking int, returning int
Procedure type Fn[int] Procedure taking int (returns unit)

Limitations

  • No generic lambdas - Lambda type parameters are not yet supported
  • No explicit capture lists - All referenced variables are automatically captured
  • No move semantics - Captured values are always copied or boxed

Next: 065_DEFER.md Previous: 055_GENERICS.md