Functions, Closures & Decorators

Difficulty

What makes something a closure

def make_multiplier(factor):
    def multiply(x):
        return x * factor       # `factor` is captured from the enclosing scope
    return multiply

double = make_multiplier(2)
triple = make_multiplier(3)
double(5)   # 10
triple(5)   # 15

multiply is a closure over factor — even after make_multiplier returns, multiply still has access to its own factor, because Python keeps the enclosing variable alive as long as any closure references it. double and triple each capture a different factor because each call to make_multiplier creates a fresh scope.

The late-binding trap

funcs = []
for i in range(3):
    funcs.append(lambda: i)

[f() for f in funcs]   # [2, 2, 2]  -- NOT [0, 1, 2]!

Every lambda captures the variable i by reference, not its value at the time the lambda was created. Since all three lambdas share the same enclosing scope (the loop body doesn't create a new scope per iteration — loops don't introduce scopes in Python at all), by the time they're called, i has already finished the loop and holds its final value, 2.

The fix: bind the value via a default argument

funcs = []
for i in range(3):
    funcs.append(lambda i=i: i)   # default arg is evaluated NOW, at def-time

[f() for f in funcs]   # [0, 1, 2]

Default argument values are evaluated once, when the lambda/def is created — using i=i copies the current value of i into the function's own default, decoupling it from the loop variable's later mutations.

Alternative fix: a factory function

def make_getter(value):
    return lambda: value    # closes over `value`, a fresh parameter per call

funcs = [make_getter(i) for i in range(3)]
[f() for f in funcs]   # [0, 1, 2]

Each call to make_getter creates a genuinely new scope with its own value, so each returned lambda captures a distinct variable — this is the same principle as make_multiplier above, just applied to fix the loop pitfall.

Interview-ready summary: Closures capture variables by reference to the enclosing scope, not by value — which is powerful for building factories/decorators but means closures created in a loop all share the loop variable and see its final value unless you force early binding (default argument trick or a separate factory function per iteration).

What @decorator actually does

def shout(func):
    def wrapper(*args, **kwargs):
        result = func(*args, **kwargs)
        return result.upper()
    return wrapper

@shout
def greet(name):
    return f"hello, {name}"

# equivalent to:
# def greet(name): ...
# greet = shout(greet)

greet("ada")   # 'HELLO, ADA'

@shout just rebinds the name greet to whatever shout(greet) returns — here, the wrapper closure, which calls the original greet and transforms its result. func is captured in wrapper's closure, so it's still callable even though greet itself has been reassigned.

A decorator that takes its own arguments

def repeat(times):
    def decorator(func):
        def wrapper(*args, **kwargs):
            results = []
            for _ in range(times):
                results.append(func(*args, **kwargs))
            return results
        return wrapper
    return decorator

@repeat(times=3)
def roll_die():
    import random
    return random.randint(1, 6)

# equivalent to:
# roll_die = repeat(times=3)(roll_die)

roll_die()   # [4, 1, 6]  (three rolls)

Three levels: repeat(times) returns decorator; decorator(func) returns wrapper; calling wrapper(...) finally runs the original function. @repeat(times=3) evaluates repeat(times=3) first (producing decorator), then applies that to roll_die — this is why a parameterized decorator always needs parentheses even with no arguments inside them (@repeat()), while a plain decorator never does (@shout, not @shout()).

Stacking multiple decorators

@decorator_a
@decorator_b
def f(): ...

# equivalent to: f = decorator_a(decorator_b(f))

Decorators apply bottom-up: the one closest to the function runs first (wraps first), so the outermost decorator in the source is the outermost wrapper at call time.

Class-based decorators

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)

@CountCalls
def greet():
    return "hi"

greet(); greet()
greet.calls   # 2

Any object with __call__ can be a decorator — a class-based decorator is useful when the wrapper needs to hold non-trivial state (here, a call counter) rather than a single closed-over variable.

Interview-ready summary: @decorator is sugar for func = decorator(func); a decorator that itself takes arguments needs an extra function layer (args -> decorator -> wrapper). Decorators stack bottom-up, and anything callable — including a class implementing __call__ — can serve as one.

The problem without wraps

def logged(func):
    def wrapper(*args, **kwargs):
        return func(*args, **kwargs)
    return wrapper

@logged
def add(a, b):
    """Add two numbers."""
    return a + b

add.__name__    # 'wrapper'  -- lost the real name!
add.__doc__     # None       -- lost the docstring!
help(add)        # shows wrapper's (empty) signature/doc, not add's

Every function decorated with logged reports its name as "wrapper" and loses its docstring — this breaks help(), auto-generated API docs, debuggers, and any code that inspects func.__name__ (e.g., logging frameworks that print the function name).

The fix

import functools

def logged(func):
    @functools.wraps(func)
    def wrapper(*args, **kwargs):
        return func(*args, **kwargs)
    return wrapper

@logged
def add(a, b):
    """Add two numbers."""
    return a + b

add.__name__    # 'add'
add.__doc__     # 'Add two numbers.'
add.__wrapped__ # <function add at ...> -- the original, un-decorated function

functools.wraps(func) is itself a decorator (built on functools.update_wrapper) that copies __name__, __doc__, __module__, __qualname__, and __dict__ from func onto wrapper, and sets wrapper.__wrapped__ = func so introspection tools can find the original if needed.

Why this matters beyond cosmetics

  • Debugging/tracebacks: stack traces show the wrapper's name unless metadata is copied, making it harder to tell which decorated function actually failed.
  • Documentation tools (Sphinx, help()) rely on __doc__/__name__ to generate correct docs — without wraps, every decorated function's docs would show up as generic wrapper boilerplate.
  • Other decorators/frameworks that inspect __name__ (e.g., some testing or routing frameworks match handlers by function name) would silently break.

Interview-ready summary: A decorator's inner wrapper function shadows the original function's identity (__name__, __doc__, etc.) unless you explicitly restore it with @functools.wraps(func) — treat it as mandatory boilerplate any time you write a decorator, not an optional nicety.

Basic usage: memoizing an expensive pure function

import functools

@functools.lru_cache(maxsize=128)
def fib(n):
    if n < 2:
        return n
    return fib(n - 1) + fib(n - 2)

fib(35)   # fast -- each unique n is computed once, then cached
fib.cache_info()   # CacheInfo(hits=32, misses=36, maxsize=128, currsize=36)

Without caching, naive recursive fib is exponential (recomputes the same sub-calls repeatedly). lru_cache turns it into linear time by remembering every fib(n) result already computed — an "LRU" (least recently used) eviction policy discards the oldest entries once maxsize is exceeded (or never evicts, if maxsize=None).

Requirements and gotchas

  • Arguments must be hashable — you can't cache a call with a list or dict argument, since the cache key is built from the arguments themselves (as if put in a dict). Pass tuples or frozensets instead.
  • Only for pure functions — the cache doesn't know if the function has side effects or depends on external mutable state; caching a function whose result depends on something other than its arguments (current time, a database row that can change) will return stale results.
  • Memory: maxsize=None caches unboundedly — fine for a small, fixed input domain, dangerous for functions called with unbounded/unique arguments (e.g., caching by user-supplied string with no upper bound).
  • Caching instance methods: @lru_cache on a method caches by (self, *args), which keeps self alive for as long as the cache entry exists — this can prevent garbage collection of instances you expected to be freed. A common workaround is caching a module-level function that takes only the needed hashable data instead of the whole self.

cache vs lru_cache

@functools.cache   # Python 3.9+, equivalent to lru_cache(maxsize=None)
def expensive(n):
    ...

functools.cache is a simpler alias for an unbounded cache, useful when you know the input domain is naturally small/finite and don't need eviction.

Interview-ready summary: lru_cache memoizes pure functions keyed by their (hashable) arguments, turning repeated/recursive expensive computation into cheap lookups — but it requires hashable arguments, assumes no side effects or external state dependence, and can extend an object's lifetime unexpectedly when applied to instance methods.

Basic usage

from functools import partial

def power(base, exponent):
    return base ** exponent

square = partial(power, exponent=2)
cube = partial(power, exponent=3)

square(5)   # 25
cube(5)     # 125

square and cube are new callables with exponent pre-bound; calling them only needs the remaining argument (base).

Equivalent lambda

square = lambda base: power(base, exponent=2)

Functionally similar for simple cases, but partial has real advantages:

  • Introspectable: square.func, square.args, square.keywords let you inspect what's pre-filled — useful for debugging or building generic tooling around partially-applied callables.
  • Picklable: partial objects can be pickled (as long as the underlying function and bound arguments are), which matters for multiprocessing (worker functions passed to a process pool must be picklable) — a lambda cannot be pickled at all.
  • Clearer intent: partial(power, exponent=2) reads as "power, with exponent fixed at 2," which is more self-documenting than a lambda wrapping a call.

A common real use: multiprocessing/callback APIs

from functools import partial
from multiprocessing import Pool

def process(item, config):
    return item * config["factor"]

with Pool() as pool:
    results = pool.map(partial(process, config={"factor": 3}), [1, 2, 3])

Since worker functions passed across process boundaries must be picklable, partial is the standard way to "bake in" extra arguments for a function used as a callback, where a lambda would fail to pickle.

Interview-ready summary: partial pre-fills some of a function's arguments and returns a new, real callable object — functionally similar to a wrapping lambda, but introspectable and picklable, which matters for tooling and multiprocessing in particular.