Closures, Lexical Scope, and Captured Environments
What This Concept Is
A closure is a procedure together with the environment in which it was created. When the procedure later runs, any free variable it mentions is looked up in that captured environment, not in whoever happens to be calling it. This rule is called lexical scope: names mean what the text around them says they mean.
Three parts have to travel together to understand closures:
- a procedure (a
lambdaordef) - a set of free variables it mentions but does not itself bind
- the frame(s) that were visible at its point of definition
Once the procedure escapes its birthplace -- returned, stored, passed onward -- those frames stay alive as long as the procedure is reachable. That is why closures let you encode state without a class (SICP §1.3.4 and §3.1).
Why It Matters Here
Closures are the bridge between:
- Higher-order procedures (Concept 04): HOFs that return procedures almost always return closures.
- The environment model (Concept 07): the environment model exists exactly to explain what closures do at run time.
- Objects and mutation (Concept 13): SICP's bank-account example shows a closure can hold private state, which is the core of what "object" means.
- Interpreters (Concept 10): the interpreter's own representation of a user procedure is literally a pair of (body, environment pointer) -- a closure.
Get closures right and environments, objects, and eval stop being mysterious. Get them wrong and subtle bugs -- especially around loops in Python and JavaScript -- will haunt you for years.
Concrete Example
SICP's make-adder:
(define (make-adder n)
(lambda (x) (+ x n)))
(define add5 (make-adder 5))
(add5 10) ; => 15
When make-adder 5 is called, a new frame binds n = 5. The lambda captures a reference to that frame. When add5 later runs with x = 10, it resolves n in the captured frame and returns 15. The frame is alive even after make-adder has returned, solely because add5 is still holding it.
A second example -- stateful closure:
(define (make-counter)
(let ((count 0))
(lambda ()
(set! count (+ count 1))
count)))
(define c (make-counter))
(c) ; 1
(c) ; 2
No class, no field, no this -- just a variable captured in a closure. Each call to make-counter makes a new frame, so two counters do not share state.
Python has the same rule:
def make_counter():
count = 0
def tick():
nonlocal count
count += 1
return count
return tick
C notably does not have closures. You fake them with a struct carrying the captured state plus a function pointer -- which is exactly what a closure is, made explicit.
Common Confusion / Misconception
The most famous trap is loop-variable capture, especially in Python:
fs = [lambda: i for i in range(3)]
[f() for f in fs] # [2, 2, 2], not [0, 1, 2]
All three closures share the same i binding, which ends at 2. The fix is to bind a fresh variable per iteration, e.g. [lambda i=i: i for i in range(3)].
Two other common confusions:
- Lexical vs dynamic scope. Lexical scope uses the environment at definition; dynamic scope would use the environment at call. Scheme and Python are lexical. Dynamic scope (old-style Lisp,
let*in some shell languages) is a different beast and a much bigger source of bugs. - "Closures copy variables." They do not. They capture references to frames. Mutating a captured variable is visible to all closures sharing that frame -- intentionally.
A subtle one: recursion in returned closures. (lambda () (factorial ...)) captures factorial by name. If factorial is later rebound, the closure calls the new one. This is normal and often desirable, but can bite if you are expecting a snapshot.
How To Use It
- When you reach for a class with a single method and a couple of fields, try a closure first. Often shorter, always less ceremony.
- When passing a procedure that "needs some context," capture that context in a closure rather than threading it as a
void */ extra argument. - When debugging, trace the chain: which frame was this lambda defined in? Which frames does that frame chain to? The answer to a "why does this variable mean that?" question lives in the lexical chain.
- In Python, use
nonlocalexplicitly for captured-variable mutation; in JavaScript, preferlet/constovervarto avoid hoisting traps.
Check Yourself
- Why does
add5 = make_adder(5)still work aftermake_adderhas returned? - Write the minimal explanation of the loop-variable bug in one sentence.
- In what sense is a C
struct { int (*fn)(...); state_t *data; }the "explicit" form of a closure?
Mini Drill or Application
Build both: a make-accumulator amount (SICP 3.1) that returns a procedure that adds its argument to a running sum and returns the new sum, and a make-monitored fn that wraps a procedure so that calling it with 'how-many-calls? reports its call count. Implement each in both Scheme and Python. Then, in C, show how you would fake the accumulator with a struct + function pointer.