Site

Closures — Python Closures and Decorators

Tutorial S7  •  Python / Learn

S7.0 What This Teaches

This tutorial covers closures, higher-order functions, and decorators in Python:

S7.1 First-Class Functions

Functions in Python are objects. You can assign them to variables, pass them as arguments, and return them from other functions:
def double(x: int) -> int:
    return x * 2

def apply(func, values: list) -> list:
    return [func(v) for v in values]

print(apply(double, [1, 2, 3, 4]))   # [2, 4, 6, 8]
print(apply(str, [1, 2, 3]))          # ['1', '2', '3']
print(apply(abs, [-1, -2, 3]))        # [1, 2, 3]

# Functions as dict values
ops = {"+": lambda a, b: a + b, "-": lambda a, b: a - b}
print(ops["+"](3, 4))   # 7

S7.2 Closures

A closure is an inner function that captures variables from the enclosing scope. The captured variables persist as long as the closure is alive:
def make_adder(delta: int):
    def adder(x: int) -> int:
        return x + delta   # captures delta from outer scope
    return adder

add5  = make_adder(5)
add10 = make_adder(10)

print(add5(3))    # 8
print(add10(3))   # 13

# Counter using a closure with mutable state
def make_counter(start: int = 0):
    count = [start]   # list to allow mutation in inner scope
    def increment():
        count[0] += 1
        return count[0]
    return increment

counter = make_counter()
print(counter())   # 1
print(counter())   # 2

S7.3 Decorators

A decorator is a function that takes a function and returns a new function. The @decorator syntax is shorthand for func = decorator(func):
import time
from functools import wraps

def timer(func):
    @wraps(func)                # preserves func.__name__, __doc__, etc.
    def wrapper(*args, **kwargs):
        start = time.monotonic()
        result = func(*args, **kwargs)
        elapsed = time.monotonic() - start
        print(f"{func.__name__} took {elapsed:.4f}s")
        return result
    return wrapper

@timer
def slow_sum(n: int) -> int:
    return sum(range(n))

print(slow_sum(1_000_000))  # prints timing and result

S7.4 Stacking and Parameterized Decorators

from functools import wraps

def repeat(n: int):
    """Decorator factory that runs the function n times."""
    def decorator(func):
        @wraps(func)
        def wrapper(*args, **kwargs):
            for _ in range(n):
                result = func(*args, **kwargs)
            return result
        return wrapper
    return decorator

def log(func):
    @wraps(func)
    def wrapper(*args, **kwargs):
        print(f"calling {func.__name__}")
        return func(*args, **kwargs)
    return wrapper

@log
@repeat(3)    # applied bottom-up: repeat first, then log
def greet(name: str):
    print(f"Hello, {name}!")

greet("Alice")

S7.5 functools Utilities

from functools import partial, reduce

# partial - freeze some arguments
def power(base: float, exp: float) -> float:
    return base ** exp

square = partial(power, exp=2)
cube   = partial(power, exp=3)
print(square(4))   # 16.0
print(cube(3))     # 27.0

# reduce - fold a sequence to a single value
product = reduce(lambda acc, x: acc * x, [1, 2, 3, 4, 5])
print(product)   # 120

# lru_cache - memoize with Least Recently Used eviction
from functools import lru_cache

@lru_cache(maxsize=None)
def fib(n: int) -> int:
    if n < 2: return n
    return fib(n - 1) + fib(n - 2)

print(fib(40))   # fast due to caching

S7.6 Example - All Together

# Closures - validation decorator and pipeline composition.

from functools import wraps, reduce

def validate(*predicates):
    """Decorator that checks all predicates before calling the function."""
    def decorator(func):
        @wraps(func)
        def wrapper(*args, **kwargs):
            for pred in predicates:
                if not pred(*args, **kwargs):
                    raise ValueError(f"validation failed: {pred.__name__}")
            return func(*args, **kwargs)
        return wrapper
    return decorator

def positive(x): return x > 0
def less_than_100(x): return x < 100

@validate(positive, less_than_100)
def process(x: float) -> float:
    return x * 2

print(process(42))    # 84.0
try:
    print(process(-1))  # raises ValueError
except ValueError as e:
    print(e)

S7.7 Exercise

Exercise
  • Write a memoize decorator that caches results in a dict. Apply it to a recursive Fibonacci function and compare performance with and without the decorator.
  • Write a retry(n) parameterized decorator that retries a function up to n times on exception. Test it with a function that fails randomly.
  • Use functools.partial to create specialized versions of a generic format_number(value, width, decimal_places) function.

S7.8 Common Mistakes

Forgetting @wraps on a decorator

def my_decorator(func):
    def wrapper(*args, **kwargs):     # missing @wraps(func)
        return func(*args, **kwargs)
    return wrapper

@my_decorator
def greet(): pass
print(greet.__name__)   # "wrapper" - lost the original name!
Always use @functools.wraps(func) inside a decorator so that __name__, __doc__, and other metadata are preserved.

Late binding in closures

adders = [lambda x: x + i for i in range(5)]
print(adders[0](0))   # 4, not 0! All lambdas capture the same i=4

# Fix: capture current value with a default argument
adders = [lambda x, i=i: x + i for i in range(5)]
print(adders[0](0))   # 0

S7.9 Key Terms

TermMeaning
first-class functionFunction that can be assigned, passed, and returned like any value
closureInner function that captures and remembers variables from its enclosing scope
decoratorFunction that wraps another function, typically added with @syntax
@wrapsCopies __name__, __doc__, etc. from the wrapped function to the wrapper
partialCreates a new function with some arguments pre-filled
reduceFolds a sequence to a single value by applying a two-argument function
lru_cacheMemoization decorator with configurable cache size; caches recent results
late bindingClosures capture variable references, not values - value resolved at call time