Iterators, Generators & Decorators¶
Three intermediate-level features that show up everywhere in real Python code.
Iterators — the protocol behind for¶
When you write for x in something, Python calls iter() on it to get an iterator, then keeps calling next() until StopIteration is raised.
nums = [10, 20, 30]
it = iter(nums) # get an iterator from a list
print(next(it)) # 10
print(next(it)) # 20
print(next(it)) # 30
try:
print(next(it)) # raises StopIteration — list exhausted
except StopIteration:
print("Out of items.")
So for is just sugar for this:
nums = [10, 20, 30]
it = iter(nums)
while True:
try:
x = next(it)
except StopIteration:
break
print(x)
Building a custom iterator¶
A class becomes iterable by defining __iter__ and __next__:
class Countdown:
def __init__(self, start):
self.current = start
def __iter__(self):
return self # the object itself is the iterator
def __next__(self):
if self.current <= 0:
raise StopIteration
self.current -= 1
return self.current + 1 # before decrement
for n in Countdown(5):
print(n, end=" ")
print()
Generators — easier iterators with yield¶
Writing iterators by hand is tedious. Generator functions use yield to give one value at a time:
Every time yield runs, the function pauses and returns the value. Next iteration, it resumes from there.
Why generators are amazing¶
They're lazy — values are computed on demand. Great for large or infinite sequences.
def all_squares():
n = 1
while True:
yield n * n
n += 1
g = all_squares()
for _ in range(10):
print(next(g), end=" ")
print()
An "infinite" sequence, but using almost no memory.
Compare to a list — the list version would never finish:
Generator expressions — one-liners¶
Like list comprehensions but with () instead of []:
# List — computes ALL values up front
squares_list = [n*n for n in range(1_000_000)]
# Generator — lazy, computes on demand
squares_gen = (n*n for n in range(1_000_000))
# Same behavior with for, but generator uses far less memory
print(sum(squares_gen))
Use a generator when you only iterate once and don't need all values at the same time.
Useful built-in generators¶
range(n)— already a generator-like objectenumerate(items),zip(a, b)— return iteratorsmap(fn, items),filter(fn, items)— return iterators
# Show that map/filter are lazy iterators, not lists
m = map(lambda x: x*2, [1, 2, 3])
print(type(m)) # <class 'map'>
print(list(m)) # convert to list to see values
Mini-project — Fibonacci generator¶
def fibonacci():
a, b = 0, 1
while True:
yield a
a, b = b, a + b
fib = fibonacci()
first_10 = [next(fib) for _ in range(10)]
print(first_10)
Decorators¶
A decorator is a function that wraps another function to add behavior — without changing the original.
A simple decorator¶
def shout(fn):
def wrapper(*args, **kwargs):
result = fn(*args, **kwargs)
return result.upper() + "!!!"
return wrapper
@shout
def greet(name):
return f"hello, {name}"
print(greet("alice")) # "HELLO, ALICE!!!"
print(greet("bob")) # "HELLO, BOB!!!"
@shout is the same as greet = shout(greet). The decorator replaces greet with the wrapper function.
A timing decorator (real-world)¶
import time
def timed(fn):
def wrapper(*args, **kwargs):
start = time.time()
result = fn(*args, **kwargs)
elapsed = time.time() - start
print(f"{fn.__name__} took {elapsed*1000:.2f} ms")
return result
return wrapper
@timed
def slow_add(a, b):
time.sleep(0.05)
return a + b
print(slow_add(3, 4))
print(slow_add(10, 20))
A logging decorator¶
def log_calls(fn):
def wrapper(*args, **kwargs):
print(f"→ Calling {fn.__name__}({args}, {kwargs})")
result = fn(*args, **kwargs)
print(f"← {fn.__name__} returned {result!r}")
return result
return wrapper
@log_calls
def add(a, b):
return a + b
@log_calls
def greet(name, greeting="Hello"):
return f"{greeting}, {name}!"
add(3, 4)
greet("Alice", greeting="Hi")
Stacking decorators¶
You can apply several — they run from the closest to the function outward:
def loud(fn):
def wrapper(*a, **kw):
return f"!!! {fn(*a, **kw)} !!!"
return wrapper
def angry(fn):
def wrapper(*a, **kw):
return fn(*a, **kw).upper()
return wrapper
@loud # applied SECOND
@angry # applied FIRST
def greet(name):
return f"hello, {name}"
print(greet("alice")) # "!!! HELLO, ALICE !!!"
Decorators that take arguments¶
Need an extra layer:
def repeat(times):
def decorator(fn):
def wrapper(*args, **kwargs):
for _ in range(times):
fn(*args, **kwargs)
return wrapper
return decorator
@repeat(3)
def hello(name):
print(f"Hello, {name}")
hello("Alice")
Real-world decorators you'll use¶
@property,@staticmethod,@classmethod(see OOP)@functools.cache/@functools.lru_cache— memoize function results@dataclasses.dataclass— auto-generate__init__,__repr__@app.route("/")in Flask,@app.get("/")in FastAPI
@cache example — speeds up recursive Fibonacci dramatically:
from functools import lru_cache
import time
def fib_slow(n):
if n < 2: return n
return fib_slow(n-1) + fib_slow(n-2)
@lru_cache(maxsize=None)
def fib_fast(n):
if n < 2: return n
return fib_fast(n-1) + fib_fast(n-2)
start = time.time()
print("slow fib(30) =", fib_slow(30))
print(f" took {time.time()-start:.3f}s")
start = time.time()
print("fast fib(30) =", fib_fast(30))
print(f" took {time.time()-start:.3f}s")
The cache version is hundreds of times faster — because it doesn't recompute the same fib(k) repeatedly.
Preserve function metadata — functools.wraps¶
Without wraps, the decorated function loses its name and docstring:
from functools import wraps
def timed(fn):
@wraps(fn) # preserves fn.__name__ and fn.__doc__
def wrapper(*args, **kwargs):
return fn(*args, **kwargs)
return wrapper
@timed
def my_function():
"""The original docstring."""
pass
print(my_function.__name__) # 'my_function' (not 'wrapper')
print(my_function.__doc__) # 'The original docstring.'
Always use @functools.wraps in your decorators. Free, no downside.
Wrap-up¶
| Concept | Use it for |
|---|---|
| Iterator | Anything that can be looped (built-in or custom). |
Generator (yield) |
Lazy sequences — large or infinite data. |
Generator expression (... for ...) |
One-liner lazy sequences. |
Decorator (@) |
Add cross-cutting behavior — logging, timing, caching, auth. |
@functools.wraps |
Always inside your decorators. |
@functools.cache |
Memoize pure functions. |
Practice¶
What does this print?
Expected: 0 1 1 2 3
Make count_to(3) produce 1, 2, 3 (currently produces nothing)
Expected: 1 2 3
Quiz — Quick check¶
What you remember
Q1. What's the difference between return and yield?
- No difference
-
returnends the function once;yieldpauses it and resumes on the next call -
yieldis only valid in classes -
returnis faster
Why: A function with
yieldis a generator. Eachnext()call resumes the function right after the lastyield.returnterminates the function entirely.
Q2. Why use a generator expression (x*x for x in range(N)) instead of a list comprehension [x*x for x in range(N)]?
- Generators are syntactically shorter
- Generators compute values lazily — use far less memory for large or infinite sequences
- Lists don't support
forloops - Generators are easier to read
Why: A list comprehension allocates every value up front. A generator produces one at a time. For 10 million squares, the list uses ~80MB; the generator uses ~200 bytes.
Q3. What does @functools.wraps(fn) do inside a decorator?
- Caches the function's results
- Makes the function asynchronous
- Copies the wrapped function's name and docstring onto the wrapper
- Validates arguments
Why: Without
@wraps, the decorated function would report its name as"wrapper"(whatever you named the inner function).@wraps(fn)restores__name__,__doc__, and other metadata so debugging andhelp()still work properly.
Common doubts¶
When should I use a generator vs a list?
Use a generator when (a) the sequence is huge or infinite, (b) you only iterate once, or © you might stop early. Use a list when you need to index, slice, iterate multiple times, or know the length up front.
Can I return something in a generator?
Yes — return value inside a generator becomes the value attached to the StopIteration exception when the generator finishes. Rarely needed in practice; you mostly use yield and let the function fall off the end.
How does @decorator actually work mechanically?
@decorator above def foo(): is just syntactic sugar for foo = decorator(foo). The decorator receives the original function, wraps it (usually in a new function), and returns the replacement. That's why every decorator returns a function — that returned function replaces the original name.