Pebellslearn by building
0%
RoadmapsMatrixCoursesSandboxSign in
Iteration & Laziness  ›  Lesson

Writing an Iterable by Hand

Iterator Protocol 22 minThe two methods and one exception behind every for loop, and the difference between a container and a stream
You're building a piece ofQuarry — the feed
This piece — row_cursor(): Walks a source's rows one at a time, skipping blanks and comments, and can be walked again from the top.
Scenario Quarry's source files mix blank lines and # comments in with the real rows, and a bad row has to be reported by its line number in the original file.
Your task
Write RowCursor(lines), a re-walkable iterable whose __iter__ returns a fresh RowIterator. Each walk yields (line_number, text) for every line that isn't blank or a # comment, with line numbers counted from 1 in the original list and text stripped. Then write row_cursor(lines), which loops over ONE cursor twice and returns {"first": [...], "second": [...]} as [number, text] lists. Example: row_cursor(["boot ok", "", "# note", " disk warm "]) → both walks are [[1, "boot ok"], [4, "disk warm"]].

Writing an Iterable by Hand

In short: a for loop doesn't know about lists, files or dicts. It only calls iter(obj) once, to get an iterator, then calls next(iterator) once per pass until it raises StopIteration. An iterable is anything iter() accepts — it hands back a fresh iterator every time, which is why you can loop over the same list twice. An iterator is what iter() returns: it has __next__, remembers a position, and its own __iter__ just returns itself — which is why you can only walk one, once. Quarry's 200 MB log can't be read into a list first; everything in this section rests on these two methods and one exception.

A for loop pulling values one at a time from an iterator with next(), getting "boot ok" back, then a fourth call raising StopIteration instead of returning — the iterator only moves when asked

Read a 200 MB log without building a list

Here is a for loop written the long way — the two calls it's secretly making:

python

That's the whole protocol. next also takes an optional default, turning "the first item, if there is one" into one line instead of a try:

What does this print?
it = iter(["INFO boot", "ERROR disk"])
next(it)
next(it)
print(next(it, "no more rows"))

The mistake people make is treating something iterable (a list) as if it were the iterator itself. A list hands out a brand new iterator every time you call iter() on it — that's why looping over the same list twice just works. map, by contrast, returns an iterator directly, with exactly one position:

What does this print?
upper = map(str.upper, ["info", "warn", "error"])
print(len(list(upper)), len(list(upper)))

A file object works the same way: looping over an open file gives you one line at a time, and the file itself is the position. That's what makes it safe to read a file far bigger than memory — nobody ever asks for "all of it" at once.

Give Quarry's rows a class — the wrong way

To make your own class work in a for loop, give it __iter__ and __next__:

python

Step through a few calls by hand and watch pos move. Each next does one small piece of work and returns — nothing runs between calls, because nothing is asking:

step through it
1class LineIterator:
2 def __init__(self, lines):
3 self.lines = lines
4 self.pos = 0
5 def __iter__(self):
6 return self
7 def __next__(self):
8 if self.pos >= len(self.lines):
9 raise StopIteration
10 line = self.lines[self.pos]
11 self.pos += 1
12 return line
13 
14it = LineIterator(["boot ok", "disk warm"])
15first = next(it)
16second = next(it)
17third = next(it, "done")
18print(first, second, third, it.pos)

The same conversation, as a picture. The consumer does all the asking — the iterator never sends anything on its own:

Drawing diagram…

This is what people mean when an iterator is pulled, not pushed. It's also why laziness saves memory: a value nobody has asked for yet has not been read, computed or stored.

Now the trap that catches almost everyone who writes their first iterable class. Quarry wraps its rows in a class so it can add behaviour later, and a report runs over the rows twice — once to count, once to print. The second pass comes back empty:

broken — fix it

Run it and look at the second list. Then change __iter__ so each loop gets its own iterator, and the same Rows object can be walked twice.

Rows holds data, so it's a container — but it was written as an iterator, with one shared position that sits at the end for good after the first pass. The rule: if your class holds data, give it __iter__ that returns a new iterator each time, no __next__ — it's an iterable. If your class is a position in some data, give it __next__, and make __iter__ return self — it's an iterator. Often the new iterator doesn't need its own class at all: return iter(self.lines) is a perfectly good __iter__.

Fetch pages only when the buffer runs dry

This is where pulling starts to pay. Some of Quarry's sources hand over rows a page at a time over the network. A cursor that fetches every page up front wastes fetches whenever the consumer stops early — and Quarry's preview mode always stops early. So the cursor keeps a small buffer and only fetches when it runs dry:

python

The consumer stopped at row 23, so the cursor fetched three pages and never touched the other seven. An eager reader — one that fetches every page up front — always pays for all ten, whatever the consumer actually needed. A lazy cursor's cost scales with what was read, not with what exists.

Two smaller tools in real code: iter(callable, sentinel) calls a function repeatedly and stops at a sentinel — iter(lambda: f.read(4096), "") reads a file in 4 KB chunks until end of file (lesson 02 returns to this). And a class with __getitem__ but no __iter__ still loops: Python falls back to obj[0], obj[1], … until IndexError — old, don't design around it.

Quick practice

What does this print?
rows = ["a", "b", "c"]
print(iter(rows) is iter(rows))
What does this print?
it = iter(["a", "b"])
print(iter(it) is it)
What does this print?
class TwoValues:
    def __init__(self):
        self.pos = 0
        self.values = ["a", "b"]

    def __iter__(self):
        return self

    def __next__(self):
        if self.pos >= len(self.values):
            return None          # bug: should raise StopIteration
        v = self.values[self.pos]
        self.pos += 1
        return v

it = TwoValues()
print(next(it), next(it), next(it))

🎯 Your turn

Quarry's source files have blank lines and # comments mixed in with the real rows, and a bad row has to be reported by its line number in the original file, not by its position after the blanks were dropped.

Write the class RowCursor(lines). It's a container, so looping over it must give a fresh walk every time. Each walk yields (line_number, text) pairs, where line_number counts from 1 in the original list and text is the line with the surrounding whitespace stripped. Lines that are blank or start with # after stripping are skipped.

Then write row_cursor(lines), which builds one cursor, loops over it twice, and returns both walks as lists of [number, text] lists:

  • row_cursor(["boot ok", "", "# note", " disk warm "]) → {"first": [[1, "boot ok"], [4, "disk warm"]], "second": [[1, "boot ok"], [4, "disk warm"]]}

Write the iterator by hand with __iter__ and __next__. No yield yet.

Hint: two classes, RowCursor with __iter__ returning a new RowIterator, and RowIterator holding the position and doing the skipping inside __next__.

Press ▶ Run, then ✓ Check. ✅

Practice — level up

0/6 solved

Solve each in its own editor — Run to try, Check to grade. Stuck? Reveal a Hint, or the full Solution + walkthrough.

Warm-upThe first n, by hand

Write take_first(items, n), which returns a list of the first n items of any iterable, or fewer if it runs out. Use iter() once and next() with a default, not slicing, so it works on a file or a map as well as a list.

iteration-protocolstopiteration
DrillA countdown iterator

Write the iterator class Countdown(start), which yields start, start - 1, … down to 1, then stops. countdown(start) (already written) returns list(Countdown(start)).

custom-iterables__next__
BuildFetch a page only when you need one

A remote source hands rows over in pages: pages is a list of lists, and some pages may be empty. Finish PageCursor, an iterator that takes the next page only when its buffer is empty, and counts in fetched how many pages it has taken. Then write page_cursor(pages, want), which pulls up to want rows and returns {"items": [...], "fetched": n}.

custom-iterablesiteration-protocol
BossA loop inside a loop over the same object

pairs(values) lists every pair of readings [a, b] where a comes before b, using two nested loops over the same Readings object. As written it returns far too few pairs. Fix Readings (not pairs) so the nested loops work.

custom-iterablesiterable-vs-iterator
CapstoneLook ahead without using it up

Compressing a stream of sensor states means knowing whether the next value is the same as this one before you take it. Write Peekable(iterable), an iterator with peek(default=None) that returns the next value without consuming it (or default at the end). Then write group_runs(items), which uses it to return [[value, run_length], ...] for each run of equal neighbours.

custom-iterables__next__iteration-protocol
ExplainWhy the second loop printed nothing

A teammate wrapped Quarry's rows in a class with __iter__ returning self and a __next__ that walks a list. Looping over it once works, but a second loop straight after prints nothing, and there's no error. Explain what is happening and how to fix it, and say when __iter__ returning self is correct.

row_cursor.py
Call the function you wrote — just like your app's frontend would. Edit the inputs and hit Call.
row_cursor(lines) → dictWalk one cursor twice and return both walks, blanks and comments skipped, original line numbers kept.
try:
returned
Press Run to execute your code.
Press Check to run the tests for this lesson.