Lesson 12 of 34 · 0 of 3 exercises solved
Decorators & Functional Tools
A is a that takes a function and returns a new one, and the @ syntax is shorthand for applying it. Decorators work because functions in Python are ordinary objects.
import time
import functools
# A decorator is: decorated = decorator(original_function)
# @decorator is just syntax sugar for that assignment
def timer(func):
@functools.wraps(func) # preserves __name__, __doc__, etc.
def wrapper(*args, **kwargs):
start = time.perf_counter()
result = func(*args, **kwargs)
elapsed = time.perf_counter() - start
print(f"{func.__name__} took {elapsed:.4f}s")
return result
return wrapper
@timer
def slow_sum(n: int) -> int:
return sum(range(n))
slow_sum(10_000_000) # slow_sum took 0.2345s
# Decorator with arguments — a decorator factory
def retry(max_attempts: int = 3, delay: float = 0.5):
def decorator(func):
@functools.wraps(func)
def wrapper(*args, **kwargs):
last_error = None
for attempt in range(1, max_attempts + 1):
try:
return func(*args, **kwargs)
except Exception as e:
last_error = e
print(f"Attempt {attempt} failed: {e}")
if attempt < max_attempts:
time.sleep(delay)
raise last_error
return wrapper
return decorator
@retry(max_attempts=3, delay=0.1)
def flaky_operation():
import random
if random.random() < 0.7:
raise ConnectionError("Temporary failure")
return "success"from functools import lru_cache, partial, reduce
# @lru_cache — memoization (cache results of expensive calls)
@lru_cache(maxsize=128)
def fib(n: int) -> int:
if n < 2:
return n
return fib(n-1) + fib(n-2)
print(fib(40)) # fast because results are cached
print(fib.cache_info()) # CacheInfo(hits=38, misses=41, ...)
# functools.partial — pre-fill some arguments
def power(base: float, exp: float) -> float:
return base ** exp
square = partial(power, exp=2)
cube = partial(power, exp=3)
print(square(5)) # 25.0
print(cube(3)) # 27.0
# map, filter, reduce
nums = [1, 2, 3, 4, 5]
doubled = list(map(lambda x: x * 2, nums))
big = list(filter(lambda x: x > 2, nums))
total = reduce(lambda acc, x: acc + x, nums, 0)
print(doubled) # [2, 4, 6, 8, 10]
print(big) # [3, 4, 5]
print(total) # 15
# sorted with key function
words = ["banana", "apple", "cherry", "date"]
by_len = sorted(words, key=len)
by_last = sorted(words, key=lambda w: w[-1])
print(by_len) # ['date', 'apple', 'banana', 'cherry']Common question
Does a decorator change the function it decorates?
A decorator is a function that takes a function and returns a function. Python functions are objects, so you can pass them around and wrap them like any value; the @ syntax is just a tidy way to apply that wrapping when the function is defined. The wrapper can run code before and after the original, change its or result, it, retry it, or register it somewhere.
Write decorators with a wrapper that accepts *args and **, apply functools.wraps to keep the metadata, and keep them small. A decorator that needs its own settings is a function that returns a decorator. The 's cache, lru_cache, and staticmethod are all built on this idea.
Source: PEP 318, which introduced the syntax, defines @dec followed by f as "equivalent to f = dec(f)".
Common mix-ups
Each of these sounds reasonable. Think about why it is wrong, then open the explanation.
The @ syntax edits the function in place, adding behaviour to its body.
A decorator only works on functions that take no arguments.
functools.wraps is optional and has no practical effect.
Practice
Write a decorator called shout that makes any function returning a return it in UPPER CASE with an exclamation mark appended. Use functools.wraps so the decorated function keeps its name.
Your first decorator
import functools
def shout(func):
...
@shout
def greet(name):
"""Return a friendly greeting."""
return f"hello {name}"
print(greet("ada"))
print(greet.__name__)
Press Run to try your code, then Check answer to grade it.Write count_calls, a decorator that counts how many times the decorated function has been called and stores the number on the function as a .calls attribute.
Stateful decorator
import functools
def count_calls(func):
...
@count_calls
def ping():
return "pong"
ping(); ping(); ping()
print(ping.calls)
Press Run to try your code, then Check answer to grade it.The recursive fib below is correct but painfully slow: fib(35) takes seconds and fib(80) would take longer than the universe. Add a single decorator from functools so that fib(80) returns instantly, then change the print to try it.
Memoise with one line
import functools
def fib(n):
if n < 2:
return n
return fib(n - 1) + fib(n - 2)
print(fib(25)) # try 80 once the decorator is on
Press Run to try your code, then Check answer to grade it.Check yourself
What is @timer above def work(): equivalent to?
Tip: use the ← and → keys to move between lessons.