Module 2
Iteration, generators & comprehensions
A forloop in Python is not magic over lists — it's a tiny protocol anyone can join. The loop calls iter(x) (which calls x.__iter__()) to get an iterator, then calls next(it) (__next__) repeatedly until the iterator raises StopIteration. That's the whole contract:
# "for item in x:" is really:
it = iter(x) # x.__iter__()
while True:
try:
item = next(it) # it.__next__()
except StopIteration:
break
... # loop bodyLists, dicts, files, database cursors, network streams — anything that implements those two methods works in a for loop, in sum(), in unpacking, everywhere. That uniformity is why Python code composes so well.
Generators are the cheapest way to implement that protocol: a function containing yield doesn't run when called — it returns a paused computation. Each next() resumes it until the next yield, then freezes it again, locals intact. The killer property: it holds O(1) state instead of O(n) results.
sum(x * x for x in range(10**9)) # runs fine: one x at a time in memory
sum([x * x for x in range(10**9)]) # builds a billion-element list first
# (tens of GB) — likely killed by the OSSame arithmetic, same answer — one holds a single number at a time, the other materializes a billion before adding the first pair.
Comprehensions are the idiomatic transform layer: [f(x) for x in xs if p(x)] (list), plus dict, set, and generator-expression forms. A comprehension beats map/filterwhen there's any expression logic — it reads left-to-right as “what, from where, under what condition”. But it should become a plain loop the moment you need side effects, multiple statements, or error handling per item. A comprehension that mutates things or spans four lines is a loop wearing a disguise.
prices = {row.ticker: row.close for row in rows if row.close > 0} # good
[log.write(r) for r in rows] # bad: side effects — write a for loopThe senior-level payoff is pipelines: chained generators are streaming. Each stage pulls one item from the stage before it, so a 1M-row price file flows through parse → filter → aggregate with one row in flight and nothing materialized:
def load_prices(path):
with open(path) as f:
rows = csv.DictReader(f) # streams lines
parsed = (parse_row(r) for r in rows) # one at a time
valid = (p for p in parsed if p.price > 0)
yield from valid
total = sum(p.price * p.qty for p in load_prices("fills.csv"))
# constant memory whether the file has 1k rows or 100MThe itertools module is the toolbox for these pipelines: islice (take/skip without indexing), chain (concatenate streams), groupby (run-length grouping of sorted input), and accumulate — which is exactly a running prefix sum, the same structure we use for O(1) range queries in Prefix sums.
One trap to respect: generators are one-shot. Once exhausted, iterating again silently yields nothing — no error, just an empty second pass. If two consumers need the data, either re-create the generator or materialize it once with list(). See it happen below.
🎛 Lazy vs eager lab
Pipeline: parse → filter even → square, then take the first 5 results.
Eager — list at every stage
parsed = [parse(x) for x in raw] evens = [p for p in parsed if p%2==0] squares = [e*e for e in evens] squares[:5]
Lazy — generator chain
parsed = (parse(x) for x in raw) evens = (p for p in parsed if p%2==0) squares = (e*e for e in evens) list(islice(squares, 5))
Items pulled through the pipeline
Peak memory (eager)
20,000
items across intermediate lists
Peak memory (lazy)
1
one item in flight, any N
Stage executions, take-5
25,000vs23
eager vs lazy
Eager pipelines materialize a full list at every stage, so both memory and work scale with N even when you only want a few results. A generator chain is pull-based: each next() on the last stage drags exactly one item through the whole pipeline (plus items the filter discards), and stops the instant the consumer is satisfied. The price of laziness is that generators are one-shot — iterate twice and the second pass is empty. Counts are exact for this parse/filter/square pipeline; real workloads add per-item costs but scale the same way. Educational tool.
Why “take 5” is the acid test
The sharpest way to see lazy vs eager is to ask for only the first 5 results of a 3-stage pipeline over N items:
# EAGER: every stage processes ALL N before you see result #1 parsed = [parse(x) for x in raw] # N items materialized evens = [p for p in parsed if p % 2 == 0] # ~N/2 more squares = [e * e for e in evens] first5 = squares[:5] # all that work for 5 numbers # LAZY: demand flows backwards — 5 results pull ~10 raw items total parsed = (parse(x) for x in raw) evens = (p for p in parsed if p % 2 == 0) squares = (e * e for e in evens) first5 = list(islice(squares, 5)) # stops the moment it has 5
In the lazy version each next() on squares pulls one item from evens, which pulls from parsed until an even number survives the filter. Demand propagates backwards; work happens only when a result is actually consumed. That's why the lab's “stage executions” tile barely moves for the lazy column while the eager one scales with N — and why a well-written price-file loader can serve “first 5 bad rows” from a 100M-row file in microseconds.
Things to try
- • Push N to 1,000,000 and hit “take 5”: eager executes millions of stage steps; lazy needs about a dozen.
- • Watch the peak memory items tile: eager grows with N (sum of every intermediate list); lazy stays at 1 regardless of N.
- • Trigger the one-shot trap: re-iterate the lazy pipeline and get zero items the second time — no error, just silence.
- • In a real shell, time next(islice((x*x for x in range(10**9)), 5, 6)) vs building the list. Then try itertools.accumulate on daily P&L to get a running total in one line.