Exercises — Iterators — `__iter__`, `__next__`, StopIteration
The whole page rests on three sentences from the parent note. We restate them once so every solution can lean on them without magic:
Level 1 — Recognition
Exercise 1.1 — Iterable or iterator?
For each object, say whether it is (a) an iterable, (b) an iterator, or (c) both. Justify by naming the method(s) it has.
[1, 2, 3](a list)iter([1, 2, 3])(the objectiterhands back)range(5)- A
Countdown(3)instance from the parent note
Recall Solution 1.1
The test: has __next__ ⇒ iterator (it's a tape player). has __iter__ returning an iterator ⇒ iterable (it's a bag). Both ⇒ both.
[1,2,3]— iterable only. A list has__iter__but no__next__. Check:hasattr([], '__next__')isFalse.iter([1,2,3])— both. It is alist_iterator; it has__next__(it's the tape player) and its__iter__returnsself.range(5)— iterable only.rangehas__iter__but no__next__; eachiter(range(5))gives a freshrange_iterator.Countdown(3)— both. Its__iter__returnsselfand it defines__next__, so the object is its own tape player.
The pattern: containers (list, range, dict) are iterable-only; the thing iter() returns is the iterator; a class whose __iter__ returns self is both.
Level 2 — Application
Exercise 2.1 — Count the button presses
Given:
it = iter([10, 20, 30])How many times can you call next(it) returning a real value before it raises StopIteration? What does the 4th call do? Write the exact outputs.
Recall Solution 2.1
Three values live in the list, so 3 successful presses.
next(it) # 10 (1st press)
next(it) # 20 (2nd press)
next(it) # 30 (3rd press, last item)
next(it) # raises StopIteration (4th press → tape ended)The iterator holds a position; each press advances it. Once position passes the last index, the "end of tape" click fires.
Exercise 2.2 — Trace Countdown
Using the parent note's Countdown, hand-trace list(Countdown(3)). Write the value of self.current after each __next__ call, and the final list.
Recall Solution 2.2
list(...) presses next until StopIteration.
| call | check current <= 0? |
value returned | current after |
|---|---|---|---|
| 1 | 3 ≤ 0? no | 3 | 2 |
| 2 | 2 ≤ 0? no | 2 | 1 |
| 3 | 1 ≤ 0? no | 1 | 0 |
| 4 | 0 ≤ 0? yes | — raises StopIteration |
0 |
Result: [3, 2, 1]. The exhaustion check runs first, so when current is 0 we stop before returning it. That is exactly why 0 never appears.
Exercise 2.3 — next with a default
Predict the output:
it = iter([7])
print(next(it, 'end'))
print(next(it, 'end'))
print(next(it, 'end'))Recall Solution 2.3
7
end
end
First press returns the only item 7. Every press after exhaustion would raise StopIteration, but the default 'end' catches it internally and returns 'end' instead. The iterator stays exhausted, so it keeps giving 'end' forever.
Level 3 — Analysis
Exercise 3.1 — Why does nested looping over a list work?
Explain, using the protocol, why this prints 4 pairs and not fewer:
nums = [1, 2]
for a in nums:
for b in nums:
print(a, b)Then predict what happens if nums were replaced by its own iterator it = iter([1, 2]) used in both loops.
Recall Solution 3.1
List version — 4 pairs. Each for calls iter(nums) and gets a separate tape player with its own position. The outer loop's player and the inner loop's player advance independently over the same bag. Look at the figure below: two arrows (orange = outer, teal = inner) point at different positions of the same list, because each iter(nums) minted its own player. That independence is exactly what lets all four pairs print. Output: (1,1)(1,2)(2,1)(2,2).

(In the figure: the two cream boxes are the shared list nums; the orange arrow is outer-loop iterator A sitting at value 1, the teal arrow is inner-loop iterator B sitting at value 2. Neither arrow can disturb the other's position — that is the whole point.)
Shared-iterator version — 2 pairs, then done.
it = iter([1, 2])
for a in it: # outer press → a = 1, position now at 2
for b in it: # inner presses: b = 2, then StopIteration
print(a, b) # prints (1, 2)Now there is only one player, so the inner loop consumes the same position, exhausting it. When the outer loop presses again it immediately gets the "end of tape" click. Output: only (1, 2). This is the concrete reason a list is deliberately not its own iterator.
Exercise 3.2 — Off-by-one in a custom iterator
This class is meant to yield 0, 1, 2 for UpTo(3). Find the bug, state what it prints, and fix it.
class UpTo:
def __init__(self, stop):
self.stop = stop
self.n = 0
def __iter__(self):
return self
def __next__(self):
value = self.n
self.n += 1
if self.n > self.stop:
raise StopIteration
return valueRecall Solution 3.2
Trace UpTo(3) (stop = 3):
| call | value = n |
n += 1 |
n > 3? |
returns |
|---|---|---|---|---|
| 1 | 0 | 1 | no | 0 |
| 2 | 1 | 2 | no | 1 |
| 3 | 2 | 3 | no | 2 |
| 4 | 3 | 4 | yes | StopIteration |
Wait — it does print 0, 1, 2. The subtle bug appears at the boundary case UpTo(1): expected [0].
| call | value |
n after |
n > 1? |
returns |
|---|---|---|---|---|
| 1 | 0 | 1 | no | 0 |
| 2 | 1 | 2 | yes | StopIteration |
That still gives [0]. Now UpTo(0), expected []:
| call | value |
n after |
n > 0? |
returns |
|---|---|---|---|---|
| 1 | 0 | 1 | yes | StopIteration |
Gives []. So the class is actually correct for all these — the check-after-increment happens to work because we saved value before incrementing.
The real fragility: the exhaustion check runs after mutating state, so if you ever add code between value = self.n and the check, a wrong value leaks. The robust pattern (parent note's Countdown) checks exhaustion first:
def __next__(self):
if self.n >= self.stop: # check FIRST
raise StopIteration
value = self.n
self.n += 1
return valueBoth give [0,1,2] for UpTo(3), but check-first is the pattern that survives edits.
Level 4 — Synthesis
Exercise 4.1 — Build myrange(start, stop, step)
Write an iterator class MyRange that reproduces range(start, stop, step) for a positive step, raising StopIteration at the right moment. Then extend it to also handle a negative step (counting down). Give the full class and show it matches list(range(...)) for:
(0, 5, 1), (2, 10, 3), (5, 0, -1), (0, 0, 1).
Recall Solution 4.1
class MyRange:
def __init__(self, start, stop, step=1):
if step == 0:
raise ValueError("step must not be zero")
self.current = start
self.stop = stop
self.step = step
def __iter__(self):
return self
def __next__(self):
# check-first, and the stop test depends on step's SIGN
if self.step > 0:
done = self.current >= self.stop
else:
done = self.current <= self.stop
if done:
raise StopIteration
value = self.current
self.current += self.step
return valueWhy the sign split? With a positive step we walk upward and stop when we reach/pass stop from below (current >= stop). With a negative step we walk downward and must stop from above (current <= stop). One fixed comparison cannot cover both directions — the halting condition literally flips with the sign.
Checks (all match list(range(...))):
(0,5,1)→[0,1,2,3,4](2,10,3)→[2,5,8](5,0,-1)→[5,4,3,2,1](0,0,1)→[](start already ≥ stop, immediateStopIteration)step=0→ValueError(matches realrange, which forbids zero step).
Exercise 4.2 — Reusable vs one-shot
Make MyRange reusable: looping over the same object twice should give the full sequence both times. Show the minimal change and explain why the original fails a second loop.
Recall Solution 4.2
The original is one-shot: __iter__ returns self, so self.current is consumed by the first loop and never resets. The second loop starts from the exhausted state and yields nothing.
Fix — separate iterable from iterator (bag vs player). The iterable stores the recipe; each __iter__ births a fresh player:
class MyRangeReusable:
def __init__(self, start, stop, step=1):
if step == 0: raise ValueError("step must not be zero")
self.start, self.stop, self.step = start, stop, step
def __iter__(self):
return MyRange(self.start, self.stop, self.step) # new tape playerNow r = MyRangeReusable(0, 3, 1); list(r) → [0,1,2] and a second list(r) → [0,1,2] again, because each call to iter(r) (inside list) makes a brand-new MyRange with its own current.
Level 5 — Mastery
Exercise 5.1 — Safely draining an infinite iterator
Using the parent note's infinite Evens iterator, write code that collects the first 5 even numbers into a list without hanging. Do it two ways: (a) manual next in a loop, (b) using `itertools.islice`. State the exact result.
Recall Solution 5.1
Evens yields 0, 2, 4, 6, 8, … forever (self.n starts at -2, first next makes it 0). list(Evens()) would loop forever — we must bound it.
(a) Manual:
ev = Evens()
out = []
for _ in range(5): # bound = 5 presses only
out.append(next(ev))
# out == [0, 2, 4, 6, 8](b) islice:
from itertools import islice
out = list(islice(Evens(), 5)) # [0, 2, 4, 6, 8]islice(iterator, 5) presses next at most 5 times, then stops — it never asks the infinite source for a 6th item, so nothing hangs.
Exercise 5.2 — A Peekable wrapper (design)
Design a class Peekable(iterator) that wraps any iterator and adds a peek() method: peek() returns the next item without consuming it, so the following next() still returns that same item. Handle the empty case. Show a trace.
Recall Solution 5.2
Key idea: a tape player has no rewind, so to "peek" we must pull one item and stash it in a buffer, then hand it out on the next real next.
_SENTINEL = object() # a unique "nothing buffered" marker
class Peekable:
def __init__(self, it):
self._it = iter(it) # be tolerant: accept any iterable
self._buf = _SENTINEL
def __iter__(self):
return self
def __next__(self):
if self._buf is not _SENTINEL: # something was peeked
value, self._buf = self._buf, _SENTINEL
return value
return next(self._it) # normal press; may raise StopIteration
def peek(self, default=_SENTINEL):
if self._buf is _SENTINEL: # nothing stashed yet
try:
self._buf = next(self._it) # pull one and stash it
except StopIteration:
if default is _SENTINEL:
raise # empty and no default → re-raise
return default # empty but caller gave a fallback
return self._buf # hand back the stashed item, unconsumedWhy the sentinel and not None? A real stream might legitimately contain None; using None as "empty" would confuse a buffered None with "buffer empty." A unique object() can never collide with real data.
Trace on Peekable(iter([1, 2])):
| call | buffer before | action | returns | buffer after |
|---|---|---|---|---|
peek() |
empty | pull 1, stash | 1 | 1 |
peek() |
1 | already stashed | 1 | 1 |
next() |
1 | drain buffer | 1 | empty |
next() |
empty | press source | 2 | empty |
peek('x') |
empty | source exhausted → default | 'x' |
empty |
next() |
empty | press source | StopIteration |
empty |
The two peek() calls both return 1 and do not advance; the first real next() still yields 1. That is the whole contract.
Recall Self-test checklist
- Can you state the one-line test that separates iterable from iterator? :::
hasattr(obj, '__next__')— has it ⇒ iterator. - Why does nested
forover a list work but over a shared iterator fail? ::: A list gives each loop a fresh iterator (independent position); a shared iterator has one position that the inner loop exhausts. - Why must
MyRange's stop test depend on the step's sign? ::: Positive step halts from below (>= stop), negative step halts from above (<= stop); one comparison can't cover both directions. - How do you take 5 items from an infinite iterator safely? ::: Bound it — manual
nextinrange(5)oritertools.islice(it, 5).
Connections
- Iterators — `__iter__`, `__next__`, StopIteration — the parent note these drills test.
- Generators — yield, lazy evaluation — rewrite
MyRange/Evensin three lines withyield. - itertools — islice, count, chain —
isliceis the safe way to bound infinite iterators. - for loops and comprehensions — every trace here is what a
fordoes under the hood. - Lazy evaluation and memory efficiency — why
Evensbeats a list of "all evens." - Dunder (magic) methods —
__iter__/__next__live in this family. - StopIteration and PEP 479 — why hand-written classes
raise, generatorsreturn.