Closures and Decorators
Use retained lexical state and transparent wrappers while preserving signatures, return values, and failures.
By the end, you’ll be able to…
- Explain closure cells and late binding.
- Implement a parameterized decorator with wraps.
- Identify state and exception policies in wrappers.
01Functions retain access to names
A closure is a function that retains access to names from an enclosing function after that outer call finishes. Different calls to the factory create separate environments. This gives a small function private configuration without a global variable. It is useful for predicates and adapters, although a class may communicate the design better when the object has several related operations.
A closure retains the variable binding, not necessarily a frozen copy of its current value. Lambdas created in a loop often all observe the final loop value. Bind a default argument when you intentionally want a snapshot, or call a factory with each value. Use nonlocal when rebinding an enclosing name; mutation of an existing captured list does not require that declaration.
02A decorator replaces a binding
The @decorator syntax applies a callable to the function object during definition and binds the returned object to the original name. A typical decorator returns a wrapper that forwards arguments, calls the original function, and returns its result. Forgetting the return silently changes successful calls into None, which can make a logging decorator break an otherwise correct program.
Use functools.wraps on the wrapper so introspection keeps useful metadata and a __wrapped__ reference. Forward *args and **kwargs to preserve calling flexibility. This does not make every wrapper semantically transparent: a decorator can still change timing, exceptions, identity, or descriptor behavior. Describe those changes as part of the public interface instead of treating decoration as invisible magic.
03Factories configure wrappers
A parameterized decorator adds an outer factory layer. The factory validates configuration, returns a decorator, and that decorator returns a wrapper. Read these layers from the outside inward: configuration is supplied once at definition time, the original function is supplied once, and call arguments are supplied on every invocation. Keeping these moments separate makes state lifetimes easier to reason about.
The bounded decorator below checks a result after a successful call. If the original function raises, its exception passes through unchanged. That is a deliberate policy. A retry decorator would require a much stronger contract about transient exceptions, maximum attempts, waiting, and idempotency; retrying an operation that already charged a customer could duplicate an irreversible effect.
04State and ordering are design choices
Decorators are applied from the closest line to the function outward. Consequently, caching outside validation may skip validation on cache hits, and auditing outside authorization may record rejected calls. Draw the nested calls before stacking wrappers. A wrapper designed for synchronous functions must also be redesigned for async functions if it needs to observe the eventual result rather than a coroutine object.
Captured counters and dictionaries are shared by calls to the same decorated function. They may require synchronization under concurrency and can retain references longer than expected. functools.lru_cache is appropriate for reusable results from suitable hashable inputs, but caching depends on correctness assumptions about side effects and freshness. Test arguments, return values, metadata, and failure behavior, not merely that a message was printed.
The ideas worth keeping.
- Closures retain bindings with explicit lifetimes.
- Wrappers must preserve results and define exception behavior.
- Decorator ordering and shared state affect correctness.
Make it yours.
Type and test your code right here in the interactive workspace. Run your solution, check the output, and compare with the worked guide when ready.
Independent counters
Create make_counter(start=0). Each returned function increments and returns its own value. Verify two counters do not share state.
Explore a worked solution
def make_counter(start=0): value = start def increment(): nonlocal value value += 1 return value return incrementfirst, second = make_counter(), make_counter(10)assert [first(), first(), second()] == [1, 2, 11]nonlocal rebinds the cell belonging to one factory call. The other factory call owns a different cell.
Count successful calls
Write successful_calls, exposing wrapper.successes and incrementing it only when the wrapped function returns. Preserve the wrapped result and metadata.
Explore a worked solution
from functools import wrapsdef successful_calls(function): @wraps(function) def wrapped(*args, **kwargs): result = function(*args, **kwargs) wrapped.successes += 1 return result wrapped.successes = 0 return wrapped@successful_callsdef inverse(value): return 1 / valueassert inverse(2) == 0.5try: inverse(0)except ZeroDivisionError: passassert inverse.successes == 1Incrementing after the call preserves failures without counting them. This simple counter is for sequential use; concurrent callers need an explicit synchronization policy.
What stayed with you?
Check the ideas while they’re fresh. Your answers stay in this page and feedback appears as you go.
Independent counters
Create make_counter(start=0). Each returned function increments and returns its own value. Verify two counters do not share state.
[Process exited with code 0 - No output]
Keep exploring.
The official references behind this chapter, when you want a second angle or the full details.
One chapter closer.
Reading is a start. Trying it yourself makes the connection.