13. Closures
A closure is written |params| body. An inline body is a single expression. A trailing-block body, closed by end, is a statement sequence with the same semantics as do…end (§8), the do being implicit between |x| and end. There is no do or do! keyword to write and no closure-level effect marker: effectfulness is inferred from the body and gated at the call site by the called method's bang.
Two layouts, one closure:
use io
def main!() -> () [io]
[1, 2, 3].map(|x| x * 2) # inline: the `)` bounds the body, no `end`
[1, 2, 3].each! |x| # trailing block: the `end` closes it
io.print!("#{x}\n")
end
end
- Inline,
f(|x| EXPR). The closure is an argument inside parentheses, or any value position, and the enclosing)or,bounds the single-expression body. There is noend. The body may sit on the same line, or begin on the next line indented under|params|. It is one expression in both layouts, and a fold or map step with anif … endbody needs no helper extraction. Usedo … endwhere a sequence is wanted. Use the inline form for a closure that is not the call's last argument too:apply(|n| n * 2, 5). - Trailing block,
f |x|then the body on following lines thenend. The closure is a call's last argument, written after the call with no parentheses, andendcloses it. The body is ado…endblock with thedoimplicit: a statement sequence whose value is its last expression, or()where it ends on a statement. This suits builder and customization blocks, a sequence ofc.add!(…)calls, and a multi-lineif,matchorloopbody reads naturally in it.
Effectfulness is inferred and never spelled. A pure combinator (map, find, filter, with no bang) checks the closure body in pure context, and an action call inside is rejected. An action combinator (each!, with a bang) lets the body call actions and propagates their effects to the caller (§6). The bang on the method says which one applies, and the closure needs no marker of its own.
open io
open list
def demo!(xs: List<i32>) -> () [io]
xs.map(|x| io.print!("#{x}")) # error: map is pure; its closure cannot call an action
xs.each!(|x| io.print!("#{x}")) # ok: each! is an action; [io] propagates to the caller
end
A let-bound closure, one not in call position, infers its effects from its body, and those effects flow to wherever it is later called. Where the binding, a struct field or a return type declares an explicit function type, that type's effect row bounds the closure. An unannotated function type is pure, and storing an effectful closure there requires annotating the row: g: (i32) -> () [io] = |x| io.print!(x). See §6, Effect-annotated function types.
Capture
A closure captures the bindings it uses by value. It copies them where it is written, and the closure and the enclosing scope never share a mutable cell: nothing the closure does is visible through a captured name afterwards, and nothing the enclosing scope does later changes what the closure sees. That is what lets a flow-narrowing fact reach into a closure body (§7), and it fits §12's rule that var is a property of the binding and no property of the value.
The consequence is stated outright: assigning to a captured binding is a compile error (H0209). The write would land on the closure's own copy and be discarded when it returns, and an accumulator written that way yields its pre-loop value in place of a sum.
use io
def main!() -> () [io]
var total = 0
[1, 2, 3].each! |x|
total = total + x # error: `total` was captured by value
end
io.print!("#{total}\n")
end
Two forms give what that reaches for. A loop in the enclosing body, where the var is a real local and the write is an ordinary one; or a fold, which threads the accumulator through and returns it:
def sum(xs: List<int>) -> int
xs.fold(0, |acc, x| acc + x)
end
The rule bites only on captured bindings. A closure's own parameters and its own var locals are ordinary mutable locals, writable as usual and from a nested block inside the body, and a parameter that shadows a captured name is the closure's own binding too. A field write rebinds its root (§12), and c.v = x through a captured c is rejected on the same grounds as a bare write to c.
The pure and action forms both remain
A combinator that takes a callback comes in two forms, and both are part of the surface for good: the pure map/filter/fold/find/find_map/any?/all?/flat_map/partition/take_while/count, and an effect-polymorphic ! twin for each alongside them. The ! distinguishes them at the call site, nothing is ambiguous, and the pure form states real information: its type says the traversal cannot touch the world, which is what makes it usable from a pure def, a where block, or meta. Neither is the special case of the other, and the pure ones are not deprecated.
Use the ! form where the callback needs an effect, and the pure one otherwise. The pure form rejecting an effectful callback (H0603) is the intended half of this and no gap to work around.
The rule reaches past List. Option and Result are the other callback-taking faces in core, and their combinators are paired the same way: option.map!/and_then!/or_else! and result.map!/map_err!/and_then!/or_else! (§8). An effectful step over an Option would otherwise have no combinator at all and fall back to an explicit match, the hand-rolled form the List twins exist to retire.
sort_by, min_by and max_by are pure-only, and there is no sort_by!. A comparator that performs effects is nearly always a bug, and the sort's guarantees stop meaning anything where the comparator can observe the world: a stable sort assumes a consistent ordering, and one that reads a clock or a file need not be consistent between two calls on the same pair. Compute whatever the effect would supply into the elements first (xs.map!(…)), then sort the result with a pure comparator.
Parameter types
Closure parameter types are inferred bidirectionally from the expected function type at the call site, usually a higher-order combinator like list.map. No annotation is needed where there is one:
[1, 2, 3].map(|x| x * 2) # x: i32, from List<i32>.map's signature
Where there is no expected type, a closure bound to an unannotated let with no other constraint, inference cannot pin the parameters, and the compiler reports cannot infer type, the same diagnostic as §11. The fix is the same: annotate either the binding or the parameters.
f: (i32) -> i32 = |x| x * 2
g = |x: i32| x * 2
Body complexity
An inline closure body is one expression. This is a grammar fact and no lint: a second statement before the closing ) or , is a parse error. A sequence fits through a do … end block (§8), which is itself one expression. A trailing-block body is already a do…end block and sequences directly, with no inner do needed; it is the form for builder and customization blocks. In either form, keep the logic small. A nested loop, or a match with three or more arms anywhere in the body, is flagged; extract it into a named def or def!, and the call site becomes list.map(items, transform) with the logic named. Closures are for one-shot predicates, small transformations and short builder sequences, and past that the naming pressure of def is worth paying.
There are no brace-block closures. Ruby's { |x| x*2 } is not allowed in Hanki, Ruby's precedence footgun being well known. |x| body is the only form.