Nested Functions and Closures
A closure is an inner function that keeps hold of variables from the function that created it, long after that function has returned.
- Nested functions
- What makes it a closure
- Seeing the captured values
- Each closure has its own copy
- Closures that hold state
- Returning several closures over one state
- Closures compared with classes
- The late binding trap
- Practical closures
- A configurable validator
- Memoising by hand
- A rate limiter
- Common mistakes
- Best practices
- Practice
- Conclusion
- Basics
- Data Types
- Operators
- Strings
- Control Flow
- Lists
- Tuples
- Sets
- Dictionaries
- Comprehensions
- Functions
- Advanced Functions
- Recursion
- Exception Handling
- File Handling
- Modules
- Standard Library
- OOP
- Advanced OOP
- Iterators and Generators
- Decorators
- Context Managers
- Descriptors and Dataclasses
- Python Internals
- Concurrency
- Regular Expressions
- Serialization
- Command Line Python
- Testing and Debugging
- Type Hints
- Performance
- Python Security
- DSA with Python
Nested functions
def outer():
def inner():
return "from inner"
return inner()
print(outer()) # from inner
# print(inner()) # NameError - inner exists only inside outerDefining a function inside another is ordinary Python. The inner function is created fresh every time the outer one runs, and it is invisible from outside.
Use a nested function when a helper is meaningful only to one function, and when it needs access to that function's local variables.
What makes it a closure
def multiplier(factor):
def multiply(n):
return n * factor # factor comes from the ENCLOSING scope
return multiply
triple = multiplier(3)
print(triple(7)) # 21multiplier has already returned by the time triple(7) runs. Its local variable factor should be gone. It is not, because multiply referred to it, so Python kept it alive attached to the function object.
multiplier(3) ──► creates multiply
and a cell holding factor = 3
│
triple ───────────────────────┘
triple(7) ──► looks up n locally, factor in its cell ──► 21Three conditions define a closure:
- There is a nested function.
- The inner function refers to a variable from the enclosing function.
- The outer function returns the inner one.
Seeing the captured values
def multiplier(factor):
def multiply(n):
return n * factor
return multiply
triple = multiplier(3)
print(triple.__closure__) # a tuple of cells
print(triple.__closure__[0].cell_contents) # 3
print(triple.__code__.co_freevars) # ('factor',)Each closure has its own copy
double = multiplier(2)
triple = multiplier(3)
print(double(10), triple(10)) # 20 30
print(double.__closure__[0].cell_contents) # 2
print(triple.__closure__[0].cell_contents) # 3Every call to the factory produces an independent function with its own captured environment.
Closures that hold state
def make_counter():
count = 0
def increment():
nonlocal count # without this, count += 1 raises
count += 1
return count
return increment
tick = make_counter()
print(tick(), tick(), tick()) # 1 2 3
other = make_counter()
print(other()) # 1 - a separate counternonlocal is required because count += 1 is an assignment, which would otherwise make count local to increment. Reading alone never needs it.
Returning several closures over one state
def make_account(balance=0):
def deposit(amount):
nonlocal balance
balance += amount
return balance
def withdraw(amount):
nonlocal balance
if amount > balance:
raise ValueError("insufficient funds")
balance -= amount
return balance
def current():
return balance
return deposit, withdraw, current
deposit, withdraw, current = make_account(100)
deposit(50)
withdraw(30)
print(current()) # 120Three functions share one hidden variable. Nothing outside can reach balance directly, which is genuine encapsulation - the same thing a class gives you, with less ceremony.
Closures compared with classes
def make_counter():
count = 0
def increment():
nonlocal count
count += 1
return count
return increment
class Counter:
def __init__(self):
self.count = 0
def increment(self):
self.count += 1
return self.count| Closure | Class |
|---|---|
| Less code for one behaviour | Clearer for several behaviours |
| State is genuinely private | State is inspectable |
| Hard to serialise or debug | Easy to print and test |
| One function, one job | Extensible by subclassing |
One captured value and one behaviour: use a closure. More than that: use a class.
The late binding trap
functions = []
for i in range(3):
functions.append(lambda: i)
print([f() for f in functions]) # [2, 2, 2]A closure captures the variable, not the value it held at creation time. All three functions share the loop variable i, and by the time they are called the loop has finished with i equal to 2.
# Fix 1: a default argument is evaluated at definition time
functions = [lambda i=i: i for i in range(3)]
print([f() for f in functions]) # [0, 1, 2]
# Fix 2: a factory gives each function its own scope
def make(value):
return lambda: value
functions = [make(i) for i in range(3)]
print([f() for f in functions]) # [0, 1, 2]
# Fix 3: partial
from functools import partial
functions = [partial(lambda v: v, i) for i in range(3)]
print([f() for f in functions]) # [0, 1, 2]This trap appears constantly with event handlers and callbacks created in a loop. If several callbacks all behave as though they belong to the last item, this is why.
Practical closures
A configurable validator
def min_length(n):
def validate(text):
return len(text) >= n
return validate
checks = [min_length(3), min_length(8)]
print([check("hello") for check in checks]) # [True, False]Memoising by hand
def memoise(func):
cache = {}
def wrapper(n):
if n not in cache:
cache[n] = func(n)
return cache[n]
return wrapper
def slow_square(n):
print("computing", n)
return n * n
fast = memoise(slow_square)
print(fast(4)) # computing 4 -> 16
print(fast(4)) # 16, no computationThat is a decorator without the @ syntax. Decorators are exactly this pattern with nicer notation, and they have their own note.
A rate limiter
def limit_calls(maximum):
used = 0
def call(func, *args):
nonlocal used
if used >= maximum:
return "limit reached"
used += 1
return func(*args)
return call
limited = limit_calls(2)
print(limited(str.upper, "a")) # A
print(limited(str.upper, "b")) # B
print(limited(str.upper, "c")) # limit reachedCommon mistakes
- Forgetting
nonlocaland meetingUnboundLocalError. - Using
globalwherenonlocalwas meant. - Falling into late binding when creating functions in a loop.
- Returning
inner()instead ofinner. - Building a closure over a large object and keeping it alive far longer than intended.
- Using a closure where a class would be easier to test and debug.
Best practices
- Use a closure for a single configured behaviour; use a class beyond that.
- Always bind loop values explicitly when creating functions in a loop.
- Use
nonlocalonly for the variable you genuinely need to update. - Name factory functions so the call site reads well:
min_length(3), notmake(3). - Remember that a closure keeps its captured objects alive; do not capture more than you need.
Practice
- Write a factory producing a function that raises numbers to a fixed power.
- Build a counter that can also be reset, returning two closures over one variable.
- Demonstrate the late binding trap with three callbacks, then fix it in two ways.
- Write a memoising wrapper and prove it avoids recomputation.
- Rewrite the account example as a class and compare the two versions.
Conclusion
A closure is a function plus the variables it captured from where it was defined. It gives you private state without a class, it requires nonlocal to update that state, and it captures variables rather than values - which is the whole explanation of the late binding trap.