Python Module 10 – Iterators, generators and functional tools
How iteration works: iterables and iterators
See what a for loop does with iter(), next() and StopIteration, write iterator classes, and stop one-shot iterators such as map and zip from emptying a loop.
What you will learn
- Explain what iter(), next() and StopIteration do in every for loop
- Distinguish an iterable from an iterator, and predict when a second loop sees nothing
- Write an iterator class, and an iterable class that every loop can restart
- Read data in fixed-size pieces with iter(callable, sentinel)
Before you start
What you’ll learn · Before you start
What you will learn
- Explain what iter(), next() and StopIteration do in every for loop
- Distinguish an iterable from an iterator, and predict when a second loop sees nothing
- Write an iterator class, and an iterable class that every loop can restart
- Read data in fixed-size pieces with iter(callable, sentinel)
Before you start
On this page
Read the marks "72 85 91 64" through map(int, …), add them up with sum(), then count them with
len(list(marks)), and the count comes out as 0. Nothing was lost: a map hands out each value once, and sum() had
already taken all four, so the average divides by zero one line later.
That zero comes from the two calls inside every for loop, iter() and next(), and from the difference between an
iterable: A value a for loop can go through one item at a time, such as a string, a list, a tuple, a dict, a set or a range. A dict gives its keys. More in “for loops with range, enumerate and zip”, which hands every loop a fresh iterator: An object that hands out items one at a time: next() returns its next item, or raises StopIteration when none are left. It keeps one position, so it is used up after one pass, and iter() returns the iterator itself., and an
iterator, which keeps one position and is used up after one pass. Below you will step through real runs of both, write
iterator classes of both kinds, and read data in fixed-size pieces with iter(function, sentinel).
A for loop calls iter() once, then next() until StopIteration
A for loop never counts positions. It asks the object after in for an iterator by calling iter() on it, once,
then calls next() on that iterator again and again, binding each result to the loop variable, until next() raises
the exception StopIteration: The exception next() raises when an iterator has no items left. A for loop catches it and ends quietly, which is why a loop never shows it., which the loop treats as “no items left”. The iterator
is the object that remembers the position and produces one item per next() call:
What a for loop does with marks
Text description of the diagram
The diagram is a flow from top to bottom with one loop in it.
- It starts at the statement for m in marks.
- Once, at the start, the loop calls iter(marks) and keeps the iterator it returns, here called it.
- It then calls next(it). If next() returns an item, the item is assigned to m and the loop body runs, and the flow goes back to next(it) for the next item.
- When there is nothing left, next() raises StopIteration, and the loop ends without showing the exception.
The program below runs both versions over the same three marks: first a for statement, then the same loop spelled
out by hand with the three steps that the statement performs for you. Step through it and watch the flag under
marks, the position the next item comes from:
Program · for_by_hand.py
marks = [72, 85, 91]
for m in marks:
print("for:", m)
it = iter(marks)
while True:
try:
item = next(it)
except StopIteration:
break
print("by hand:", item)
print(type(it).__name__, iter(it) is it) Variables
- just taken by the loop
- changed in this step
- passed by the loop
- read next
- where the loop stopped
Printed so far
Nothing printed yetfor: 72 for: 85 for: 91 by hand: 72 by hand: 85 by hand: 91 list_iterator True
Every step was recorded from a run with Python 3.14.8 on macOS 26 arm64.
The drawing shows the variables when the program ends, and the program's 28 steps are listed below.
Change the program and run it here. Runs on this device, in your browser. The first run downloads Python (about 13.5 MB), which is kept for the next runs.
Your run, in this browser
Every step as text
- Start. No line has run yet, so there are no variables. Python starts with line 1.
- Line 1.
marks = [72, 85, 91]makesmarksa list of 3 items. - Line 3.
for m in marks:takes the item at position 0 ofmarks:mis72. The for statement called iter(marks) once, before this first pass, and keeps that iterator out of sight. The flag under marks shows where it reads next. - Line 4.
print("for:", m)printsfor: 72. - Line 3.
for m in marks:takes the item at position 1 ofmarks:mis85. - Line 4.
print("for:", m)printsfor: 85. - Line 3.
for m in marks:takes the item at position 2 ofmarks:mis91. - Line 4.
print("for:", m)printsfor: 91. - Line 3.
for m in marks:finds no item at position 3 ofmarks(it has 3 items), so the loop ends. - Line 6.
it = iter(marks)setsitto<list_iterator object>.iter(marks)makes a new iterator, a separate object that keeps the position. The flag labelleditis that position: 0, the first mark. - Line 7.
while True:tests its condition: it is true, so the body runs. - Line 8.
try:runs. - Line 9.
item = next(it)setsitemto72. - Line 12.
print("by hand:", item)printsby hand: 72. - Line 7.
while True:tests its condition: it is true, so the body runs again (pass 2). - Line 8.
try:runs. - Line 9.
item = next(it)setsitemto85(it was72). - Line 12.
print("by hand:", item)printsby hand: 85. - Line 7.
while True:tests its condition: it is true, so the body runs again (pass 3). - Line 8.
try:runs. - Line 9.
item = next(it)setsitemto91(it was85). - Line 12.
print("by hand:", item)printsby hand: 91. - Line 7.
while True:tests its condition: it is true, so the body runs again (pass 4). - Line 8.
try:runs. - Line 9.
item = next(it)itemkeeps its value,91. Key point There is no item at position 3, sonext(it)raises StopIteration instead of returning a value, anditemkeeps 91. - Line 10. Line 10 runs. This is the step a for loop does for you. It catches StopIteration and ends, which is why a for loop never shows the exception.
- Line 11.
breakleaves the loop at once, without reading the items it has not reached. - Line 14.
print(type(it).__name__, iter(it) is it)printslist_iterator True. That was the last line, so the program ends. Both loops gave 72, 85 and 91 in order.iter(it)gave backititself, which is why an iterator can stand wherever an iterable is expected.
The while loop is what Python does underneath. iter(marks) runs once and returns a list_iterator, a separate
object that keeps the position. Each next(it) returns the following item, and after the last one next() raises
StopIteration. A for statement catches that exception itself, which is why you never see it. The last line shows
the rule that makes iterators special: calling iter() on an iterator returns the very same object.
Checkpoint
Check: iter() once, next() until StopIteration
A list hands out new iterators; an iterator keeps one position
Anything that can hand out an iterator is an iterable: lists, strings, dictionaries, ranges and files are all
iterables. An iterable has an __iter__() method that returns an iterator. An iterator has a __next__() method that
returns the next item or raises StopIteration, plus an __iter__() method that returns the iterator itself, so that
an iterator can be used wherever an iterable is expected. Step through two iterators over one list, and a second name
for one of them:
Program · two_iterators.py
marks = [72, 85, 91]
first = iter(marks)
second = iter(marks)
print(next(first), next(first))
print(next(second))
same = iter(first)
print(same is first, next(same))
print(next(first, "empty")) Variables
- changed in this step
- passed by the loop
- read next
Printed so far
Nothing printed yet72 85 72 True 91 empty
Every step was recorded from a run with Python 3.14.8 on macOS 26 arm64.
The drawing shows the variables when the program ends, and the program's 9 steps are listed below.
Change the program and run it here. Runs on this device, in your browser. The first run downloads Python (about 13.5 MB), which is kept for the next runs.
Your run, in this browser
Every step as text
- Start. No line has run yet, so there are no variables. Python starts with line 1.
- Line 1.
marks = [72, 85, 91]makesmarksa list of 3 items. - Line 2.
first = iter(marks)setsfirstto<list_iterator object>. - Line 3.
second = iter(marks)setssecondto<list_iterator object>.firstandsecondare two iterators over the same list. Each keeps its own position, so both flags start at 0. - Line 4.
print(next(first), next(first))prints72 85. Two calls ofnext(first)move only the flag offirst, to position 2.secondhas not moved. - Line 5.
print(next(second))prints72.secondgives the first mark, 72, as every new iterator over a list does. - Line 6.
same = iter(first)setssameto<list_iterator object>. Key point For an iterator,iter()returns the iterator itself, sosameis not a new position: it is a second name forfirst. - Line 7.
print(same is first, next(same))printsTrue 91. Sonext(same)moves the flag offirst. It gives 91, the item at position 2. - Line 8.
print(next(first, "empty"))printsempty. That was the last line, so the program ends.firsthas nothing left, sonext()returned its default. The list can still hand out a new iterator at any time; an iterator, once used up, stays used up.
Every container makes a new iterator each time you ask, so two loops over a list never disturb each other: the flags
of first and second moved on their own. An iterator has only one position, so every loop over it, and every name
for it, continues where the previous one stopped.
| Criterion | Iterable | Iterator |
|---|---|---|
| Methods | __iter__(), which returns a new iterator | __next__(), and __iter__() returning itself |
| Loop over it twice | Both loops see every item | The second loop sees what the first left |
| len() and indexing | Often, as in lists and ranges | Not in the built-in ones |
| Examples | list, tuple, str, dict, set, range | map, filter, zip, enumerate, reversed, files |
| When to choose | Data you read many times | One pass over data produced as you go |
The program below loops twice over eleven common objects and reports what the second loop saw:
import io
things = {
"list": [1, 2],
"tuple": (1, 2),
"str": "ab",
"dict": {"a": 1, "b": 2},
"range": range(2),
"map": map(str, [1, 2]),
"filter": filter(None, [1, 0, 2]),
"zip": zip("ab", "cd"),
"enumerate": enumerate("ab"),
"reversed": reversed([1, 2]),
"file": io.StringIO("line 1\nline 2\n"),
}
for name, obj in things.items():
first = list(obj)
second = list(obj)
kind = "iterator" if iter(obj) is obj else "iterable"
print(f"{name:10} {kind:9} second loop: {len(second)} of {len(first)} items") Output
list iterable second loop: 2 of 2 items tuple iterable second loop: 2 of 2 items str iterable second loop: 2 of 2 items dict iterable second loop: 2 of 2 items range iterable second loop: 2 of 2 items map iterator second loop: 0 of 2 items filter iterator second loop: 0 of 2 items zip iterator second loop: 0 of 2 items enumerate iterator second loop: 0 of 2 items reversed iterator second loop: 0 of 2 items file iterator second loop: 0 of 2 items
Recorded with Python 3.14.8 on macOS 26 arm64. To run it yourself: mise exec python@3.14.8 -- python3 one_shot_or_not.py
Runs on this device, in your browser. The first run downloads Python (about 13.5 MB), which is kept for the next runs.
Your run, in this browser
The pattern is exact: every object that is its own iterator came back empty the second time. The documentation of
iterator types adds a rule for the end of an iterator. Once __next__() has raised StopIteration, it must keep
raising it on every later call; an iterator that starts producing items again is called broken.
Key idea
An iterable hands out a new iterator every time iter() is called on it. An iterator returns itself from iter(),
so it has one position and is used up after one pass.
Checkpoint
Check: Iterable or iterator
Every read moves an iterator forward, so a second loop sees what is left
The difference matters because many everyday objects, map(), zip(), filter(), enumerate() and open files
among them, are iterators themselves, and anything that reads an iterator moves it on, not only a for loop. To
watch it happen, wrap a sequence in a class that counts the calls to next():
class Counted:
"""Wraps an iterable and counts how often next() is called on it."""
def __init__(self, items):
self._it = iter(items)
self.calls = 0
def __iter__(self):
return self
def __next__(self):
self.calls += 1
return next(self._it) # raises StopIteration at the end
c = Counted("abcde")
for ch in c:
pass
print("a for loop over 5 items:", c.calls, "calls to next()")
c = Counted(range(1_000_000))
print("999_999 in c:", 999_999 in c, "after", c.calls, "calls")
c = Counted(range(1_000_000))
print("3 in c:", 3 in c, "after", c.calls, "calls")
print("list(c) then holds", len(list(c)), "items") Output
a for loop over 5 items: 6 calls to next() 999_999 in c: True after 1000000 calls 3 in c: True after 4 calls list(c) then holds 999996 items
Recorded with Python 3.14.8 on macOS 26 arm64. To run it yourself: mise exec python@3.14.8 -- python3 counting_calls.py
Runs on this device, in your browser. The first run downloads Python (about 13.5 MB), which is kept for the next runs.
Your run, in this browser
- A loop over 5 items calls
next()6 times: one call for each item, and one more that raisesStopIteration. 999_999 in chad to pull a million items before it found the last one, becauseinon an iterator has nothing better to do than callnext()and compare.3 in cstopped after 4 calls, and those 4 items were gone: thelist()that followed collected only the 999,996 items that were left. A membership test on an iterator moves it forward.
The bug this causes is rarely a crash at the point of the mistake. Here sum() reads every number from a map
object, and the line that counts them gets an iterator that is already exhausted:
line = "72 85 91 64"
marks = map(int, line.split())
total = sum(marks)
count = len(list(marks)) # the map was used up by sum()
print("total:", total, "count:", count)
try:
print("average:", total / count)
except ZeroDivisionError as e:
print("ZeroDivisionError:", e)
marks = list(map(int, line.split())) # a list can be read again
print("average:", sum(marks) / len(marks)) Output
total: 312 count: 0 ZeroDivisionError: division by zero average: 78.0
Recorded with Python 3.14.8 on macOS 26 arm64. To run it yourself: mise exec python@3.14.8 -- python3 marks_average.py
Runs on this device, in your browser. The first run downloads Python (about 13.5 MB), which is kept for the next runs.
Your run, in this browser
count is 0 because list(marks) started where sum() stopped, at the end. Step through the two reads and watch
the map’s position:
The drawing shows the whole scene; its 8 steps are listed below.
Every step as text
- sum() asks for an iterator. sum(marks) first calls iter(marks). A map object is an iterator, so iter() hands back the map itself, still before the first part of the line, '72'.
- The first mark. Each next() makes the map call int() on the next part of the line. The first call turns '72' into 72, and the map's position moves past it.
- Three more. Three more calls hand over 85, 91 and 64. sum() adds each one as it arrives and keeps none of them.
- The end of the map. The fifth next() finds no part left, so the map raises StopIteration. sum() takes that as the end and returns 312.
- Counting starts. list(marks) calls iter(marks) as well and gets the same map object, which still has nothing left.
- Nothing to count. The first next() raises StopIteration at once, so list() returns an empty list and len() gives 0.
- The error, one line later. total / count is 312 / 0, which raises ZeroDivisionError. The mistake happened a line earlier, when one iterator was read twice.
- The fix. list(map(int, line.split())) stores the four marks once. A list hands every reader a new iterator, so sum() and len() both see all four, and the average is 78.0.
The error appears one line later, as a division by zero, far from its cause. The fix is to decide how many times the data will be read. If more than once, store it in a list first. If the data is too big for a list, compute everything in one pass, keeping a running total and a running count in the same loop.
Reading a map, a zip or a file twice
total = sum(marks) followed by len(list(marks)) reads one iterator twice, and the second read sees nothing.
Write marks = list(map(int, parts)) when the data is read more than once, or add up and count in one loop when it
is too big for a list.
Checkpoint
Check: Reading an iterator twice
An iterator class keeps its position in its attributes
Any class with those two methods works with for, list(), sum(), in and every other tool that iterates.
Countdown below counts down from a start to 1: __next__() returns the current number and moves on, and raises
StopIteration once it reaches 0. Its __repr__() lets the figure show the attribute that holds the position, so
step through and watch c:
Program · countdown.py
class Countdown:
"""An iterator: counts down from start to 1, once."""
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
def __repr__(self):
return f"Countdown(current={self.current})"
c = Countdown(3)
for n in c:
print(n)
print(list(c))
print(next(c, "done")) Variables
- changed in this step
Printed so far
Nothing printed yet3 2 1 [] done
Every step was recorded from a run with Python 3.14.8 on macOS 26 arm64.
The drawing shows the variables when the program ends, and the program's 12 steps are listed below.
Change the program and run it here. Runs on this device, in your browser. The first run downloads Python (about 13.5 MB), which is kept for the next runs.
Your run, in this browser
Every step as text
- Start. No line has run yet, so there are no variables. Python starts with line 1.
- Line 1.
class Countdown:makes the classCountdown. Its body runs only when it is used. The class now holds its methods.__iter__()and__next__()run only when something askscfor items. - Line 21.
c = Countdown(3)setsctoCountdown(current=3). Creatingcruns__init__(), which stores 3 inc.current. Nothing has been counted yet. - Line 22.
for n in c:starts:nis3. The loop callediter(c), which returnedcitself, thennext(c):__next__()returned 3 and loweredcurrentto 2. - Line 23.
print(n)prints3. - Line 22.
for n in c:goes round again:nis2. - Line 23.
print(n)prints2. - Line 22.
for n in c:goes round again:nis1. - Line 23.
print(n)prints1. - Line 22.
for n in c:has no item left, so the loop ends. Key pointcurrentis 0, so__next__()raised StopIteration and the loop ended. The position lives in the object, and it stays at 0. - Line 24.
print(list(c))prints[].list(c)asks the same object again.currentis still 0, so the firstnext()raises StopIteration: an empty list. - Line 25.
print(next(c, "done"))printsdone. That was the last line, so the program ends. With a second argument, next() returns that default instead of raising StopIteration.
The position is the attribute current, which __next__() lowers on every pass. Because the class returns self
from __iter__(), a Countdown object behaves like map: once the loop has used it up, list(c) is empty. The last
line shows next() with a second argument, a default that it returns instead of raising StopIteration. Press Edit,
change the start to 5 and run it again: the loop prints five numbers, and the list after it is still empty.
Checkpoint
Check: An iterator class
An iterable class gives every loop its own iterator
A collection that people loop over more than once should not be its own iterator. Make __iter__() return a new
iterator object instead, so that every loop gets its own position, nested loops included. reusable.py runs the same
nested loops over a one-shot iterator and over such an iterable. Its OneShot is the iterator class of the previous
section under a new name, and its Countdown is a different class, the iterable built on it:
class OneShot:
"""An iterator: every loop shares the same position."""
def __init__(self, start):
self.current = start
def __iter__(self):
return self
def __next__(self):
if self.current <= 0:
raise StopIteration
self.current -= 1
return self.current + 1
class Countdown:
"""An iterable: every loop gets a new iterator of its own."""
def __init__(self, start):
self.start = start
def __iter__(self):
return OneShot(self.start)
for make in (OneShot, Countdown):
launch = make(3)
pairs = [(a, b) for a in launch for b in launch if a > b]
print(f"{make.__name__:9}", pairs) Output
OneShot [(3, 2), (3, 1)] Countdown [(3, 2), (3, 1), (2, 1)]
Recorded with Python 3.14.8 on macOS 26 arm64. To run it yourself: mise exec python@3.14.8 -- python3 reusable.py
Runs on this device, in your browser. The first run downloads Python (about 13.5 MB), which is kept for the next runs.
Your run, in this browser
With OneShot, the outer loop took 3, and the inner loop then took 2 and 1 from the same object, leaving nothing for
the outer loop: only two pairs. Countdown stores just the starting number and creates a fresh OneShot each time
iter() is called, so the inner loop starts again at 3 for every outer item and the result has all three pairs. Step
through both runs:
The drawing shows the whole scene; its 11 steps are listed below.
Every step as text
- The outer loop starts. The outer loop calls iter(launch). OneShot returns itself, so this loop reads the object's own position.
- a = 3. next() gives 3, so a is 3, and the one position moves on to 2.
- The inner loop starts. For a = 3 the inner loop calls iter(launch) too. It gets the same object with the same position, so it starts at 2, not at 3.
- Two pairs. The inner loop takes 2 and then 1, and keeps (3, 2) and (3, 1). The one position is now past the end.
- The inner loop ends. The next call raises StopIteration, which ends the inner loop.
- The outer loop ends too. The outer loop asks for its next item and gets StopIteration as well, because the inner loop used the items up. OneShot gives only 2 pairs.
- Now Countdown(3). Countdown stores only its start. Each iter(launch) builds a new OneShot(3), so the outer loop gets a position of its own.
- a = 3 again. The outer loop's own iterator gives 3.
- A new inner iterator. The inner loop's iter(launch) builds a second OneShot(3), which starts again at 3. It gives 3, 2 and 1, and the loop keeps (3, 2) and (3, 1).
- a = 2. The inner loop never touched the outer loop's iterator, so it gives 2. A third new OneShot(3) lets the inner loop find (2, 1).
- Three pairs. For a = 1 the inner loop finds nothing smaller, then the outer iterator ends. Every loop had a position of its own, so Countdown gives all 3 pairs.
Lists, dictionaries and ranges are built exactly this way. The next lesson replaces such iterator classes with a
single function that uses yield, which saves writing the class at all.
Key idea
Return self from __iter__() only in an iterator. A collection returns a new iterator from __iter__(), so every
loop over it, nested or not, starts at the beginning.
Checkpoint
Check: An iterable that restarts
range computes its numbers, so two billion take no more memory than three
range() stores only its start, stop and step, and computes numbers when they are needed. Its length, its items by
position and membership tests come from arithmetic, so a range of two billion numbers costs no more memory than
range(3):
import sys
r = range(0, 10**10, 5) # two billion numbers: every fifth one below ten billion
print(len(r), r[3], r[-1]) # length and indexing, computed
print(9_999_999_995 in r, 7 in r) # membership by arithmetic, not by looping
print(sys.getsizeof(r) == sys.getsizeof(range(3))) # the same size in memory
print(list(range(3)), list(range(3))) # loops over it twice
it = iter(range(3)) # an iterator over a range is one-shot
print(type(it).__name__, list(it), list(it)) Output
2000000000 15 9999999995 True False True [0, 1, 2] [0, 1, 2] range_iterator [0, 1, 2] []
Recorded with Python 3.14.8 on macOS 26 arm64. To run it yourself: mise exec python@3.14.8 -- python3 range_lazy.py
Runs on this device, in your browser. The first run downloads Python (about 13.5 MB), which is kept for the next runs.
Your run, in this browser
sys.getsizeof() reports the same size for both ranges, the third line’s True. The membership test for
9,999,999,995 answered at once: for integers, CPython computes it from the start, stop and step instead of looping.
And because a range is an iterable, not an iterator, it can be looped over any number of times. Only
iter(range(3)), a range_iterator, behaves like the one-shot objects above.
Checkpoint
Check: range, a lazy sequence
iter(function, sentinel) calls a function until it returns the sentinel
iter() has a second form. Given a function and a sentinel: A value that marks the end of the data instead of a count, such as the b"" that read() returns at the end of a file. iter(func, sentinel) calls func() until it returns that value. value,
iter(function, sentinel) returns an iterator that calls the function with no arguments on every next() and stops
as soon as it returns the sentinel. Reading a binary file in fixed-size pieces is its classic use, because read()
returns b"" at the end:
import io
import random
from functools import partial
data = io.BytesIO(b"0123456789abcdef!")
read4 = partial(data.read, 4) # read4() returns the next 4 bytes
for chunk in iter(read4, b""): # call read4() until it returns b""
print(chunk)
rng = random.Random(7)
roll = partial(rng.randint, 1, 6)
print("rolls before the first 6:", list(iter(roll, 6))) Output
b'0123' b'4567' b'89ab' b'cdef' b'!' rolls before the first 6: [3, 2, 4]
Recorded with Python 3.14.8 on macOS 26 arm64. To run it yourself: mise exec python@3.14.8 -- python3 chunks.py
Runs on this device, in your browser. The first run downloads Python (about 13.5 MB), which is kept for the next runs.
Your run, in this browser
functools.partial(data.read, 4) makes a function that needs no arguments, which is what this form of iter()
requires. Step through the first loop and watch each result meet the sentinel test:
The drawing shows the whole scene; its 7 steps are listed below.
Every step as text
- next() calls read4(). The loop asks the iterator for its first item. The iterator calls read4() with no arguments, which reads the next 4 bytes of data.
- Not the sentinel. read4() returns b'0123'. The iterator compares it with the sentinel, b"", and they are not equal.
- The loop gets it. So next() returns b'0123', the loop gives it the name chunk, and its body prints it.
- Three more chunks. The same happens three more times: b'4567', b'89ab' and b'cdef' each pass the test and are printed.
- The last byte. Only 1 byte is left, so read4() returns b'!'. That is still not b"", so the loop gets it as well.
- The sentinel. Now read4() returns b"", the end of the data. It equals the sentinel, so the iterator raises StopIteration and the loop ends; b"" never reaches the loop.
- Any value can end it. The dice use the same rule with iter(roll, 6). The rolls 3, 2 and 4 come out, and the first 6 stops the iterator without being returned.
The loop received 4 bytes at a time and the 1 byte left at the end, then stopped at b"" without passing it on. The
dice show that the sentinel can be any value: the rolls before the first 6 were 3, 2 and 4, and the 6 itself was not
returned. The random generator is seeded, so the run gives the same rolls every time.
Checkpoint
Check: iter() with a sentinel
Practise: a Fibonacci iterator and overlapping pairs
Write two iterators with sample tests that run in your browser and a first hint: a Fibonacci class that is used up
after one pass, and a pairs() function that stays lazy on input that can be read only once.
Exercise · Medium · Python
A Fibonacci iterator and overlapping pairs
Write two iterators in sequences.py, without itertools and without yield (generators come in the next lesson).
Fibonacci(limit=None)is an iterator class. It produces the Fibonacci numbers 0, 1, 1, 2, 3, 5, 8 … and stops after the last number that is not greater thanlimit, solist(Fibonacci(10))is[0, 1, 1, 2, 3, 5, 8]. Without a limit it never stops. Like every iterator it is used up once: a secondlist()of the same object is[], andnext()keeps raisingStopIteration.pairs(iterable)returns an iterator of the overlapping pairs of neighbouring items:list(pairs("abcd"))is[("a", "b"), ("b", "c"), ("c", "d")]. It must work on any iterable, including iterators that can be read only once, and it must be lazy: asking for the first pair reads only the first two items. Fewer than two items give no pairs.
Starter code · sequences.py
class Fibonacci:
"""The Fibonacci numbers 0, 1, 1, 2, 3, ... up to limit (None: no end)."""
def __init__(self, limit=None):
self.limit = limit
def __iter__(self):
return self
def __next__(self):
raise StopIteration
def pairs(iterable):
"""An iterator of the overlapping pairs of neighbouring items."""
items = list(iterable)
return iter([(items[i], items[i + 1]) for i in range(len(items) - 1)]) The sample tests · test_sequences.py
from itertools import islice
from sequences import Fibonacci, pairs
class Reader:
"""An iterator over items that counts how many were read."""
def __init__(self, items):
self._it = iter(items)
self.read = 0
def __iter__(self):
return self
def __next__(self):
value = next(self._it)
self.read += 1
return value
def test_fibonacci_with_limit():
"""stops after the last number that is not greater than the limit"""
assert list(Fibonacci(10)) == [0, 1, 1, 2, 3, 5, 8]
assert list(Fibonacci(13)) == [0, 1, 1, 2, 3, 5, 8, 13]
assert list(Fibonacci(0)) == [0]
def test_fibonacci_without_limit():
"""keeps going without a limit"""
assert list(islice(Fibonacci(), 12)) == [0, 1, 1, 2, 3, 5, 8, 13, 21, 34, 55, 89]
def test_fibonacci_is_one_shot():
"""is its own iterator and stays used up"""
f = Fibonacci(5)
assert iter(f) is f
assert list(f) == [0, 1, 1, 2, 3, 5]
assert list(f) == []
assert next(f, "done") == "done"
def test_pairs():
"""pairs neighbouring items, and gives nothing for fewer than two items"""
assert list(pairs("abcd")) == [("a", "b"), ("b", "c"), ("c", "d")]
assert list(pairs([1, 2])) == [(1, 2)]
assert list(pairs([1])) == []
assert list(pairs([])) == []
def test_pairs_is_lazy():
"""reads only the items it needs, from an iterator that can be read once"""
source = Reader(range(1_000_000))
p = pairs(source)
assert next(p) == (0, 1)
assert source.read == 2
assert next(p) == (1, 2)
assert source.read == 3
assert iter(p) is p A hint
An iterator class needs two methods: __iter__(), which returns self, and __next__(), which returns the next value or raises StopIteration. For Fibonacci, keep the two latest numbers as attributes and move them on in __next__(). For pairs(), write a small class that calls iter() on the iterable once in __init__() and remembers the previous item; pairs() returns an instance of it.
Results of the sample tests
| Test | Result | Details |
|---|
What your code printed
The sample tests run on this device, in your browser (Pyodide): nothing is sent to mysmartcopilot.com. The first run downloads Python (about 13.5 MB), which is kept for the next runs. A check in your browser is feedback for you, not proof that the code is right for every input.
Last, a session of questions on the seven ideas above. It opens with the idea you have answered least well so far and explains every answer, right or wrong, as soon as you give it:
Practice that adapts to you
Practise this lesson
14 questions about 7 ideas of this lesson. Each answer is explained at once, and what you get right is kept in this browser.
Interview questions
Fresher to 2–5 years · What is the difference between an iterable and an iterator?
An iterable is any object that can produce an iterator: calling iter() on it works, because it defines
__iter__() (or, in older code, __getitem__() with indexes from 0). Lists, strings, dictionaries, sets, ranges and
files are iterables. An iterator is the object that does the iterating: it has __next__(), which returns the next
item or raises StopIteration when there are no more, and an __iter__() that returns itself. The practical
difference is reuse. A container gives every loop a new iterator, so it can be looped over again and again, while
an iterator has a single position and is used up after one pass. That is why map(), zip(), filter() and open
files give an empty second loop. Every iterator is an iterable, but not every iterable is an iterator.
Key takeaways
for x in objcallsiter(obj)once, thennext()untilStopIteration, which the loop handles silently.- An iterable hands out a new iterator to every loop; an iterator returns itself from
iter(), keeps one position and is used up after one pass, asmap,filter,zip,enumerate,reversedand files are. - Every read moves an iterator forward,
inandsum()included, so a second loop over the same iterator sees only what is left: keep a list when the data is read more than once. - Write an iterator class with
__iter__()returningselfand__next__()raisingStopIteration; for data that is looped over more than once, return a new iterator from__iter__()instead. rangeis a lazy sequence that computes its numbers and can be looped over many times;iter(function, sentinel)calls a function until it returns the sentinel.
Terms in this lesson
- iterator
- An object that hands out items one at a time: next() returns its next item, or raises StopIteration when none are left. It keeps one position, so it is used up after one pass, and iter() returns the iterator itself.
- sentinel
- A value that marks the end of the data instead of a count, such as the b"" that read() returns at the end of a file. iter(func, sentinel) calls func() until it returns that value.
- StopIteration
- The exception next() raises when an iterator has no items left. A for loop catches it and ends quietly, which is why a loop never shows it.
References
- The Python Tutorial: Iterators (Python Software Foundation)
- Built-in Types: Iterator Types (Python Software Foundation)
- Built-in Functions: iter() (Python Software Foundation)
- Built-in Functions: next() (Python Software Foundation)
- The Python Language Reference: The for statement (Python Software Foundation)
- Glossary: iterable (Python Software Foundation)
- Glossary: iterator (Python Software Foundation)
- Built-in Types: Ranges (Python Software Foundation)
- io: IOBase iterates over lines (Python Software Foundation)
- sys.getsizeof() (Python Software Foundation)
Related tools
Report a problem with this lesson
Kept only in this browser. Your Learn progress