Advanced generators and itertools

Python · Advanced · 12 min

Streaming pipelines and coroutine-style code rest on these generator features.

yield from iterable delegates to another iterable, yielding all its items. With a sub-generator it does more than a loop would: values passed in with send() and exceptions passed with throw() are forwarded to the sub-generator, and the expression result = yield from sub() evaluates to the sub-generator's return value (a generator's return x raises StopIteration(x) under the hood).

Generators are two-way. yield is an expression: received = yield value. gen.send(x) resumes the generator and makes the paused yield evaluate to x. You must start a fresh generator with next(gen) (or send(None)) to advance it to the first yield. This is how coroutines worked before async/await, and those keywords are still implemented on the same machinery.

gen.close() raises GeneratorExit at the paused yield, so finally blocks and with statements inside the generator run their cleanup. A generator that holds a resource (an open file) releases it when closed or garbage collected; close it explicitly if you stop early.

The real everyday win is pipelines: small generator stages composed like Unix pipes, each lazily pulling from the previous. Memory use stays flat regardless of input size. itertools supplies the fittings:

  • chain.from_iterable, islice, takewhile, dropwhile
  • groupby (input must be sorted by the key), pairwise, batched(it, n) (3.12, and since 3.13 strict=True)
  • tee to split an iterator, accumulate, product, combinations

Example

from itertools import groupby

def read_lines(chunks):
    for chunk in chunks:
        yield from chunk.splitlines()      # delegate to another iterable

def parse(lines):
    for line in lines:
        user, amount = line.split(",")
        yield user, int(amount)
chunks = ["ana,5\nben,3", "ana,7\ncy,1"]
records = sorted(parse(read_lines(chunks)))      # lazy until sorted() pulls
for user, rows in groupby(records, key=lambda r: r[0]):
    print(user, sum(amount for _, amount in rows))   # ana 12 / ben 3 / cy 1

def averager():
    total, count, avg = 0, 0, None
    while True:
        value = yield avg                  # receives what send() passes
        total, count = total + value, count + 1
        avg = total / count

avg = averager()
next(avg)                                  # prime: run to the first yield
print(avg.send(10), avg.send(20))          # 10.0 15.0

Remember

  • yield from delegates and returns the sub-generator's return value
  • send() makes yield evaluate to a value; prime with next() first
  • Compose lazy pipelines from small generators and itertools

Watch out for

  • Using itertools.groupby on unsorted data and getting split groups.

Check yourself

  1. `list(groupby([1, 1, 2, 1]))` produces how many groups?

Sign in to answer, see the explanations, and get this lesson back as a quick review later.

Official docs: docs.python.org/3/reference/expressions.html#yield-expressions