Closures capture variables, not values¶
Level: 201 · a Python programmer who has met nonlocal and the lambda-in-a-loop trap
One line: A block, proc or lambda carries the variables around it — so it sees later assignments and can make them — and a block parameter is a fresh variable per call, while an outer variable assigned in a loop is one variable shared by every closure made there.
A block, a proc and a lambda are all closures: they hold the variables of the scope that wrote them, not a copy of the values those variables had. So a lambda that reads n returns whatever n is at call time (row 1), the two lambdas made by one make_counter call share one count while a second call gets its own (row 2), and a block can assign an outer local outright (row 3). Scope runs one way: a name first assigned inside a block does not exist after it (row 4). What makes the loop trap of other languages rare in Ruby is row 5 — a block parameter is a fresh variable on every call, so three lambdas closing over |i| return 1, 2, 3 — but the trap is still there whenever the variable lives outside the block: an outer j assigned inside map, or the i of a for loop, which makes no scope of its own, is one variable, so every lambda returns 3 and i is still 3 after the loop (row 6). A block parameter with the same name as an outer local shadows it (row 7), and a |x; tmp| list after the parameters declares tmp block-local, so the block's assignment does not reach the outer tmp (row 8). The captured variable is a real one: f.binding.local_variable_get(:n) reads it from outside (row 9). And a def is the one construct here that is not a closure — a method body opens a fresh scope and cannot see the locals around it (row 10).
Verified output of closures_capture_variables_rb.rb — regenerated by tools/run_examples.py, never hand-typed.
1. a closure sees the later value of n: 2
2. two lambdas share one count; a new maker starts over: 2, 0
3. a block assigns the outer total: 6
4. a name first assigned inside stays inside: defined?(inner) = nil
5. a block parameter is fresh per call: [1, 2, 3]
6. an outer j assigned in the block; a for loop's i: [3, 3, 3], [3, 3, 3], i after the loop = 3
7. a block parameter shadows the outer x: x = 10
8. |x; tmp| makes tmp block-local: tmp = "outer"
9. the captured variable, seen from outside: f.binding.local_variable_get(:n) = 2
10. a def is not a closure: NameError: a def cannot see the outer n
Reading the output¶
Row 1 prints 2 although f was created while n was 1: the lambda holds the variable. Row 2 prints 2, 0 — two inc.calls moved the first counter's count, and the second make_counter call has a count of its own. Row 3's total is 6 after each added to it from inside the block; row 4's defined?(inner) is nil after a block assigned inner, because a variable born inside a block dies with it. Row 5 is [1, 2, 3]: three calls of the block, three variables named i, three lambdas each holding its own. Row 6 is [3, 3, 3] twice — once for an outer j assigned inside the block and once for a for loop's i — and then i after the loop = 3, the sign that for leaked its variable into the surrounding scope. Row 7 leaves x = 10, since the block's |x| was a different variable; row 8 leaves tmp = "outer", since |x; tmp| gave the block a private tmp. Row 9 reads 2 out of the lambda's binding. Row 10 is the NameError raised when a method body mentions n.
Compared with Python¶
Python's nested def and lambda close over the enclosing function's variables in the same way (rows 1 and 2), with one rule Ruby does not have: reading an outer variable is free, but assigning to it makes a new local unless the function declares it nonlocal — so total += x without the declaration is an UnboundLocalError, and with it the total is 6 (row 3). Row 4 is the same in both. Rows 5 and 6 are the famous trap, seen from Python's side: a comprehension or a for loop uses one variable, so [lambda: i for i in range(1, 4)] gives three closures that all return 3, and the fixes are a factory function whose parameter is fresh per call — exactly Ruby's block parameter — or the lambda i=i: default that copies the value at definition time. A parameter shadows (row 7) and an assignment in a def is local by default (row 8), which is why Python never needed a |x; tmp| syntax. The captured variable lives in a cell, f.__closure__[0].cell_contents, and co_freevars names it (row 9); a module-level name is a global rather than a cell, which is why the twin builds its closures inside functions. Row 10 is the difference that bites in the other direction: a nested Python def is a closure and sees n, while a Ruby def does not. Python states the rules under naming and binding ↗ and the nonlocal statement ↗, and answers the loop question in the programming FAQ ↗.
Verified output of closures_capture_variables_py.py — regenerated by tools/run_examples.py, never hand-typed.
1. a closure sees the later value of n: 2
2. two closures share one count; a new maker starts over: 2, 0
3. assigning the outer total, without / with nonlocal: UnboundLocalError / 6
4. a name first assigned inside stays inside: reading inner outside = NameError
5. a factory call or an i=i default is fresh per call: [1, 2, 3], [1, 2, 3]
6. a comprehension's i; a for loop's i: [3, 3, 3], [3, 3, 3], i after the loop = 3
7. a parameter shadows the outer x: x = 10
8. an assignment in a def is local by default: tmp = 'outer'
9. the captured variable, seen from outside: f.__closure__[0].cell_contents = 2, co_freevars = ('n',)
10. a nested def is a closure: sees n = 2
Try it¶
- Write
make_account(balance)that returns three lambdas —deposit,withdrawandbalance— sharing onebalancevariable, withwithdrawrefusing to go below zero. Open two accounts and show that moving money in one leaves the other alone. - Build three procs in a loop three ways —
eachwith a block parameter,eachassigning an outer variable, andfor— and predict each result before running. Then fix theforversion by moving the loop body into a method that takesias a parameter, the way Python's factory function does, and explain why that works although adefcannot seeiat all.
Solution to 1
closures_capture_variables_kata_rb.rb in full — pasted here by tools/run_examples.py from the file CI runs.
# Exercise 1: make_account(balance) returns three lambdas that share one
# balance variable; two accounts do not interfere, because each call of
# make_account has its own balance.
def make_account(balance)
deposit = ->(amount) { balance += amount }
withdraw = ->(amount) { amount <= balance ? (balance -= amount) : "refused: #{amount} > #{balance}" }
read = -> { balance }
{ deposit: deposit, withdraw: withdraw, balance: read }
end
a = make_account(100)
b = make_account(5)
puts "a deposit 50 -> #{a[:deposit].call(50)}"
puts "a withdraw 30 -> #{a[:withdraw].call(30)}"
puts "b withdraw 30 -> #{b[:withdraw].call(30)}"
puts "a balance -> #{a[:balance].call}"
puts "b balance -> #{b[:balance].call}"
puts "shared inside a: deposit and read see one variable: #{a[:deposit].call(0) == a[:balance].call}"
Verified output of closures_capture_variables_kata_rb.rb — regenerated by tools/run_examples.py, never hand-typed.
See also¶
- Blocks are not objects — the block whose parameters row 5 relies on
- Procs and lambdas differ in arity and
return— both kinds are closures; they differ in whatreturndoes - Procs curry and compose —
currybuilds closures one argument at a time forloops do not scope — the leakediof row 6- Default arguments are evaluated each call — a default can close over an earlier parameter
define_method— methods generated in a loop, each closing over its own nameBindingandeval—Proc#binding, the object row 9 reads through- Gotchas for Ruby programmers in Python — late binding and
nonlocalin that list - Rust: what a closure is ↗ — a closure that borrows the variables it names
- Rust: the
movekeyword ↗ — capture by value, the choice Python spellsi=i itand_1name the block's parameter — A block with no|x|can read its arguments as_1,_2, … (Ruby 2.7) or asit(Ruby 3.4); they are…- A pin compares; a bare name binds — Inside a pattern a bare name always binds