~ $ cat closures-why-your-functions-remember-things.md
Closures: why your functions remember things
Write a little function that makes counters:
def make_counter():
count = 0
def increment():
nonlocal count
count += 1
return count
return increment
a = make_counter()
b = make_counter()
a() # 1
a() # 2
b() # 1
a and b count independently — each one remembers its own count. But here’s the part
that should bother you: by the time you call a(), make_counter has already returned. Its
count variable should be gone; locals die when a function returns. Yet there it is, still
incrementing, three calls later.
That’s a closure. And once you see what it’s actually holding onto, a surprising amount of everyday code stops looking like magic.
What a closure actually is
A closure is a function bundled together with the variables it uses from the scope around it.
That’s the whole definition. When you write a function that refers to a variable declared
outside itself — count, above — the language can’t hand you the function on its own. The
function is useless without that variable. So it captures it: the function and the outside
variables it depends on travel together, as one unit. That unit is the closure.
(One Python wrinkle you can see in the code: reading a captured variable just works, but
reassigning one — like count += 1 — needs the nonlocal keyword. Without it, Python sees an
assignment and assumes you’re making a brand-new local. nonlocal says “no, I mean the captured
one.” Languages differ on this detail; the capture itself is the universal part.)
The key word is captures, and we’ll come back to how literally it means it. First, the
question that makes closures click: where does count actually live?
Where the captured variable lives
Normally, a local variable lives on the stack. You call a function, it gets a stack frame holding its locals; the function returns, the frame is popped, and the locals are gone. That’s the simple, fast rule — and it’s exactly the rule the counter seems to break.
Closures are the exception, and they pay for it. When the language sees that an inner function
refers to count, it knows count has to outlive make_counter. So it doesn’t put count on
the stack frame that’s about to vanish. It puts it on the heap — the memory that lives until
nothing references it anymore — and the returned function keeps a reference to it.
So a and b each came from their own run of make_counter, which means each got its own
count on the heap. They share nothing. That’s why they count independently: two separate
captured variables, two separate functions holding onto them.
This is also why closures aren’t free. A captured variable can’t be cleaned up when its function returns, because something still points at it. It stays alive as long as the closure does. Usually that’s exactly what you want. Occasionally it’s a leak you didn’t mean to make — a giant object kept alive by a tiny callback that happened to mention it.
Why they exist at all
Closures aren’t a feature someone bolted on for fun. They fall out, almost inevitably, of one decision: making functions first-class values — things you can return from other functions, pass as arguments, and store in variables.
The moment functions can be passed around, they start outliving the scope that created them.
You return a function from make_counter and call it later, somewhere else entirely. But a
function that referenced count still needs count to mean something when it finally runs.
There are only two ways out. Either you ban functions from referring to anything outside
themselves — which makes them far less useful — or you let the referenced variables come along
for the ride. Closures are the second choice. They’re the price, and the reward, of treating
functions as values.
That’s why you find closures in language after language — Python, JavaScript, Swift, Go, Rust, Lisp — and not by coincidence. Any language with first-class functions hits the same problem and reaches for the same answer.
What closures let you build
Closures look like a curiosity until you notice how much they quietly hold up.
Private state. The count in make_counter can’t be read or changed from outside. There’s
no syntax to reach it — the only door in is the function you returned. That’s real
encapsulation, built from nothing but scope. In Python it’s actually more private than a class
attribute: a leading underscore, or even a name-mangled __count, is private only by
convention, but a closed-over variable has no public name at all.
Callbacks that remember context. Schedule Timer(5, lambda: save(doc)) and the saved-off
function runs five seconds later — long after the surrounding function returned — and it still
knows which doc you meant. That’s a closure capturing doc. The same thing happens with a
button wired to command=lambda: save(doc), a sort key like sorted(rows, key=lambda r: r.score),
or any callback you hand to a framework. A callback that couldn’t remember the variables around
it would be nearly useless.
Function factories. make_counter is one. So is a function that takes a multiplier and
returns a function that multiplies by it:
def multiply_by(n):
return lambda x: x * n
double = multiply_by(2)
triple = multiply_by(3)
double(10) # 20
triple(10) # 30
double and triple are the same function body with a different captured n. You build
specialized functions out of general ones, with the specialization stored in the closure. The
same idea shows up as memoization (a cache captured in a closure), partial application, and
decorators that wrap a function while remembering it — all of them this one mechanism wearing
different hats.
The bug everyone hits once
Closures capture variables, not values — and the difference bites in a loop.
funcs = []
for i in range(3):
funcs.append(lambda: i)
[f() for f in funcs] # [2, 2, 2] — not [0, 1, 2]
You expected [0, 1, 2]. You got [2, 2, 2]. The reason is exactly the definition: each lambda
captured the variable i, not the value i had at that instant. The loop reuses one i the
whole way through — and in Python it even outlives the loop. By the time you call the functions,
the loop is long done and that single shared i is stuck at its final value, 2. All three
closures look at the same variable and read the same number.
Python has no block-scoped let to reach for here, so the usual fix is to bind the value as a
default argument:
funcs = []
for i in range(3):
funcs.append(lambda i=i: i)
[f() for f in funcs] # [0, 1, 2]
lambda i=i: i looks strange, but it’s doing something precise: a default argument is evaluated
now, at definition time, so each lambda freezes its own copy of the current i instead of
sharing the one loop variable. (A factory function — a small def that takes i as a parameter
and returns the closure — does the same job if you find the default-argument trick too cute.)
Whenever a closure seems to “see the wrong value,” this is almost always why: it’s holding a
variable that kept changing after it was captured.
The whole idea, in one line
Strip away the syntax and a closure is a small, sturdy thing: a function plus the part of its birthplace it still needs. The language keeps that piece of the past alive for exactly as long as the function might use it, and not a moment longer.
You were almost certainly using closures before you had a name for them — every callback that remembered a variable, every handler that knew which thing you clicked. The name just lets you reason about it on purpose: what is this function holding onto, where does that live, and how long will it stay? Once you can answer those three, closures stop being magic and start being a tool.