Iterators, generators, closures and decorators · about 40 min

The iterator protocol

Why this matters

for works on lists, strings, dicts, files, ranges, zip and enumerate objects, and on things that never end, like a stream of network messages. How can one statement handle all of them? Through a tiny agreement called the iterator protocol: two methods, __iter__ and __next__, and one exception, StopIteration. Once you know it, you can make your own objects work with for, list(), sum(), zip() and every other tool that consumes iterables, and you will understand why some objects can be looped over only once.

Iterables and iterators

You met iter() and next() in the iteration module. Now precisely:

nums = [10, 20]
it = iter(nums)        # calls nums.__iter__()
print(it)
print(next(it))        # calls it.__next__()
print(next(it))
print(next(it))
<list_iterator object at 0x7f...>
10
20
Traceback (most recent call last):
  ...
StopIteration

The list does not move; the separate list_iterator object holds a position (an index into the list). That separation is the key design idea:

  iterable (the list)                iterator (list_iterator)
  [10, 20, 30]   <---- refers to ----  position = 1
                       iter(nums)          next() returns nums[1], position becomes 2

What for really does

This loop:

for x in [10, 20, 30]:
    print(x)

is equivalent to:

_it = iter([10, 20, 30])
while True:
    try:
        x = next(_it)
    except StopIteration:
        break
    print(x)

The bytecode shows it directly: GET_ITER calls iter() once, and FOR_ITER calls __next__ each time round, jumping out of the loop when the iterator is exhausted. (CPython handles the "exhausted" signal from built-in iterators without actually creating an exception object, which keeps loops fast.) list(x), sum(x), sorted(x), x in y for many types, tuple unpacking a, b = x, and str.join(x) all use the same protocol.

Writing an iterator class

class Countdown:
    def __init__(self, start):
        self.current = start

    def __iter__(self):
        return self

    def __next__(self):
        if self.current <= 0:
            raise StopIteration
        value = self.current
        self.current -= 1
        return value

for n in Countdown(3):
    print(n)
print(list(Countdown(5)))
3
2
1
[5, 4, 3, 2, 1]

That is the whole protocol. Any object with these two methods works everywhere an iterable is expected.

Exhaustion: the one-shot problem

Iterators have state, and once StopIteration has been raised, a well-behaved iterator keeps raising it. So an iterator can be consumed only once:

c = Countdown(3)
print(list(c))
print(list(c))
[3, 2, 1]
[]

No error, just nothing. This silent emptiness is a common bug source. Many built-ins return iterators, not lists: zip, map, filter, enumerate, reversed, and file objects:

pairs = zip("ab", [1, 2])
print(dict(pairs))
print(list(pairs))      # already used up
{'a': 1, 'b': 2}
[]

If you need the values more than once, store them: pairs = list(zip(...)).

Iterable versus iterator in your own classes

A list can be looped over any number of times, even in nested loops, because each for calls iter(the_list) and gets a fresh iterator. Your own collections should do the same: make the collection an iterable whose __iter__ returns a new iterator object each time, and keep the moving position in that iterator.

class Word:
    def __init__(self, text):
        self.text = text
    def __iter__(self):
        return WordIterator(self.text)

class WordIterator:
    def __init__(self, text):
        self.text, self.i = text, 0
    def __iter__(self):
        return self
    def __next__(self):
        if self.i >= len(self.text):
            raise StopIteration
        ch = self.text[self.i]
        self.i += 1
        return ch

w = Word("hi")
print([a + b for a in w for b in w])
['hh', 'hi', 'ih', 'ii']

The nested comprehension gets two independent iterators. If Word were its own iterator, the inner loop would exhaust it and the result would be wrong. In practice, the easiest way to write __iter__ is as a generator, or to return iter(self._items) for an inner collection, as you did with Playlist in the OOP module. The next lesson shows generators.

Infinite and lazy iterators

Nothing says an iterator must end. itertools.count() yields 0, 1, 2, ... forever. Iterators compute each value only when asked, so an infinite one costs nothing until you pull from it. Combine with something that stops:

import itertools
for n in itertools.count(10, 5):
    if n > 25:
        break
    print(n, end=" ")
print()
print(list(itertools.islice(itertools.count(), 4)))
10 15 20 25
[0, 1, 2, 3]

itertools.islice(it, n) takes just the first n items. Calling list() directly on an infinite iterator would run until memory runs out.

Common mistakes

Recap

Going further: dis.dis("for x in y: pass") shows GET_ITER and FOR_ITER. Read the "Iterator Types" section of the standard library docs (under "Built-in Types").

Exercises

Exercise 1 · quiz

Iterators run out

What does this print?

nums = [1, 2, 3]
it = iter(nums)
print(sum(it), sum(it), sum(nums), sum(nums))
Answer
  • 6 0 6 6

it is an iterator: it remembers its position, and the first sum consumes it completely. The second sum(it) gets no items and returns 0, without any error. The list itself is an iterable, not an iterator: every sum(nums) calls iter(nums) and gets a fresh iterator starting at the beginning.

Exercise 2 · write code

A Countdown iterator class

In solution.py, write an iterator class Countdown such that Countdown(3) yields 3, 2, 1 and then stops.

So list(Countdown(3)) is [3, 2, 1], list(Countdown(0)) is [], and a Countdown that has been fully consumed stays empty. Do not use yield here (generators are the next lesson).

File: solution.py

Starting point

class Countdown:
    def __init__(self, start):
        self.current = start

    def __iter__(self):
        return self
Hint 1

__next__ must either return the next value or raise StopIteration.

Hint 2

Save the current value in a local variable, decrease self.current, then return the saved value.

Reference solution
class Countdown:
    def __init__(self, start):
        self.current = start

    def __iter__(self):
        return self

    def __next__(self):
        if self.current <= 0:
            raise StopIteration
        value = self.current
        self.current -= 1
        return value

Exercise 3 · write code

An iterable that can be looped twice

Countdown above can only be looped over once. Fix that design by separating the iterable from its iterator.

In solution.py, write a class Span representing the integers from start up to but not including stop, moving by step (default 1, always positive):

So s = Span(0, 10, 3) gives list(s) == [0, 3, 6, 9] every time you iterate it, two nested loops over the same Span work independently, and len(s) == 4.

File: solution.py

Starting point

class Span:
    def __init__(self, start, stop, step=1):
        self.current = start
        self.stop = stop
        self.step = step

    def __iter__(self):
        return self

    def __next__(self):
        if self.current >= self.stop:
            raise StopIteration
        value = self.current
        self.current += self.step
        return value

    def __len__(self):
        return 0


class SpanIterator:
    pass
Hint 1

The Span object should never change while iterating; all the moving state belongs in SpanIterator.

Hint 2

Span.__iter__ is just return SpanIterator(self.start, self.stop, self.step).

Hint 3

Length: the count of values is the ceiling of (stop - start) / step, which in integers is (stop - start + step - 1) // step.

Reference solution
class Span:
    def __init__(self, start, stop, step=1):
        self.start = start
        self.stop = stop
        self.step = step

    def __iter__(self):
        return SpanIterator(self.start, self.stop, self.step)

    def __len__(self):
        if self.stop <= self.start:
            return 0
        return (self.stop - self.start + self.step - 1) // self.step


class SpanIterator:
    def __init__(self, current, stop, step):
        self.current = current
        self.stop = stop
        self.step = step

    def __iter__(self):
        return self

    def __next__(self):
        if self.current >= self.stop:
            raise StopIteration
        value = self.current
        self.current += self.step
        return value

Exercise 4 · write code

Write the for loop yourself

In solution.py, write a function my_for(iterable, func) that calls func(item) for every item of iterable, in order, and returns how many items there were, without using a for loop or a comprehension. Use iter(), next(), a while True loop and try/except StopIteration, which is exactly what the for statement does under the hood.

Example: my_for("abc", print) prints a, b, c on separate lines and returns 3.

File: solution.py

Starting point

def my_for(iterable, func):
    count = 0
    for item in iterable:
        func(item)
        count += 1
    return count
Hint 1

Start with it = iter(iterable).

Hint 2

Inside while True:, put only item = next(it) in the try; on StopIteration, break.

Hint 3

Call func(item) outside the try, so a StopIteration raised by func is not mistaken for the end.

Reference solution
def my_for(iterable, func):
    it = iter(iterable)
    count = 0
    while True:
        try:
            item = next(it)
        except StopIteration:
            break
        func(item)
        count += 1
    return count