Python Quest

Track 12 · Lesson 1 of 12

Iterables and iterators

How a for loop really works: the iteration protocol, iter() and next(), StopIteration and writing your own iterator class.

About 40 minIntermediate4 exercisesFree

You will be able to

  • Explain the difference between an iterable and an iterator
  • Drive iteration by hand with iter() and next(), including next()'s default value
  • Write a class that implements __iter__ and __next__ and ends with StopIteration
  • Recognise and fix bugs caused by consuming an iterator twice

You have written hundreds of for loops over lists, strings, dictionaries and files. They all look the same from the outside, yet a list keeps its items in memory while a file reads one line at a time from disk. What lets one for statement work with all of them?

The answer is a small agreement called the iteration protocol. Once you understand it you can make your own objects loopable, read the first line of a data stream and process the rest separately, and avoid one of the most confusing bugs in Python: data that silently "disappears" the second time you loop over it. Everything in the next two lessons — generators and pipelines — is built on this protocol.

Two roles: iterable and iterator

  • An iterable is anything you can loop over: a list, tuple, string, dict, set, range, file. Technically, it is an object whose __iter__ method returns an iterator. You ask for one with the built-in iter(obj).
  • An iterator is the object that does the walking. It remembers its position and hands out the next item each time you call next(it) (which calls its __next__ method). When there are no items left it raises StopIteration.

A list is iterable but is not itself an iterator: you can loop over the same list many times because each loop asks for a fresh iterator. An iterator is single-use: once it has handed out every item, it is exhausted and stays empty.

Driving iteration by hand
orders = ["A-101", "A-102", "A-103"]

it = iter(orders)      # ask the list for an iterator
print(type(it).__name__)
print(next(it))        # A-101
print(next(it))        # A-102
print(next(it))        # A-103
print(next(it, "no more orders"))  # a default instead of StopIteration

Try it: Remove the default from the last next() call and run it again to see the StopIteration error.

What a for loop really does

This loop:

for order in orders:
    print(order)

is roughly equivalent to:

it = iter(orders)
while True:
    try:
        order = next(it)
    except StopIteration:
        break
    print(order)

The for statement calls iter() once, calls next() repeatedly, and treats StopIteration as the normal signal to stop — not as an error. sum(), list(), max(), sorted(), in, unpacking and comprehensions all use the same protocol.

Predict the output

readings = iter([12, 15, 11])

for r in readings:
    print("first loop:", r)

for r in readings:
    print("second loop:", r)

print("done")

Using next() on purpose

Sometimes you want to treat the first item differently — a CSV header, a title line, a starting balance. Get an iterator, take the first item with next(), then loop over the rest. The loop continues from where next() left off.

Header first, then data rows
lines = ["sku,qty", "PEN-01,40", "INK-07,12", "PAD-33,5"]

rows = iter(lines)
header = next(rows)
print("columns:", header.split(","))
for line in rows:          # continues after the header
    sku, qty = line.split(",")
    print(sku, int(qty))

Try it: Change lines to an empty list. Which line fails, and how could next(rows, None) help?

Writing an iterator class

To make your own iterator, give a class two methods:

  • __iter__(self) returns the iterator — for an iterator class, that is self.
  • __next__(self) returns the next value, or raises StopIteration when finished.

Here is an iterator over the invoice numbers in a range, formatted as text:

An iterator class
class InvoiceNumbers:
    def __init__(self, first, last):
        self.current = first
        self.last = last

    def __iter__(self):
        return self

    def __next__(self):
        if self.current > self.last:
            raise StopIteration
        number = f"INV-{self.current:04d}"
        self.current += 1
        return number


for n in InvoiceNumbers(98, 101):
    print(n)

numbers = InvoiceNumbers(1, 2)
print(list(numbers))
print(list(numbers))   # exhausted: an iterator is single-use

Try it: Add a third call print(next(numbers, 'empty')) at the end.

Iterable versus iterator in your own classes

If you want an object that can be looped over many times — like a list — make it an iterable: its __iter__ returns a new iterator each time instead of self. A quick way is to return an iterator over some internal data (return iter(self.items)); in the next lesson you'll see that a generator is the neatest way.

iterable (e.g. list)iterator (e.g. iter(list), a file)
iter(x) returnsa new iteratoritself
has __next__noyes
loop over it twiceworks both timessecond loop is empty

When not to write an iterator class: most of the time you don't need one. If the data already fits in a list, return the list. If you need lazy, one-at-a-time values, a generator function (next lesson) does the same job in a few lines. Write a class when the iterator needs extra methods or state other code inspects.

Guided practice · ●●difficulty 2 of 5 · about 6 min

Separate the header from the rows

A warehouse export arrives as an iterable of text lines — sometimes a list, sometimes a file object that can only be read once. The first line is a header.

Write split_header(lines) that returns a tuple (header, rows):

  • header is the first line, or None if there are no lines at all;
  • rows is a list of the remaining lines, in order.

Use iter() and next() so it works for any iterable, including one-shot iterators.

split_header(["sku,qty", "PEN-01,40", "INK-07,12"])
# ('sku,qty', ['PEN-01,40', 'INK-07,12'])
split_header([])
# (None, [])

Python code editor. Tab indents. Press Escape then Tab to leave the editor. Control or Command plus Enter runs the code; Shift plus Enter checks your answer.

Ctrl+Enter run · Shift+Enter check · your code is saved in this browser as you type

Output

Run your code to see its output, or Check to test it against the requirements.

The solution becomes available after you pass, or after all three hints and two checks.

Challenge · ●●●difficulty 3 of 5 · about 8 min

A countdown iterator

A launch screen at a delivery depot counts down before the gates open. Write an iterator class Countdown:

  • Countdown(start) stores the starting number;
  • __iter__ returns the iterator itself;
  • __next__ returns start, then start - 1, … down to 1, then raises StopIteration.
list(Countdown(3))   # [3, 2, 1]
list(Countdown(0))   # []
c = Countdown(2)
next(c), next(c)     # (2, 1)
next(c)              # raises StopIteration

Python code editor. Tab indents. Press Escape then Tab to leave the editor. Control or Command plus Enter runs the code; Shift plus Enter checks your answer.

Ctrl+Enter run · Shift+Enter check · your code is saved in this browser as you type

Output

Run your code to see its output, or Check to test it against the requirements.

The solution becomes available after you pass, or after all three hints and two checks.

Challenge · ●●●difficulty 3 of 5 · about 10 min

A reusable shift rota

A clinic assigns reception shifts in rotation. Write a class ShiftRota that is iterable (it can be looped over many times, like a list):

  • ShiftRota(staff, days) stores a list of names and a number of days;
  • looping over it yields tuples (day, name) for days 1 to days, cycling through staff in order;
  • every new loop starts again from day 1;
  • if staff is empty, or days is 0, it yields nothing.
rota = ShiftRota(["Huda", "Omar"], 3)
list(rota)   # [(1, 'Huda'), (2, 'Omar'), (3, 'Huda')]
list(rota)   # the same list again

Python code editor. Tab indents. Press Escape then Tab to leave the editor. Control or Command plus Enter runs the code; Shift plus Enter checks your answer.

Ctrl+Enter run · Shift+Enter check · your code is saved in this browser as you type

Output

Run your code to see its output, or Check to test it against the requirements.

The solution becomes available after you pass, or after all three hints and two checks.

Debugging · ●●●difficulty 3 of 5 · about 7 min

Fix the vanishing readings

A sensor dashboard calls summarize(readings) to get (count, average) for a batch of temperature readings. It works in the developer's tests with lists, but in production the readings arrive as an iterator streamed from a device, and the dashboard shows an average of 0.0 every time.

Fix summarize so it returns the right (count, average) for any iterable. For no readings it should return (0, None).

summarize([20.0, 22.0, 24.0])         # (3, 22.0)
summarize(iter([20.0, 22.0, 24.0]))   # (3, 22.0)  -- currently (3, 0.0)
summarize(iter([]))                   # (0, None)

Python code editor. Tab indents. Press Escape then Tab to leave the editor. Control or Command plus Enter runs the code; Shift plus Enter checks your answer.

Ctrl+Enter run · Shift+Enter check · your code is saved in this browser as you type

Output

Run your code to see its output, or Check to test it against the requirements.

The solution becomes available after you pass, or after all three hints and two checks.

Summary

  • An iterable gives out iterators with iter(); an iterator hands out items with next() and raises StopIteration when finished.
  • A for loop calls iter() once and next() until StopIteration; list(), sum(), sorted() and comprehensions work the same way.
  • Iterators are single-use and remember their position: next() can take a header, and the loop carries on from there.
  • An iterator class needs __iter__ (return self) and __next__; a reusable iterable's __iter__ returns a new iterator each time.