Decorators and Closures
50 min · Advanced
Need help? Ask a tutor on WhatsApp (opens in a new tab)Decorators are a favourite interview topic because they combine functions as values, closures and clean code in a few lines. Each section builds one step with a working program, from a closure to a decorator with arguments, then the question bank gives short answers.
Closures and nonlocal
Closure is a function that remembers variables from the function where it was defined, even after that function has returned.
A function can be created inside another function and returned. The inner function keeps the outer variables it uses.
To change such a variable, and not just read it, the inner function declares it nonlocal.
Example 1 · A counter closure with nonlocal
def make_counter(): count = 0
def step(): nonlocal count count += 1 return count
return step
a = make_counter()b = make_counter()print(a(), a(), b())print(a.__closure__[0].cell_contents)1 2 1 2
What this code does
- Each call to
make_countercreates a newcountand a newstepthat remembers it, soareaches 2 whilebis still at 1. - The remembered variable is stored in a cell on the function.
cell_contentsshows its current value, 2 fora.
Example 2 · The late-binding trap in a loop
funcs = [lambda: n for n in range(3)]print([f() for f in funcs])
fixed = [lambda n=n: n for n in range(3)]print([f() for f in fixed])[2, 2, 2] [0, 1, 2]
What this code does
- All three lambdas read the same variable
n, and by the time they run the loop has left it at 2, so you get[2, 2, 2]. - The default argument
n=nis evaluated when each lambda is created, so each one keeps its own value.
Each call to make_counter makes its own count, so a and b count separately. The values live in cells stored in __closure__. The second example shows the famous trap: all three lambdas share one variable n, which is 2 when they run. A default argument n=n copies the value at creation time.
Writing a decorator
Decorator is a function that takes a function, wraps it with extra behaviour and returns the new function. The line @name above a def is short for func = name(func).
Logging, timing and login checks are needed on many functions. A decorator adds them in one place, without changing each function.
The wrapper calls the original function, so it must accept any arguments.
Example 3 · A logging decorator with functools.wraps
from functools import wraps
def log_calls(func): @wraps(func) def wrapper(*args, **kwargs): print(f"calling {func.__name__}{args}") result = func(*args, **kwargs) print(f"{func.__name__} returned {result}") return result return wrapper
@log_callsdef add(a, b): """Add two numbers.""" return a + b
add(2, 3)print(add.__name__, add.__doc__)calling add(2, 3) add returned 5 add Add two numbers.
What this code does
@log_callsreplacesaddwithwrapper. Callingadd(2, 3)prints the call, runs the real function, prints the result and returns it.wrapscopied the original name and docstring, so the last line printsadd Add two numbers.and notwrapper None.
@wraps(func) copies the name and docstring of add onto the wrapper. Without it, add.__name__ would be wrapper, which breaks debugging and tools that read the name. Always write *args, **kwargs and return the result.
Decorators with arguments, and stacking
Decorator factory is a function that takes arguments and returns a decorator, as in @repeat(3). It adds one more level of nesting.
When a decorator needs settings, such as how many times to repeat, write three levels: factory, decorator, wrapper.
Several decorators can sit on one function. They are applied from the bottom up.
Example 4 · A decorator with arguments and stacked decorators
from functools import wraps
def repeat(times): def decorator(func): @wraps(func) def wrapper(*args, **kwargs): for _ in range(times): result = func(*args, **kwargs) return result return wrapper return decorator
def shout(func): @wraps(func) def wrapper(*args, **kwargs): return func(*args, **kwargs).upper() return wrapper
@shout@repeat(2)def greet(name): print("hi", name) return f"hello {name}"
print(greet("asha"))hi asha hi asha HELLO ASHA
What this code does
repeat(2)is a factory: it returns the decorator that wrapsgreetso the body runs twice, which printshi ashatwo times.- Decorators apply bottom up, so
shoutwraps the repeated function and upper-cases only the final returned text.
@repeat(2) first calls repeat(2), which returns the real decorator. For the stack, greet = shout(repeat(2)(greet)): the bottom one wraps first, and the top one runs first when you call. The repeated function printed twice, and shout then made the final text upper case.
Class-based decorators and lru_cache
Memoization means saving the result of a function call and reusing it when the same arguments come again. functools.lru_cache does this with a decorator.
A class with __call__ can be a decorator and keeps its state in attributes, such as a call count. The standard library also gives ready-made decorators, such as lru_cache.
Both appear often in interviews.
Example 5 · A class-based decorator that counts calls
class CountCalls: def __init__(self, func): self.func = func self.calls = 0
def __call__(self, *args, **kwargs): self.calls += 1 return self.func(*args, **kwargs)
@CountCallsdef ping(): return "pong"
ping()ping()print(ping.calls)2
What this code does
@CountCallscreates an object that stores the function, andping()runs__call__on that object.- The count lives on the object, so
ping.callsis 2 after two calls.
Example 6 · Memoization with lru_cache
from functools import lru_cache
@lru_cache(maxsize=None)def fib(n): return n if n < 2 else fib(n - 1) + fib(n - 2)
print(fib(30))print(fib.cache_info())832040 CacheInfo(hits=28, misses=31, maxsize=None, currsize=31)
What this code does
- Without a cache,
fib(30)repeats the same work over a million times. With it, each value from 0 to 30 is computed once. cache_info()shows 31 misses (the first time each number was seen) and the hits that reused stored results.
In CountCalls, the decorated name becomes an object, so ping.calls is available. lru_cache stores results by arguments, so the recursive fib computes each number once instead of exponentially many times. Its arguments must be hashable. Use cache_info() to see hits and misses.
Next step
Check your understanding
12 quick questions