← History

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.

PythonGoRustPerl

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:

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.