FREE LESSON · Languages & compilers · 6 OF 10
A closure retains an environment
A closure retains an environment: Trace lexical scope, bindings, mutation, and lifetime.
Prerequisites: Functions, local variables, and nested expressions.
Learning objectives
- Resolve a free variable using lexical scope.
- Distinguish captured bindings from copied snapshots.
- Explain how a returned function can outlive its defining call.
Resolve a name in the environment where code was defined
An environment maps names to bindings. Under lexical scope, the structure of the program determines which surrounding binding a name refers to. A closure packages executable code together with the environment information needed for its free variables. A free variable is used by the function but is not supplied by that function’s own parameters or local definitions.
Consider a function that creates an adder. The returned inner function uses its parameter value and the surrounding offset. Offset is therefore free in the inner function, while value is locally bound by the call.
def make_adder(offset):
def add(value):
return offset + value
return add
add_three = make_adder(3)
add_ten = make_adder(10)
assert add_three(4) == 7
assert add_ten(4) == 14
The two calls create distinct captured environments. Returning from make_adder does not make the retained offset disappear while a closure can still use it. An implementation may allocate an environment object or use another representation that preserves the same behavior. A simple stack-only lifetime rule is insufficient for every escaping closure.
Capturing can retain a binding, not a snapshot
Different languages and capture modes specify different semantics. In Python, nested functions can share a captured binding, and nonlocal assignment updates that binding. The following factory makes two operations refer to the same counter state:
def counter():
n = 0
def increment():
nonlocal n
n += 1
return n
def read():
return n
return increment, read
inc, read = counter()
assert inc() == 1
assert read() == 1
The read closure sees the new value because both closures refer to the same captured binding. A separately created counter has a separate binding. This distinction matters for callbacks created inside loops, where several closures may share a loop variable rather than retain independent copies of each iteration’s value.
Scope and lifetime are different questions
Scope determines which binding a name denotes in the program. Lifetime determines how long the associated storage must remain available during execution. A binding can be inaccessible by name in the current function yet remain reachable through a closure. Conversely, a local name can still be in textual scope after its value is no longer needed by an optimized implementation.
Closures do not automatically make shared mutation thread-safe. If several threads call operations on one captured mutable environment, synchronization follows the language’s concurrency contract. When debugging a surprising closure result, draw each environment, its parent link, and the binding each function actually captures. That trace is more reliable than treating a function as source text with values casually pasted into it.
Primary reading
- Environment Model of Evaluation (MIT OpenCourseWare, SICP).
Practice problem
Create two counters using the shown factory: inc_a, read_a and inc_b, read_b. Predict the values after calling inc_a twice and inc_b once. Explain whether read_a and read_b share the same binding. Then explain why a caller’s local variable named n does not automatically replace the n captured by read_a.
Worked solution
After two calls to inc_a, read_a returns two. After one call to inc_b, read_b returns one. Each invocation of counter creates a new environment containing its own n binding. The two closures returned by one invocation share that invocation’s binding, but closures from different invocations do not. Under lexical scope, read_a resolves n through the environment established where read_a was defined. A caller’s unrelated local variable also named n is a different binding and does not replace that environment link. This is distinct from dynamic scoping, where resolution can depend on the runtime caller chain. The implementation must retain each captured environment as long as the corresponding closures remain reachable.
Check your understanding
What does a closure need in addition to its code?
- The entire source repository.
- Access to the bindings required by its free variables.
- A new operating-system process for every call.
- A guarantee that all captured values are immutable.
Explanation
The retained environment supplies the meaning of free-variable references.
Two closures from one counter factory see the same updates because what is shared?
- Only the spelling of the function name.
- The captured n binding from that invocation.
- Every variable in every counter ever created.
- The caller’s current n variable.
Explanation
The environment contains a shared binding for that invocation, while other invocations create distinct ones.
Primary reading
- Environment Model of Evaluation: MIT OpenCourseWare, SICP