Scope, closures & decorators
Capture state in a closure and wrap behaviour with a decorator.
Why closures and decorators are everywhere
Every time you write @app.route, @pytest.fixture, or @functools.lru_cache, you are using both ideas at once. A closure lets a function carry state without a class or a global variable. A decorator lets you add behaviour (timing, retries, auth checks, caching) around a function without touching its body. In real codebases these keep cross-cutting logic in one place instead of copy-pasted into every function.
Scope: how Python resolves a name
When you use a name, Python searches four scopes in order: Local, Enclosing, Global, Built-in (LEGB). An inner function can read names from the enclosing function for free. To rebind one, you must declare it nonlocal; otherwise any assignment inside the function marks that name as local for the whole function, so reading it before the assignment runs raises UnboundLocalError.
A closure captures its enclosing scope
A closure is an inner function plus a live link to the variables it read from the function that created it. Those variables stay alive after the outer function returns.
def make_multiplier(factor):
def multiply(x):
return x * factor # 'factor' comes from the enclosing scope
return multiply
triple = make_multiplier(3)
print(triple(5)) # 15
multiply closes over factor. Because it keeps a reference to that variable (not a snapshot copy), you can build state that survives between calls:
def make_counter():
count = 0
def step():
nonlocal count # rebind the enclosing variable, not just read it
count += 1
return count
return step
c = make_counter()
print(c(), c(), c()) # 1 2 3
Decorators wrap a function
@double replaced identity with wrapper; the call lands here
A decorator is a higher-order function: it takes a function and returns a replacement. The demo below defines double, whose inner wrapper calls the original fn and doubles the result. Writing @double above identity is exactly identity = double(identity), so the name identity now points at wrapper, and identity(5) returns 10.
Pitfall: late binding in loops
Closures capture the variable, not its value at definition time:
funcs = [lambda: i for i in range(3)]
print([f() for f in funcs]) # [2, 2, 2], not [0, 1, 2]
Every lambda shares the same i, which has reached 2 by the time you call them. Bind the value eagerly with a default argument: lambda i=i: i gives [0, 1, 2].
Interview nuance: a naive decorator hides the function it wraps. After @double, identity.__name__ is "wrapper" and its docstring is None, which breaks debuggers, introspection, and some frameworks. The fix is to decorate wrapper with functools.wraps(fn), which copies __name__ and __doc__ from the original and sets __wrapped__ to point back at it, so the wrapped function still looks like itself.
def double(fn):
def wrapper(x):
return fn(x) * 2
return wrapper
@double
def identity(x):
return x
print(identity(5)) # 10Apply
Your turn
The task this lesson builds to.
Implement scaled(factor, value) using a closure: define an inner function that captures
factor and multiplies its argument by it, then call that inner function on value and return
the result.
scaled(3, 5) is 15.
2 hints and 4 automated checks are waiting in the workspace.
Practice
Make it stick
A second problem on the same idea, so it survives past today.
Implement double_result(n): write a decorator double that doubles whatever its wrapped
function returns, apply it to a function that returns its argument, and return the result for n.
double_result(5) is 10.
2 hints and 4 automated checks are waiting in the workspace.