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) andproc(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
mutbinding BY REFERENCE, and the binding's storage is kept alive for you. Amutbinding 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
mutbinding captures it by reference (and gets the cell) exactly like one that assigns to it. If you want a copy, declare the bindinglet, or introduce aletalias in front of the closure.- A
mutbinding 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
mutbinding and then escapes into aspawngives 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 capturedmutbinding allocates. The cell is a heap object, so declaring amutbinding 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