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"
Python (loads on first run)Ctrl+Enter runs
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']
Python (loads on first run)Ctrl+Enter runs

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.

Exercise

Your first decorator

3 checks
import functools


def shout(func):
    ...


@shout
def greet(name):
    """Return a friendly greeting."""
    return f"hello {name}"


print(greet("ada"))
print(greet.__name__)
 
Python (loads on first run)Ctrl+Enter runs
Output
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.

Exercise

Stateful decorator

3 checks
import functools


def count_calls(func):
    ...


@count_calls
def ping():
    return "pong"


ping(); ping(); ping()
print(ping.calls)
 
Python (loads on first run)Ctrl+Enter runs
Output
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.

Exercise

Memoise with one line

3 checks
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
 
Python (loads on first run)Ctrl+Enter runs
Output
Press Run to try your code, then Check answer to grade it.

Check yourself

Quick check

What is @timer above def work(): equivalent to?

Finished reading?3 exercises still open. You can come back to them later.

Tip: use the ← and → keys to move between lessons.