Kodokon kodokon.com

Iterators and generators: yield and pipelines

Master the iteration protocol and generators to process data streams lazily, at constant memory.

10 min · 3 questions

Open this lesson in Kodokon

Python's for is nothing more than a protocol: iter(obj) asks for an iterator, then next() is called until the StopIteration exception. Be sure to distinguish the iterable (the list, able to hand out a fresh iterator on each pass) from the iterator (the cursor, which can only be traversed once). Understanding this protocol means understanding why some objects "get consumed."

PYTHON
numbers = [10, 20, 30]
iterator = iter(numbers)
print(next(iterator))
print(next(iterator))
print(next(iterator))
# next(iterator) would raise StopIteration
What a for loop does, by hand

A function that contains yield does not run when called: it returns a generator, an iterator whose body runs on demand. Each next() advances to the next yield, where execution is suspended along with all of its local state. The direct consequence: memory stays constant whatever the size of the stream - you produce the values one by one instead of materializing a full list.

PYTHON
def squares(limit):
    n = 0
    while n < limit:
        yield n * n
        n += 1

gen = squares(4)
print(next(gen))  # 0
print(list(gen))  # [1, 4, 9]
yield suspends the function between two values

The main benefit in production: pipelines. Each stage consumes the stream from the previous one and produces its own, lazily; nothing runs before the final consumption (list(), sum(), a for). A trade-off to keep in mind: a list can be traversed several times and sliced, a generator cannot; on the other hand, a generator processes a 10 GB file without saturating RAM. Generator expressions - (f(x) for x in xs if cond) - cover the simple stages without defining a function.

PYTHON
lines = ["  12 ", "x", " 7", "", "30"]

def clean(rows):
    for row in rows:
        row = row.strip()
        if row.isdigit():
            yield int(row)

def keep_from(values, floor):
    return (v for v in values if v >= floor)

pipeline = keep_from(clean(lines), 10)
print(list(pipeline))  # [12, 30]
A lazy pipeline: nothing runs before list()

Knowledge check

Make sure you remember the key points of this lesson.

  1. What do you get when you call a function whose body contains yield?
    • The result of the first yield
    • A generator, with no execution of the body
    • A list of all the values produced
  2. What decisive advantage does a pipeline of generators have over intermediate lists?
    • The results are sorted automatically
    • Memory stays constant: values are produced on demand
    • Processing is parallelized across several cores
  3. After a first list(gen), what does a second list(gen) return on the same generator?
    • The same list as the first call
    • An empty list
    • An exception is propagated