Functions in Python are full-fledged objects. You can assign them to variables, store them in data structures, pass them into callers, or return them from outer scopes. Once you combine this first-class capability with closures and decorators, you unlock clean architectural patterns without polluting call sites.
Let’s unpack what the @decorator syntax is actually doing under the hood by building our own caching and repetition wrappers from scratch.
Let’s use a function from our previous post on Fibonacci numbers to see this in action.
Because functions are objects, they come with some handy attributes. For instance, you can get the docstring with __doc__ and the function’s name with __name__.
Calculate the nth Fibonacci number using an iterative approach.
fibonacci_iterative
And just like other objects, functions inherit from the object base class.
Functions are more than callable blocks of code — and that flexibility is what makes closures and decorators possible.
Benchmarking Fibonacci Implementations
To really drive home the point that functions are first-class citizens, let’s put a few of our Fibonacci functions into a list and pass them to a benchmarking function. This function will test each one and see how they perform.
Show the code
def fibonacci_iterative(n: int) -> int:
"""Calculate the nth Fibonacci number using an iterative approach."""
if n < 0:
raise ValueError("Fibonacci is not defined for negative numbers.")
if n < 2:
return n
a, b = 0, 1
for _ in range(n - 1):
a, b = b, a + b
return b
def fibonacci_recursive(n: int) -> int:
"""Calculate the nth Fibonacci number using a recursive approach."""
if n < 0:
raise ValueError("Fibonacci is not defined for negative numbers.")
if n < 2:
return n
return fibonacci_recursive(n - 1) + fibonacci_recursive(n - 2)
FIBONACCI_CACHE: dict[int, int] = {}
def fibonacci_recursive_cached(n: int) -> int:
"""Calculate the nth Fibonacci number using recursion with memoization."""
if n < 0:
raise ValueError("Fibonacci is not defined for negative numbers.")
if n < 2:
return n
if n in FIBONACCI_CACHE:
return FIBONACCI_CACHE[n]
result = (
fibonacci_recursive_cached(n - 1) + fibonacci_recursive_cached(n - 2)
)
FIBONACCI_CACHE[n] = result
return resultShow the code
import time
from typing import Callable
def benchmark(functions: list[Callable[[int], int]], n: int) -> None:
"""Benchmarks a list of functions."""
for func in functions:
start = time.perf_counter()
func(n)
end = time.perf_counter()
print(f"Function {func.__name__} took {end - start:.6f} seconds.")
fib_functions = [
fibonacci_recursive,
fibonacci_recursive_cached,
fibonacci_iterative,
]
benchmark(fib_functions, 30)Function fibonacci_recursive took 0.169600 seconds.
Function fibonacci_recursive_cached took 0.000012 seconds.
Function fibonacci_iterative took 0.000003 seconds.
Closures: the Scope Persistence Puzzle
Here is a puzzle: when a Python function finishes executing, all its local variables and execution frames are popped off the call stack and garbage collected.
Now look at get_greeter:
Show the code
Good morning, World!
When get_greeter("Good morning") returns, its execution frame is finished. In theory, the greeting variable should be dead. Yet when you invoke good_morning_greeter("World") minutes later, it still greets you with "Good morning".
How does the inner function hold onto a variable whose enclosing scope was already destroyed?
That is a closure. Python attaches a hidden tuple of cell objects to the returned function object (__closure__), freezing references to the outer variables it relies on.
We can inspect this directly:
This shows that the value "Good morning" is tucked away in our good_morning_greeter function’s closure.
Closures are a key ingredient for decorators, which we’ll get to next. They also let us create “function factories” that can produce specialized functions.
For a more mathematical example, let’s create a function factory that builds a polynomial function from a tuple of coefficients.
A polynomial is defined as \(P(x) = \sum_{i=0}^{n} a_i x^i = a_n x^n + a_{n-1} x^{n-1} + \dots + a_1 x + a_0\).
Show the code
def polynomial_factory(coefficients: tuple[float, ...]) -> Callable:
"""
A factory that creates a polynomial function from a tuple of coefficients.
The coefficients are ordered from the highest power to the lowest.
"""
def polynomial(x: float) -> float:
"""
Evaluates the polynomial for a given x.
"""
return sum(c * (x**i) for i, c in enumerate(reversed(coefficients)))
return polynomial
1p1 = polynomial_factory((2, 3, 5))
2p2 = polynomial_factory((1, 0, 0, -8))
print(p1(5))
print(p2(5))- 1
- \(P(x) = 2x^2 + 3x + 5\)
- 2
- \(Q(x) = x^3 - 8\)
70
117
Here, polynomial_factory returns a new polynomial function. This new function’s closure captures the coefficients tuple. So, p1 will always be the polynomial \(2x^2 + 3x + 5\), and p2 will always be \(x^3 - 8\).
Decorators
Decorators provide clean syntactic sugar for wrapping a function or method in another callable. In our previous post, we used @cache from functools. Let’s build a caching decorator ourselves to see the mechanics.
Show the code
from functools import wraps
def my_cache(func: Callable) -> Callable:
"""A simple cache decorator."""
_cache = {}
1 @wraps(func)
def wrapper(*args):
if args in _cache:
return _cache[args]
result = func(*args)
_cache[args] = result
return result
return wrapper
@my_cache
def fibonacci_cached_by_me(n: int) -> int:
"""Calculate the nth Fibonacci number using recursion with our own cache decorator."""
if n < 0:
raise ValueError("Fibonacci is not defined for negative numbers.")
if n < 2:
return n
return fibonacci_cached_by_me(n - 1) + fibonacci_cached_by_me(n - 2)
fibonacci_cached_by_me(30)- 1
-
@wraps(func)copies__name__,__doc__, and module attributes fromfunctowrapper. Without it,fibonacci_cached_by_me.__name__becomes'wrapper', breaking debuggers, loggers, and test suites.
832040
Decorators can also accept arguments. To achieve this, we create a decorator factory—a function that returns the decorator itself:
Show the code
def repeat(times: int) -> Callable:
"""A decorator that repeats a function call a given number of times."""
def decorator(func: Callable) -> Callable:
@wraps(func)
def wrapper(*args, **kwargs):
for _ in range(times):
result = func(*args, **kwargs)
return result
return wrapper
return decorator
1@repeat(3)
def say_hello(name: str):
print(f"Hello, {name}!")
say_hello("World")- 1
-
Equivalent to
say_hello = repeat(3)(say_hello).
Hello, World!
Hello, World!
Hello, World!
In the next post, we will build a custom decorator to profile memory allocations of iterators and generators using tracemalloc.
Download the full script from python_functions_closures_and_decorators_explained.py.
