PyPython · Lesson 12 of 14

Decorators & Functional Tools

A decorator is just a function that takes a function and returns a function. The @syntax is syntactic sugar. It looks magic; it is not magic. It is just clever use of the fact that functions are objects.

Python
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
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']
◆ Note
functools.wraps is not optional — without it, your decorated function loses its __name__ and __doc__, which breaks introspection, logging, and error messages. Always include it in every wrapper you write.