Python is renowned for being readable and expressive — but its real power lies in advanced features like generators and decorators that let you write elegant, memory-efficient, and reusable code. Whether you’re a second-year student encountering these for the first time, or a final-year developer sharpening your craft, this guide will make these concepts crystal clear.
What Are Generators?
A generator is a function that yields values one at a time using the yield keyword, rather than returning all values at once. This makes them highly memory-efficient when dealing with large sequences.
Basic Generator Example
def count_up(limit):
n = 0
while n < limit:
yield n
n += 1
# Usage
for number in count_up(5):
print(number) # 0, 1, 2, 3, 4
Instead of storing all numbers in memory (like a list), the generator produces each number on demand.
Generator Expressions
Much like list comprehensions, you can write compact generator expressions:
# List comprehension – stores ALL squares in memory
squares_list = [x**2 for x in range(1000000)]
# Generator expression – produces squares ONE AT A TIME
squares_gen = (x**2 for x in range(1000000))
print(next(squares_gen)) # 0
print(next(squares_gen)) # 1
💡 Teaching Note: Show students how
sys.getsizeof()dramatically differs between the list and generator versions — this makes the memory benefit tangible.
The send() Method in Generators
Generators can be two-way communication channels using .send():
def accumulator():
total = 0
while True:
value = yield total
if value is None:
break
total += value
acc = accumulator()
next(acc) # Prime the generator
print(acc.send(10)) # 10
print(acc.send(25)) # 35
print(acc.send(5)) # 40
Understanding Decorators
A decorator is a function that wraps another function, adding behaviour before or after it runs — without modifying its source code.
The Core Mechanics
def my_decorator(func):
def wrapper(*args, **kwargs):
print("⚡ Before the function call")
result = func(*args, **kwargs)
print("✅ After the function call")
return result
return wrapper
@my_decorator
def greet(name):
print(f"Hello, {name}!")
greet("Sophia")
# Output:
# ⚡ Before the function call
# Hello, Sophia!
# ✅ After the function call
The @my_decorator syntax is syntactic sugar for greet = my_decorator(greet).
Practical Decorators
1. Timing Decorator
import time
import functools
def timer(func):
@functools.wraps(func) # Preserves original function metadata
def wrapper(*args, **kwargs):
start = time.perf_counter()
result = func(*args, **kwargs)
elapsed = time.perf_counter() - start
print(f"'{func.__name__}' executed in {elapsed:.4f}s")
return result
return wrapper
@timer
def compute_sum(n):
return sum(range(n))
compute_sum(1_000_000)
# 'compute_sum' executed in 0.0423s
2. Retry Decorator
def retry(max_attempts=3):
def decorator(func):
@functools.wraps(func)
def wrapper(*args, **kwargs):
for attempt in range(1, max_attempts + 1):
try:
return func(*args, **kwargs)
except Exception as e:
print(f"Attempt {attempt} failed: {e}")
raise RuntimeError(f"Failed after {max_attempts} attempts")
return wrapper
return decorator
@retry(max_attempts=4)
def unstable_network_call():
import random
if random.random() < 0.7:
raise ConnectionError("Network error!")
return "Data received!"
3. Caching / Memoization
from functools import lru_cache
@lru_cache(maxsize=None)
def fibonacci(n):
if n < 2:
return n
return fibonacci(n - 1) + fibonacci(n - 2)
print(fibonacci(50)) # Computed instantly via caching
Chaining Multiple Decorators
Decorators are applied bottom-up:
@timer
@retry(3)
def fetch_data(url):
# ...simulated fetch
pass
# Equivalent to: timer(retry(3)(fetch_data))(url)
Generator + Decorator Synergy
Generators and decorators work beautifully together. Here’s a decorator that logs generator values:
def log_generator(func):
@functools.wraps(func)
def wrapper(*args, **kwargs):
gen = func(*args, **kwargs)
for value in gen:
print(f" → Generated: {value}")
yield value
return wrapper
@log_generator
def countdown(n):
while n > 0:
yield n
n -= 1
list(countdown(3))
# → Generated: 3
# → Generated: 2
# → Generated: 1
🤖 Interactive AI Widget: Decorator Simulator
Use the live simulator below to build and test your own Python decorator logic. Type a function body, apply a decorator, and see the wrapped output — right here in your browser.
Key Takeaways
| Concept | Key Benefit | Use Case |
|---|---|---|
| Generator | Memory efficiency | Large data pipelines, infinite streams |
yield |
Lazy evaluation | Pagination, file reading |
| Decorator | Code reusability | Logging, auth, retry, caching |
@lru_cache |
Auto memoization | Recursive functions, expensive computations |
functools.wraps |
Metadata preservation | All production decorators |
Practice Exercises
- Write a generator that produces the Fibonacci sequence infinitely.
- Build a
@validatedecorator that checks if all arguments to a function are positive integers. - Combine a
@timerdecorator with a Fibonacci generator and measure performance with and withoutlru_cache.
📚 Recommended Reading: Fluent Python by Luciano Ramalho — Chapter 7 (Functions as First-Class Objects) and Chapter 14 (Iterables, Iterators, and Generators).