1.3.10 · D4Python Intermediate

Exercises — Iterators — `__iter__`, `__next__`, StopIteration

3,101 words14 min readBack to topic

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. [1, 2, 3] (a list)
  2. iter([1, 2, 3]) (the object iter hands back)
  3. range(5)
  4. 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. [1,2,3]iterable only. A list has __iter__ but no __next__. Check: hasattr([], '__next__') is False.
  2. iter([1,2,3])both. It is a list_iterator; it has __next__ (it's the tape player) and its __iter__ returns self.
  3. range(5)iterable only. range has __iter__ but no __next__; each iter(range(5)) gives a fresh range_iterator.
  4. Countdown(3)both. Its __iter__ returns self and 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).

Figure — Iterators — `__iter__`, `__next__`, StopIteration

(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 value
Recall 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 value

Both 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 value

Why 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, immediate StopIteration)
  • step=0ValueError (matches real range, 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 player

Now 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, unconsumed

Why 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 for over 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 next in range(5) or itertools.islice(it, 5).

Connections

  • Iterators — `__iter__`, `__next__`, StopIteration — the parent note these drills test.
  • Generators — yield, lazy evaluation — rewrite MyRange/Evens in three lines with yield.
  • itertools — islice, count, chainislice is the safe way to bound infinite iterators.
  • for loops and comprehensions — every trace here is what a for does under the hood.
  • Lazy evaluation and memory efficiency — why Evens beats a list of "all evens."
  • Dunder (magic) methods__iter__/__next__ live in this family.
  • StopIteration and PEP 479 — why hand-written classes raise, generators return.