IntermediatePython ยท Lesson 2

Decorators and Generators

Create and use Python decorators for cross-cutting concerns, and generators for memory-efficient iteration

Decorators

A decorator is a function that wraps another function to add behavior.

# Basic decorator
def my_decorator(func):
    def wrapper(*args, **kwargs):
        print("Before call")
        result = func(*args, **kwargs)
        print("After call")
        return result
    return wrapper

@my_decorator
def say_hello(name):
    print(f"Hello, {name}!")

say_hello("Alice")
# Before call
# Hello, Alice!
# After call

Preserving Metadata with functools.wraps

from functools import wraps

def my_decorator(func):
    @wraps(func)          # preserves func.__name__, func.__doc__
    def wrapper(*args, **kwargs):
        return func(*args, **kwargs)
    return wrapper

Timer Decorator

import time
from functools import wraps

def timer(func):
    @wraps(func)
    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_function():
    time.sleep(0.1)
    return "done"

slow_function()  # slow_function took 0.1004s

Decorator with Arguments (Decorator Factory)

def repeat(n):
    def decorator(func):
        @wraps(func)
        def wrapper(*args, **kwargs):
            for _ in range(n):
                result = func(*args, **kwargs)
            return result
        return wrapper
    return decorator

@repeat(3)
def hello():
    print("Hello!")

hello()
# Hello!
# Hello!
# Hello!

Caching with functools.cache

from functools import cache, lru_cache

@cache
def fibonacci(n):
    if n < 2:
        return n
    return fibonacci(n - 1) + fibonacci(n - 2)

print(fibonacci(100))  # 354224848179261915075 โ€” instant!

# lru_cache with size limit:
@lru_cache(maxsize=128)
def expensive(n):
    return sum(range(n))

Class-Based Decorators

class Retry:
    def __init__(self, times):
        self.times = times

    def __call__(self, func):
        @wraps(func)
        def wrapper(*args, **kwargs):
            for attempt in range(self.times):
                try:
                    return func(*args, **kwargs)
                except Exception as e:
                    if attempt == self.times - 1:
                        raise
                    print(f"Attempt {attempt+1} failed: {e}, retrying...")
        return wrapper

@Retry(3)
def flaky_function():
    import random
    if random.random() < 0.7:
        raise ValueError("Random failure")
    return "Success!"

Generators

Generators produce values lazily โ€” they yield one at a time.

# Generator function
def count_up(start, stop):
    n = start
    while n <= stop:
        yield n
        n += 1

gen = count_up(1, 5)
print(next(gen))  # 1
print(next(gen))  # 2
for n in count_up(1, 5):
    print(n)      # 1, 2, 3, 4, 5

yield from

def flatten(nested):
    for item in nested:
        if isinstance(item, list):
            yield from flatten(item)
        else:
            yield item

data = [1, [2, [3, 4], 5], [6, 7]]
print(list(flatten(data)))  # [1, 2, 3, 4, 5, 6, 7]

Infinite Generators

def natural_numbers():
    n = 1
    while True:
        yield n
        n += 1

def take(n, iterable):
    for i, item in enumerate(iterable):
        if i >= n:
            break
        yield item

print(list(take(5, natural_numbers())))  # [1, 2, 3, 4, 5]

Generator Expressions

# Like list comprehensions but lazy
squares_gen = (x**2 for x in range(1000000))  # no memory cost!
print(next(squares_gen))   # 0
print(next(squares_gen))   # 1
print(sum(x**2 for x in range(100)))  # 328350

# Large file processing:
def read_large_file(path):
    with open(path) as f:
        yield from f   # yields one line at a time

Send Values to Generators

def accumulator():
    total = 0
    while True:
        value = yield total
        if value is None:
            break
        total += value

gen = accumulator()
next(gen)           # prime the generator
print(gen.send(10)) # 10
print(gen.send(20)) # 30
print(gen.send(5))  # 35

Exercises

Exercise 1: Logger Decorator

Write a decorator that logs function name, arguments, and return value.

Solution:

from functools import wraps

def logger(func):
    @wraps(func)
    def wrapper(*args, **kwargs):
        arg_str = ", ".join(map(repr, args))
        kwarg_str = ", ".join(f"{k}={v!r}" for k, v in kwargs.items())
        all_args = ", ".join(filter(None, [arg_str, kwarg_str]))
        result = func(*args, **kwargs)
        print(f"{func.__name__}({all_args}) = {result!r}")
        return result
    return wrapper

@logger
def add(a, b):
    return a + b

add(3, 5)    # add(3, 5) = 8

Exercise 2: Fibonacci Generator

Write a generator that produces Fibonacci numbers indefinitely.

Solution:

def fibonacci():
    a, b = 0, 1
    while True:
        yield a
        a, b = b, a + b

gen = fibonacci()
fibs = [next(gen) for _ in range(10)]
print(fibs)  # [0, 1, 1, 2, 3, 5, 8, 13, 21, 34]

Exercise 3: Pipeline with Generators

Create a generator pipeline: generate numbers โ†’ filter evens โ†’ square them โ†’ take first 5.

Solution:

def numbers():
    n = 1
    while True:
        yield n
        n += 1

def evens(nums):
    for n in nums:
        if n % 2 == 0:
            yield n

def squared(nums):
    for n in nums:
        yield n ** 2

def take(n, nums):
    for i, num in enumerate(nums):
        if i >= n:
            break
        yield num

pipeline = take(5, squared(evens(numbers())))
print(list(pipeline))  # [4, 16, 36, 64, 100]