Closures and captured state: what each language remembers
How eight languages decide whether a closure captures a variable or its value, and the famous bugs that follow from the difference.
A closure is a function bundled with the bindings it referred to when it was made. The function can travel - be returned, stored, passed to another thread - and it keeps a handle on those bindings for as long as it lives. The subtle question every language must answer is what that handle points at: the value of a variable frozen at capture time, or the variable itself, still live and still changing. Get the answer wrong and you meet one of programming's most reliable bugs. Closures are also why first-class functions pay off, so it helps to know exactly what each language remembers.
The value or the variable? Python's late binding
Python captures the variable, not the value. Closures made inside a loop all share the same loop variable, and each one reads that variable only when it is finally called - by which point the loop has usually finished:
fns = [lambda: i for i in range(3)]
print([f() for f in fns]) # [2, 2, 2]
Every lambda closes over the one name i. After the comprehension runs, i holds its final value 2, so all three lambdas return 2. This is late binding: the name is looked up at call time, not at definition time. The standard fix pins the value with a default argument, which is evaluated at definition time:
fns = [lambda i=i: i for i in range(3)]
print([f() for f in fns]) # [0, 1, 2]
Go: the loop variable that finally changed
Go carried the same trap for its first fourteen years. A loop variable was created once and reused across iterations, so closures built in the loop all captured that single shared variable - the classic goroutine-in-a-loop bug:
funcs := []func(){}
for i := 0; i < 3; i++ {
funcs = append(funcs, func() { fmt.Print(i) })
}
for _, f := range funcs { f() }
// Go 1.21 and earlier: 333
// Go 1.22 and later: 012
Go 1.22, released in February 2024, changed the semantics: each iteration of a for loop now creates a fresh copy of the loop variable. Code that printed 333 for years now prints 012. It is a rare case of a language fixing a footgun by quietly changing what existing code means.
Rust: naming the capture discipline
Rust is the only language here that forces a decision about how a closure captures. A closure automatically implements one or more of three traits, chosen by what its body does with the captures:
Fn- reads captures through a shared reference; callable many times.FnMut- captures a mutable reference; callable many times and may change captured state.FnOnce- takes ownership of (consumes) its captures; callable once.
By default a closure borrows. The move keyword forces capture by value, moving the data into the closure so it can outlive the stack frame that built it - which is exactly what returning a closure, or sending one to another thread, requires:
fn make_adder(n: i32) -> impl Fn(i32) -> i32 {
move |y| n + y // n is moved in; the closure owns it
}
Because the compiler tracks ownership, a borrowing closure can never dangle. Rust rejects at compile time the escapes that Python and old Go allowed to blow up at runtime.
Perl: the counter that outlives its maker
Perl closures capture my lexicals by reference and keep them alive as long as the closure exists. The canonical demonstration is a counter factory:
sub make_counter {
my $n = 0;
return sub { return ++$n };
}
my $c = make_counter();
print $c->(), $c->(), $c->(), "\n"; # 123
Each call to make_counter creates a fresh $n, so every counter it hands back owns a private, persistent slot. Two counters never share a variable, and nothing outside can reach the $n inside.
OCaml and Haskell: closing over immutable bindings
In the ML family a let binding names one value and is never reassigned, so a captured binding cannot change underneath the closure. When OCaml wants mutable captured state it puts the change in an explicit cell - a ref - and captures that:
let make_counter () =
let n = ref 0 in
fun () -> incr n; !n
The binding n is still immutable; what changes is the contents of the ref it points at, and the mutation is visible in the source (incr, !) rather than hidden.
Haskell has no reassignment at all, so a captured name is a fixed value by construction. Its twist is laziness: a captured binding may be an unevaluated thunk, and Haskell evaluates each thunk at most once. Once forced, the result is memoised and reused (call-by-need), so a closure that captures an expensive computation pays for it a single time however often it runs. That interplay is explored further in loops, iterators, and laziness.
makeAdder :: Int -> (Int -> Int)
makeAdder n = \y -> n + y -- captures the immutable n
Raku: pointy blocks and lexical capture
Raku's pointy block -> $x { ... } is a lambda with named parameters, and like Perl it captures the surrounding lexicals:
my $base = 10;
my &add = -> $y { $base + $y }; # captures $base
say add(5); # 15
For persistent per-closure state Raku also offers state, a variable initialised once and retained across calls, which gives a counter without a factory:
sub counter { state $n = 0; ++$n }
say counter(); # 1
say counter(); # 2
guji: captured values that will not move
guji, the in-house compiled, statically typed, functional-first language (v0.1-alpha), captures lexical bindings the way the ML family does: closures close over immutable bindings, and each evaluation of a lambda allocates a fresh environment, so two closures built from the same lambda never share a capture slot. guji goes one step further and refuses to capture a mut binding at all, which means the shared-mutable-state pitfall behind Python's and old Go's loop bugs cannot arise. A captured value is always a value that will not move:
sub make_adder($n: Int) {
sub($y: Int): Int { $n + $y } # captures the immutable $n
}
What each language remembers
Line them up and the axis is clear. Python and pre-1.22 Go remembered the variable, and paid with loop-closure bugs. Go 1.22, Perl, and Raku give a fresh binding per iteration or per call, so state stays private. Rust makes you name the capture discipline and checks it. The ML family and guji capture immutable bindings, pushing mutation into visible cells or forbidding it in closures outright. A closure never forgets - the only question is whether what it holds is a promise or a moving target.