skip to content

In Ruby, how can a lambda returned from a method keep incrementing a local variable after that method has returned?

level: middleimportance: must knowfreq 62%

answer

  1. the variable, not its value
  2. locals move to a heap environment
  3. one environment per method call
  4. two lambdas, one shared clicks
  5. Proc#binding lists what was kept

basics

~20 s

A Ruby lambda closes over the local variable itself, not a copy of its value. When the lambda is created, CRuby moves the method's locals into a heap environment, so the lambda can still read and reassign clicks after the method returns.

solid answer

~30 s

Blocks, procs and lambdas capture the local variables in scope where they are written, by reference. When `click_counter` builds `-> { clicks += 1 }` and returns it, CRuby moves that call's locals into a heap-allocated environment, so the lambda keeps a live `clicks` after the method's stack frame is gone, and each `call` reassigns that same variable. Every call to `click_counter` creates a fresh environment, so two counters are independent, while two lambdas created in the same call share one `clicks`. A reassignment the method makes after creating the lambda is visible to it as well, because nothing was copied.

code

ruby · 16 lines
ruby
def click_counter
  clicks = 0
  increment = -> { clicks += 1 }
  current   = -> { clicks }
  clicks = 10          # reassigned after both lambdas exist
  [increment, current]
end

increment, current = click_counter
increment.call        # => 11
increment.call        # => 12
current.call          # => 12

other_increment, other_current = click_counter
other_increment.call  # => 11
current.call          # => 12, the first counter is untouched

go deeper

for a junior

Remember that a Ruby lambda keeps using the local variable it was written next to, so a returned counter keeps counting between calls.

for a middle

Explain that CRuby moves the method's locals to a heap environment when the lambda is created, that each call makes a new environment, and that lambdas from one call share it.

for a senior

Show the consequences: later reassignments leak into earlier closures, Proc#binding exposes the whole scope, and long-lived closures keep every local of their method reachable.

for a principal

Weigh closure-held state against an explicit object: a factory lambda hides state cheaply, but a small class is easier to inspect, test, reset and make thread-safe.

## What the lambda actually holds A **closure** is a callable that remembers the scope it was written in. In Ruby every block, `proc` and lambda is one. The key fact for interviews is that it remembers **variables**, not the **values** those variables held at creation time. The `Proc` class documentation puts it as "they remember and can use the entire context in which they were created". A local variable normally lives in the method's stack frame, which disappears when the method returns. When CRuby turns a block into a `Proc` object, it first moves the enclosing frame's locals into a heap-allocated **environment** (`vm_make_env_object` in `vm.c`). From then on the method and the proc read and write the same slots, and the environment lives as long as anything references it. Every way of making a callable from a block captures scope the same way: `lambda`, the `->` literal, `proc`, `Proc.new` and a block captured through an `&block` parameter. The differences between procs and lambdas concern argument checking and what `return` leaves, not what they close over. ## Walking through a click counter ```ruby def click_counter clicks = 0 increment = -> { clicks += 1 } current = -> { clicks } clicks = 10 [increment, current] end ``` 1. `clicks = 0` creates the local. 2. Creating the first lambda moves the method's locals to the heap; both lambdas point at that one environment. 3. `clicks = 10` runs after the lambdas exist. Because they share the variable, both now see `10`. 4. The method returns the pair; the frame is gone but the environment is not. 5. `increment.call` evaluates `clicks += 1`, which is `clicks = clicks + 1` on the shared slot: `11`, then `12`, and `current.call` reports `12`. ## One environment per call | Situation | Do they share `clicks`? | |---|---| | Two lambdas created in the **same** call to `click_counter` | Yes, one environment | | Lambdas from **two separate** calls | No, each call made its own environment | | The method body and a lambda it created | Yes, the method sees the lambda's writes while it is still running | | A lambda and an instance variable `@clicks` | The lambda captured `self`, so it reads that object's ivar; every lambda made on the same object shares it | This is why a factory method is the idiomatic way to build independent counters, and why returning two lambdas from one call is a way to give a reader and a writer the same private state without a class. ## Closures versus a counter object The same counter could be a class with `@clicks` and an `increment` method. Interviewers often ask for both and the trade-off between them: | Aspect | Factory returning lambdas | Class holding `@clicks` | |---|---|---| | Privacy | Only the returned lambdas can name `clicks` | Every method of the class can read `@clicks` | | Adding behaviour | Return another lambda from the same call | Add a method | | Debugging | `Proc#binding`, or nothing | `inspect` shows the instance variable | | Shape | A pair of `Proc` objects | One named object | The closure version is shorter and genuinely private; the object version is easier to extend, test and read in a stack trace. ## Reassignment versus mutation - **Reassigning** the captured variable (`clicks += 1`, `clicks = 0`) is visible to every closure sharing the environment, because they share the variable. - **Mutating** an object the variable points to (`log << :click`) is also visible, and would be even if only the object were shared. - A **block parameter** or a variable first assigned inside the lambda body belongs to that invocation and is not shared. - Nothing is captured by name lookup at call time: the parser decided when it read the lambda which names are outer locals. ## Seeing what was captured `Proc#binding` returns a `Binding` for the environment a lambda closed over. `increment.binding.local_variables` lists `:clicks` and also `:increment` and `:current`, even though the lambda's body mentions only `clicks`: CRuby keeps the whole scope, not just the names the body uses. `increment.binding.local_variable_get(:clicks)` reads the live count. ## Where it goes wrong in practice - Expecting each lambda to snapshot the value it saw, then being surprised that a later reassignment in the method changes its result. - Building one counter per call and expecting them to share a total; share an explicit object or return several lambdas from one call instead. - Keeping many long-lived lambdas created in large methods, which keeps every local of those methods reachable. - Treating `clicks += 1` as atomic when several threads call the lambda; it is a read, an add and a write.

  • What changes if click_counter stores the count in @clicks instead of a local?
    The lambda then captures `self`, the object `click_counter` ran on, and reads that object's `@clicks`. Every lambda created on the same object shares one count across calls, so the factory no longer gives independent counters, and the count is visible to the object's other methods.
  • How can you inspect the value a counter lambda is holding without calling it?
    `increment.binding.local_variable_get(:clicks)` reads the captured local through `Proc#binding`. `increment.binding.local_variables` lists every local the environment kept, which includes names the body never uses, such as the other lambda's variable.

A closure holds a key to a room with a whiteboard, not a photo of the whiteboard: every key-holder sees whatever is written there now, and each call to the factory builds a new room with its own board.

saying these in an interview costs you the question

  • The lambda copies the value of clicks when it is created
  • Locals are destroyed when the method returns, so the lambda raises NameError
  • Every call to click_counter shares one clicks variable, like a class variable
  • clicks += 1 inside the lambda creates a new local that shadows the outer one
  • Only lambdas capture locals; plain blocks and procs do not