Lesson 07 / 16
Iterator Protocol
You do not need to rebuild the container to see its items: a slice copy writes three references into a new list while an iterator writes zero, shares all three, and materializes no item.
Contents
The previous lesson measured that a copy stays one layer deep: a list taken by slice was a new container, but every item inside it was the same object as before. Still, building that container was not free. The new list did not duplicate items, but it had to write each one’s reference into itself — the copy produced no items, it touched all of them.
This lesson’s question goes one step further. To see a collection’s items start to finish, does that container have to be rebuilt? Is it possible to pass over items without ever touching them, without building any new container? Python’s answer is the iterator contract, and this lesson measures that contract on the course’s axis: how many objects does a pass materialize, how many does it retain, how many does it share?
The Contract
The contract consists of two built-ins and two special methods. iter(n) requests an
iterator from an object and calls the object’s __iter__ method. next(y) requests
the next item from the iterator and calls __next__. When there is no item left to
give, __next__ does not return a value, it throws StopIteration.
The Python Fundamentals course’s loops lesson measured this protocol from the
consuming side: which special method the for notation calls and how many
times, and that what ends the loop is not a return value but an exception, were
established there; that measurement is not repeated here. This lesson’s side is the
other one — the side that writes the contract. Two objects following the same
protocol look identical from outside, but one materializes and retains its items
ahead of time, the other materializes on demand and retains nothing at all. Syntax
does not say this difference; numbers do.
Iterable and Iterator
The contract defines two roles, and confusing them is the most common mistake. An
iterable object defines __iter__; it is not walked itself, it gives what will
walk it. An iterator defines __next__ and does the actual walking. A list is an
iterable but not an iterator: next(a_list) does not work, iter(a_list) has to be
called first.
It is not forbidden for an object to define both. Then __iter__’s body returns
itself, and the distinction disappears; the result of that is measured below. In the
case where the distinction stays intact, __iter__ produces a fresh iterator
every time it is called.
The definition that follows sits at the course’s axis: what an iterator carries is not items, it is where it left off. A position is not an item, and it does not grow with item count.
The measurement’s assumptions:
- IF1 — The oracle is the rig itself:
Itemcounts every production of itself, and sharing is shown withis; the measure for “how many objects were materialized” is this counter. - IF2 — The shared reference’s
Itemclass is used as is, with its counting arrangement. - IF3 — The source has three items. The count is kept small, because what is measured is not size, it is whether the pass builds a container.
- IF4 — “Held in container” is the number of item references an object coming out of the pass writes into itself: a list’s length, zero for an iterator. The measure is the reference count, not bytes measured.
- IF5 — “Shared” is how many of the items coming out of the walk are the same,
by
is, as the object in the source. - IF6 —
Countingmaterializes items on demand and stores none of them;Containermaterializes items at construction and holds them in a list. The two classes differ only at this point, and both sign the same contract. - IF7 — The single-pass measurement is done by calling
list()twice on the same object; the item count the second call returns is the measure. - IF8 — In the sentinel measurement,
nextis called with a second argument and tried twice on an exhausted iterator; the goal is showing that exhaustion is permanent. - IF9 — In the two-pass measurement, all three of the three approaches request the same five-item source; the only thing that changes is where the items come from. The materialized count is the sum of the two passes; the retained count is the number of references staying alive between passes.
Measurement
"""Iterator contract: how many objects does a pass materialize, how many references does it hold.""" COUNTER = {"produced": 0} class Item: """An item that counts every production of itself.""" def __init__(self, value): COUNTER["produced"] += 1 self.value = value def __repr__(self): return f"Item({self.value})" def reset(): COUNTER["produced"] = 0 def produced(): return COUNTER["produced"] def retained(obj): """The number of Item objects an object keeps alive: on itself or in a list attribute.""" if isinstance(obj, list): return sum(isinstance(o, Item) for o in obj) count = 0 for d in getattr(obj, "__dict__", {}).values(): if isinstance(d, list): count += sum(isinstance(o, Item) for o in d) return count SOURCE = [Item(i) for i in range(3)] FORMS = (("iter(source)", lambda: iter(SOURCE)), ("source[:]", lambda: SOURCE[:]), ("list(source)", lambda: list(SOURCE)), ("reversed(source)", lambda: reversed(SOURCE)), ("copy via comprehension", lambda: [Item(o.value) for o in SOURCE])) print(f"{'pass form':<22s} {'new container':>13s} {'materialized':>12s}" f" {'held in container':>18s} {'shared':>7s}") for name, build in FORMS: reset() obj = build() container = "list" if isinstance(obj, list) else "no" held = retained(obj) shared = sum(1 for o in list(obj) if any(o is k for k in SOURCE)) print(f" {name:<20s} {container:>13s} {produced():12d} {held:18d} {shared:7d}") y = iter(SOURCE) print() print(f"iter(source) is source -> {y is SOURCE}, iter(y) is y -> {iter(y) is y}") print(f"next(y) is source[0] -> {next(y) is SOURCE[0]}") a, b = iter(SOURCE), iter(SOURCE) print(f"two separate iterators: {len(list(a))} and {len(list(b))} items") t = iter(SOURCE) print(f"same iterator twice: {len(list(t))} and {len(list(t))} items") class Counting: """An iterator that materializes items on demand.""" def __init__(self, n): self.n, self.i = n, 0 def __iter__(self): return self def __next__(self): if self.i >= self.n: raise StopIteration self.i += 1 return Item(self.i - 1) class Container: """An iterable that materializes items up front and holds them.""" def __init__(self, n): self.items = [Item(i) for i in range(n)] def __iter__(self): return iter(self.items) print() print(f"{'object':<12s} {'at setup':>9s} {'held at setup':>15s}" f" {'after two items':>16s} {'__iter__ gives':>15s}") for name, build in (("Counting(5)", lambda: Counting(5)), ("Container(5)", lambda: Container(5))): reset() n = build() setup, setup_held = produced(), retained(n) it = iter(n) next(it), next(it) print(f" {name:<12s} {setup:9d} {setup_held:15d} {produced():16d}" f" {('itself' if iter(n) is n else 'another object'):>15s}") counting = Counting(3) print() print(f"Counting(3) first pass {len(list(counting))} items, second pass {len(list(counting))} items") print(f"after exhaustion: {next(counting, 'sentinel')!r} and {next(counting, 'sentinel')!r}") container = Container(3) print(f"Container(3) first pass {len(list(container))} items, second pass {len(list(container))} items") remaining = [3, 2, 1, 0, 9] print(f"iter(callable, sentinel) -> {list(iter(lambda: remaining.pop(0), 0))}") def direct(): s = Counting(5) return len(list(s)), len(list(s)), 0 def collect_to_list(): s = list(Counting(5)) return len(list(s)), len(list(s)), len(s) def rebuild(): return len(list(Counting(5))), len(list(Counting(5))), 0 print() print(f"{'two-pass approach':<30s} {'materialized':>12s} {'retained':>9s}" f" {'first':>8s} {'second':>7s}") for name, f in (("walking the same iterator", direct), ("collecting to a list first", collect_to_list), ("rebuilding on every pass", rebuild)): reset() first, second, held = f() print(f" {name:<28s} {produced():12d} {held:9d} {first:8d} {second:7d}")
pass form new container materialized held in container shared iter(source) no 0 0 3 source[:] list 0 3 3 list(source) list 0 3 3 reversed(source) no 0 0 3 copy via comprehension list 3 3 0 iter(source) is source -> False, iter(y) is y -> True next(y) is source[0] -> True two separate iterators: 3 and 3 items same iterator twice: 3 and 0 items object at setup held at setup after two items __iter__ gives Counting(5) 0 0 2 itself Container(5) 5 5 5 another object Counting(3) first pass 3 items, second pass 0 items after exhaustion: 'sentinel' and 'sentinel' Container(3) first pass 3 items, second pass 3 items iter(callable, sentinel) -> [3, 2, 1] two-pass approach materialized retained first second walking the same iterator 5 0 5 0 collecting to a list first 5 5 5 5 rebuilding on every pass 10 0 5 5
A Pass That Builds No Container
The top table lines up five pass forms side by side and splits them into three classes.
Those that rebuild the container. source[:] and list(source) produce a new
list and write 3 references into it. The number of Item objects materialized
is 0 — no item is duplicated, and shared is 3. This is the shallow copy the
previous lesson measured: the outer container is new, what is inside is old.
Those that reproduce items. The comprehension in the last row materializes 3
new Item objects and shared drops to 0. Both the container and what is inside
are new.
Those that do neither. In the iter(source) and reversed(source) rows there is
no new container, held in container is 0, materialized is 0, and shared
is still 3. The pass delivers all three of the three items, materializes none of
them, and writes none of their references into itself. The opening question is
answered here: passing over items without touching them is possible.
An objection is fair here: iter(source) also produces an object — the iterator
itself. But that object is not a container; it holds no items, it only carries a
reference to the source and a position. The table’s “materialized” column counts
Item objects, and “held in container” counts item references; the iterator gives
zero on both, because it is neither.
The difference looks like a matter of one reference per three items. If the source
had a thousand items, list(source) would write a thousand references, iter(source)
would still write zero. The container’s cost grows with item count, the iterator’s
does not: the retained count is a function of item count, not of position.
What an Iterator Carries
The three lines in the middle block separate the roles. iter(source) is source is
false: a list is an iterable, but it does not do the walking itself; iter
returns a separate object from it. iter(y) is y is true: requesting an
iterator from an iterator gives back itself. This is why a for loop can accept
both collections and iterators with the same notation — iter is applied to both,
and on the second this call changes nothing.
next(y) is source[0] is true: the item delivered is the source’s own item, not
a copy of it. An iterator is not a producer, it is a deliverer.
The next two lines show where the position is held. Two separate iterators taken from the same list are independent of each other: each gives 3 items. The same iterator walked twice gives 3 and 0. Because the position lives in the iterator, not the list, every new iterator requested from the list starts from zero; an existing iterator continues from where it left off and eventually exhausts.
Two Classes Writing the Contract
The second table compares two classes that sign the same contract. Counting
materializes 0 objects at setup and retains 0; once two items are requested,
the produced count rises to 2. Container materializes 5 at setup, retains
5, and after two items are requested the number is still 5 — production had
already finished.
The two objects are indistinguishable from outside. Both go into a for loop, both
give the same five values, iter and next work on both. The distinction is only
in the numbers: one does the work at setup, the other on demand.
The last column gives the second distinction. Counting.__iter__ returns itself;
Container.__iter__ takes another object from its inner list on every call.
This shows up in the two lines below: Counting(3) gives 3 items on the first
pass, 0 on the second — it exhausts. Container(3) gives 3 on both passes,
because every pass starts with a fresh iterator.
Single-pass behavior is not a flaw, it is the contract’s direct consequence. The contract says “give the next item on request”; it does not say “give the same items a second time too.” For an iterator to give a second pass, it would have to store every item it saw — that is, do exactly the thing it avoids. An object that retains nothing cannot rewind.
Exhaustion is permanent. When next is called two more times on an emptied
Counting, both give the sentinel value; the object does not renew itself.
The Cost of Two Passes
The last table measures the practical counterpart of this result in three ways. On hand is a single-pass source, and items are needed twice.
Walking the same iterator twice does not work: 5 and 0. Materialized is 5, retained is 0 — the cheapest row, but it does not give a second pass.
Collecting to a list and walking it twice gives the second pass: 5 and 5. Materialized is still 5, because the items were produced once and stored. The cost shows in the retained column: it rose from 0 to 5. Had item count been a thousand, this column would be a thousand.
Rebuilding the source on every pass also gives the second pass, and retained stays at 0. Its cost shows in the other column: materialized rose from 5 to 10, because five items were produced twice. This route only works if the source can be rebuilt; a network stream or user input may not give the same items a second time.
None of the three rows beats the sum of the others, and there is a real choice here: there is no two-pass without retaining. You either store the items, reproduce them, or settle for a single pass. An iterator does not remove this choice; it just makes the third option free.
The Contract’s Two Escape Hatches
The last two lines show two built-in flexibilities of the contract.
Called with a second argument, next(y, sentinel) does not throw StopIteration,
it returns the given value. The exception turns into a value; it is the way to ask
“is it done?” without setting up a try block. The value chosen as the sentinel has
to be one that cannot occur in the stream, or the ending gets mixed up with an
item — this is the same reason StopIteration is not a return value.
Called with two arguments, iter does something entirely different: it produces an
iterator whose source is not a collection but a callable. In the example, every
next takes one item from the front of a list, and the pass ends when the sentinel
0 shows up. The result is [3, 2, 1] — the sentinel itself is not delivered.
This way, a file, a queue, or any source giving one next item per call can be fed
into a for loop without ever being turned into a collection.
Summary
- The iterator contract is two built-ins and two special methods:
itercalls__iter__,nextcalls__next__;StopIterationis thrown when no item is left. - Passing, copying, and reproducing are three separate jobs:
iterbuilds no container and writes 0 references,list(source)writes 3, and a comprehension additionally materializes 3 new objects. Sharing is measured in all three, and is 0 only in the last. - An iterable defines
__iter__, an iterator defines__next__;iter(a_list)gives a separate object from the list,iter(an_iterator)gives the object itself. What an iterator carries is not items, it is the position left off at. - Two classes signing the same contract are indistinguishable from outside but differ in their numbers: 0 versus 5 production at setup, 0 versus 5 retention.
- Single-pass behavior is a consequence of the contract: an object whose
__iter__returns itself gives 3 then 0 items, one returning a fresh iterator gives 3 on both passes. An object retaining nothing cannot rewind, and exhaustion is permanent.
Next Step
The Counting class needed three methods, a bound, and a position field across five
lines — all for one thing: remembering where it left off. This is work repeated in
every hand-written iterator, and when the position is not a single number (a nested
walk, a branched search), the class grows fast. The next lesson measures the form
where the language takes this job off your hands: a function whose body pauses and
resumes from where it left off. The number to be measured is this — calling such a
function, how many of the thousand productions in its body does it do at call
time?
To keep your progress and take notes, Log in
My notes
Log in to take notes.