Skip to content

for loops do not make a scope

Level: 201 · a Python programmer who reaches for for, and a Ruby programmer who wonders why nobody else does

One line: for and while are syntax and open no scope — the loop variable and anything first assigned in the body are still there afterwards — while a block is a scope of its own, which is why Ruby code says each; Python's for and while leak the same way, its only scoped loop is a comprehension, and a function is where a temporary lives.

for x in collection is collection.each in disguise: it calls each and assigns each yielded value to x — but x is a variable of the enclosing scope, as is every variable the body assigns, so they all survive the loop, and an empty collection still leaves x defined, because the parser saw the assignment. while and until are the same: no scope. A block is different. Its parameters are new variables that exist only inside it, and a local first assigned inside it is a block-local too; only names that already existed outside are shared, and those the block can read and assign. That difference is why the idiomatic loop is each, times, upto, step or loop with a block: nothing leaks, and a lambda created inside sees its own iteration's value rather than a shared variable's last one. The program leaves each kind of loop behind and looks at what is still defined.

Verified output of for_loops_do_not_scope_rb.rb — regenerated by tools/run_examples.py, never hand-typed.

 1. for i in 1..3; x = i; end; then          -> i = 3, x = 3  (defined? local-variable / local-variable)
 2. [10, 20].each { |j| y = j }; then        -> defined?(j) -> nil; y -> NameError
 3. for v in Bag.new                         -> Bag#each called; v after the loop = :b
 4. while n < 2; w = n; ...; then            -> n = 2, w = 1
 5. times / upto / step / loop               -> defined?(t) nil, (tt) nil, (u) nil, (s) nil; loop counted k = 2
 6. z = nil; [1].each { |q| z = q }          -> z = 1
 7. for a, b in [[1, 2], [3, 4]]; then       -> a = 3, b = 4
 8. for ... end as a value                   -> [1, 2]; with break m * 10 -> 20
 9. [1].each { |q; outer| outer = ... }      -> outer = "outer"
10. for none in []; end; then none           -> nil  (defined? "local-variable")
11. lambdas capturing the loop variable      -> for -> [3, 3, 3]; each -> [1, 2, 3]

Reading the output

Row 1: after for i in 1..3; x = i; end, both i and x are still defined, holding 3. Row 2: after [10, 20].each { |j| y = j }, defined?(j) is nil and reading y is a NameError — the block parameter and the block-local both ended with the block. Row 3: for really did call Bag#each, and v is still there afterwards, holding the last yielded value. Row 4: while leaks w just as for leaks x.

Row 5: the block-taking iterators — times, upto, step, loop — leave nothing behind, while loop was still able to count in the outer k, which row 6 states directly: a block reads and assigns a variable that already exists outside it, and only new names are local to it. Row 7: for a, b in pairs destructures, and both names leak. Row 8: for is an expression like everything else — its value is the collection it iterated, or the argument of a break. Row 9 is the escape hatch for a name clash: |q; outer| declares outer block-local, so the assignment inside shadowed the outer outer instead of overwriting it. Row 10: even for none in [], which never runs its body, leaves none defined, holding nil. Row 11 is the practical consequence: three lambdas made in a for loop all return 3, because they closed over the one shared c, while three made in each return 1, 2, 3, because each block call had its own d.

Compared with Python

Python's for and while behave like Ruby's: i, x and w survive (rows 1 and 4), for calls __iter__ where Ruby calls each (row 3), a destructuring for a, b leaks both (row 7), and lambdas made in a for all see the last value (row 11) — the classic late-binding trap, which a comprehension does not fix, because the comprehension's variable is also one variable; the lambda d=d default is the fix. Where Python differs is in what it offers as a scope. It has no blocks; its one scoped loop is the comprehension, whose variable is gone afterwards (row 2) and which shadows rather than assigns an outer name (row 9, the twin of Ruby's |q; outer|). For everything else the scope is a function (row 5) — with the mirror-image rule of row 6: a loop at module level assigns the module's z, but an assignment inside a def creates a new local (unless the name is declared global or nonlocal (Not machine-checked here.)). Two smaller differences: for is a statement with no value (row 8), and the leaked name can be removed with del; and an empty loop never binds its variable at all (row 10), where Ruby's parser had already created it.

Verified output of for_loops_do_not_scope_py.py — regenerated by tools/run_examples.py, never hand-typed.

 1. for i in 1..3; x = i; end; then          -> i = 3, x = 3
 2. [10, 20].each { |j| y = j }; then        -> after [j * j for j in [10, 20]]: j -> NameError
 3. for v in Bag.new                         -> Bag.__iter__ called; v after the loop = 'b'
 4. while n < 2; w = n; ...; then            -> n = 2, w = 1
 5. times / upto / step / loop               -> inside def count(): t and tt; outside: t -> NameError, tt -> NameError
 6. z = nil; [1].each { |q| z = q }          -> for: z = 1; after `def assign(): z = 2`: z = 1
 7. for a, b in [[1, 2], [3, 4]]; then       -> a = 3, b = 4
 8. for ... end as a value                   -> SyntaxError for x = for ...; after del i: i -> NameError
 9. [1].each { |q; outer| outer = ... }      -> outer = 'outer'
10. for none in []; end; then none           -> none -> NameError
11. lambdas capturing the loop variable      -> for -> [3, 3, 3]; comprehension -> [3, 3, 3]; lambda d=d -> [1, 2, 3]

The Python library has no page on loop scope; the rules are in the reference's naming and binding ↗ section.

Try it

  1. Run the same body under a for and under an each (total = i), then audit the leaks by checking local_variables.include?(name) for the loop variables and the body variables. Finish by building three lambdas inside a for and three inside an each and calling them all.
  2. Write each_pair_leaking(hash) with for k, v in hash and its each-based twin; then find the one situation where leaking is what you want — reading the last processed element after the loop — and write it both ways.
Solution to 1

for_loops_do_not_scope_kata_rb.rb in full — pasted here by tools/run_examples.py from the file CI runs.

# Exercise 1: the same body under for and under each, then a leak audit with
# local_variables, then three lambdas from each kind of loop.

for i in 1..3
  total_for = i
end

(1..3).each do |j|
  total_each = j
end

%i[i total_for j total_each].each do |name|
  puts format("%-10s leaked? %s", name, local_variables.include?(name))
end

from_for = []
for k in 1..3
  from_for << -> { k }
end

from_each = []
(1..3).each { |m| from_each << -> { m } }

puts "lambdas from for  -> #{from_for.map(&:call).inspect}"
puts "lambdas from each -> #{from_each.map(&:call).inspect}"

Verified output of for_loops_do_not_scope_kata_rb.rb — regenerated by tools/run_examples.py, never hand-typed.

i          leaked? true
total_for  leaked? true
j          leaked? false
total_each leaked? false
lambdas from for  -> [3, 3, 3]
lambdas from each -> [1, 2, 3]

See also