description: "Iterators, Generators & Coroutines — A for loop over any object relies on two dunder methods working together: iter (returns an iterator) and next…"---
02 · Iterators, Generators & Coroutines¶
🎥 Video walkthrough¶
Level 2 introduced generators as a convenient way to produce values lazily.
This module looks underneath: the iterator protocol that for loops actually
rely on, how generators implement that protocol automatically, and how
generators can be extended into simple coroutines that receive values, not
just produce them.
The iterator protocol¶
A for loop over any object relies on two dunder methods working together:
__iter__ (returns an iterator) and __next__ (returns the next value, or
raises StopIteration when exhausted).
class CountUp:
"""A custom iterable that counts from `start` to `end` inclusive."""
def __init__(self, start, end):
self.start = start
self.end = end
def __iter__(self):
self.current = self.start
return self # this object is its own iterator
def __next__(self):
if self.current > self.end:
raise StopIteration
value = self.current
self.current += 1
return value
for n in CountUp(1, 5):
print(n) # 1 2 3 4 5
# what a `for` loop actually does under the hood:
it = iter(CountUp(1, 3))
while True:
try:
print(next(it))
except StopIteration:
break
Separating "iterable" (has __iter__) from "iterator" (has __next__) means
the same iterable can be iterated multiple times, each producing a fresh
iterator — which is exactly why CountUp.__iter__ resets self.current.
Generators implement the protocol for you¶
A generator function automatically produces an object with working
__iter__ and __next__ methods — you never have to write StopIteration
by hand; it's raised for you when the function returns.
def count_up(start, end):
current = start
while current <= end:
yield current
current += 1
gen = count_up(1, 3)
print(hasattr(gen, "__iter__"), hasattr(gen, "__next__")) # True True
print(next(gen), next(gen), next(gen)) # 1 2 3
try:
next(gen)
except StopIteration:
print("exhausted")
Generator internals: send, throw, close¶
Generators can receive values back through yield, not just produce them —
this is what makes them usable as simple coroutines.
def running_average():
total = 0
count = 0
average = None
while True:
value = yield average # pauses here; resumes when send() is called
total += value
count += 1
average = total / count
avg = running_average()
next(avg) # "prime" the generator — advances to the first yield
print(avg.send(10)) # 10.0
print(avg.send(20)) # 15.0
print(avg.send(30)) # 20.0
avg.close() # explicitly stop the generator
send(value) resumes the generator, making the paused yield expression
evaluate to value, then runs until the next yield (or return).
def resilient_worker():
while True:
try:
item = yield
print(f"processing {item}")
except ValueError as e:
print(f"recovered from: {e}")
worker = resilient_worker()
next(worker)
worker.send("task-1")
worker.throw(ValueError("bad task")) # injects an exception at the paused yield
worker.send("task-2")
Generator pipelines¶
Because generators are lazy, you can chain several together and nothing runs until the final consumer pulls values through the whole chain.
def read_lines(lines):
yield from lines
def non_empty(lines):
for line in lines:
if line.strip():
yield line
def upper(lines):
for line in lines:
yield line.upper()
raw = ["hello", "", " ", "world", ""]
pipeline = upper(non_empty(read_lines(raw)))
print(list(pipeline)) # ['HELLO', 'WORLD']
Each stage only processes one item at a time as the consumer pulls it — no stage builds a full intermediate list.
itertools — building blocks for iterators¶
The standard library's itertools module has efficient, well-tested versions
of common iterator patterns.
import itertools
print(list(itertools.islice(itertools.count(10), 5)))
# [10, 11, 12, 13, 14] — count() is infinite; islice takes just the first 5
print(list(itertools.chain([1, 2], [3, 4])))
# [1, 2, 3, 4] — flatten multiple iterables into one
print(list(itertools.groupby("aaabbbcca")))
# [('a', <itertools._grouper>), ('b', ...), ('c', ...), ('a', ...)]
# groupby only groups CONSECUTIVE equal items — sort first if you need full grouping
for size, group in itertools.groupby("aaabbbcca"):
print(size, list(group))
Coroutines vs. async/await¶
The send-based coroutine style above predates Python's native async def /
await syntax and is rarely written by hand today — but it's the mechanism
async def functions are built on. Level 3's
Concurrency II — Asyncio module covers the modern
async/await style, which you should reach for in real code.
Cheat sheet¶
| Concept | What it does |
|---|---|
__iter__ |
returns an iterator for an iterable |
__next__ |
returns the next value or raises StopIteration |
yield |
pauses a generator function, producing a value |
gen.send(value) |
resumes the generator, injecting value at the paused yield |
gen.throw(exc) |
resumes the generator by raising exc at the paused yield |
itertools |
fast, memory-efficient iterator utilities |
How It Actually Works¶
for n in CountUp(1, 5) compiles to exactly the manual while/try/except loop
shown above — the bytecode instructions are GET_ITER (calls iter(obj), which
dispatches to obj.__iter__()) followed by a FOR_ITER that repeatedly calls the
resulting iterator's __next__() and catches StopIteration internally to exit the
loop. There's no special-casing for lists vs. custom classes vs. generators here —
anything implementing this exact two-method protocol works identically, which is
also why a for loop over a dict iterates keys (its __iter__ yields keys) while
.items() returns a different iterable/iterator pair.
A generator function's returned object is a genuine implementation of this same
protocol, auto-generated by the interpreter: count_up.__iter__ just returns self,
and count_up.__next__ resumes the frame execution up to the next yield (raising
StopIteration when the function body finally returns). What makes send() and
throw() possible is that this frame is a real, addressable object sitting on the
heap with its own saved instruction pointer and local variables — gen.send(10)
resumes execution at exactly the bytecode offset just after the YIELD_VALUE
instruction that paused it, but first pushes 10 onto that frame's value stack as
the result of the yield expression (not just the statement), which is why
value = yield average can both produce a value out and receive one in through the
same syntax. throw() similarly resumes the frame, but instead of pushing a value it
injects an exception at that exact paused point, letting a try/except wrapped
around the yield inside the generator catch it — this is precisely the low-level
mechanism async def coroutines and await were later built on top of (an await
is, underneath, a yield that suspends the frame back to an event loop instead of to
manual caller code).
itertools.count(10) and similar functions are implemented in C as lightweight
iterator objects holding just a counter and a step — no list is ever materialized,
which is why islice(itertools.count(10), 5) can safely pull 5 values from an
infinite sequence: islice calls next() exactly 5 times and then stops, discarding
the (never-ending) rest. groupby works by keeping only the previous item and
comparing it to each new one as it's pulled — it has no memory of anything before
that, which is mechanically why it only merges runs of consecutive equal items
rather than doing a full grouping the way collections.Counter or a dict-based
grouping would.
Exercise¶
Write a class-based iterator Fibonacci(limit) that yields Fibonacci numbers
up to limit using the __iter__/__next__ protocol directly (no yield).
Then rewrite it as a generator function and confirm both produce identical
output. Finally, write a generator-based coroutine moving_max() that accepts
values via send() and always yields back the maximum value seen so far.