Skip to content
academia.sh

Lesson 08 / 16

Loops

A loop is not a control structure, it is a protocol: on a three-item object, __next__ is called four times, the last one throws StopIteration, and what ends the loop is not a condition but that exception.

Contents

The previous lesson measured the truthiness rule: the object after if is put through a question, and that question’s answer ends with a branch. The trial happens once, the result is read once.

A loop does not repeat the same question, it repeats a different question: is there an item next in line? The Programming Fundamentals course’s loops lesson built this structure as a control structure — the number of turns depended either on a collection’s length or on a condition, and the loop’s termination was guaranteed by a change on each turn that moved toward falsifying the condition. In Python a loop is a protocol, and that lesson’s reason for termination does not apply here: a for loop has no condition it tests. So what decides the turns have ended? This is what this lesson measures — and the answer turns out to be an exception class.

What a Loop Calls

The notation for x in n has three steps, and all three are hidden. First an iterator is requested from the object: __iter__ is called. Then on every turn the next item is requested from that iterator: __next__ is called. The third step is termination — and what announces it is not a return value.

It could not have been a return value. __next__ can return any object; whatever value we chose, that value could be a real item of the collection, and “done” would get mixed up with “here is your item.” This is why termination is taken out of the channel: __next__ throws an exception when it has no items left to give — StopIteration. This is what ends the loop.

There is a direct consequence: one last call, a call producing no item, is always made. Regardless of item count, __next__ is called one extra time, and that extra call is the loop’s termination announcement.

Writing the Same Loop by Hand

The way to show the protocol is really a shorthand is to do the same job without for: take the iterator with iter(), set up an infinite loop, call next() on every turn, and exit when StopIteration is caught. If the two notations produce the same protocol sequence, for really is the shorthand for these three steps.

Two more objects enter the measurement. Tracker defines both __iter__ and __getitem__; either is enough for iteration, but only counting tells which one is used. Indexed defines only __getitem__ and has no __iter__ — whether such an object can even enter a loop, and if so which protocol it follows, is the second question.

The measurement’s assumptions:

  • CF8 — The oracle is the rig itself: it logs the name every time a special method is called; the measure for “how many times was it called” is this log.
  • CF9Tracker keeps the behavior from the shared reference: __iter__ resets the counter and returns itself, __next__ throws StopIteration once items run out.
  • CF10 — The item count is varied from zero to four; what is measured is not the count itself but the gap between it and the call count.
  • CF11break is run on the second item; the break point’s location is fixed and chosen to isolate the effect early termination has on the call count.
  • CF12 — The measurement uses no list-building notation, only an explicit for body; its purpose is guaranteeing the counted calls come only from the iteration protocol.
  • CF13Indexed defines only __getitem__; the absence of __iter__ is deliberate and needed so the second protocol can be measured.
  • CF14 — In the while measurement, the body removes one item every turn; the guarantee of termination comes from this, and the trial count and turn count are read separately.
  • CF15 — The nested-loop measurement is done in two states: the two loops on the same object, and on separate objects. Tracker.__iter__ returns itself and resets the counter as in the shared reference; what is measured is the effect these two choices have on the turn count.

Measurement

"""Iteration protocol: how many times does a loop call __next__, and what ends it."""

LOG = []


def record(name):
    LOG.append(name)


class Tracker:
    """Both __iter__ and __getitem__ defined."""

    def __init__(self, items=(1, 2, 3)):
        self.items = list(items)

    def __iter__(self):
        record("__iter__")
        self._i = 0
        return self

    def __next__(self):
        record("__next__")
        if self._i >= len(self.items):
            raise StopIteration
        value = self.items[self._i]
        self._i += 1
        return value

    def __getitem__(self, k):
        record("__getitem__")
        return self.items[k]

    def __len__(self):
        record("__len__")
        return len(self.items)


class Indexed:
    """Only __getitem__ defined: no __iter__."""

    def __init__(self, items=(1, 2, 3)):
        self.items = list(items)

    def __getitem__(self, k):
        record("__getitem__")
        return self.items[k]


def measure(action):
    LOG.clear()
    try:
        action()
    except Exception as e:
        LOG.append(f"!{type(e).__name__}")
    return list(LOG)


LAPS = []


def complete():
    for x in Tracker():
        LAPS.append(x)


def interrupted():
    for x in Tracker():
        LAPS.append(x)
        if x == 2:
            break


def finished_else():
    for x in Tracker():
        LAPS.append(x)
    else:
        record("else")


def interrupted_else():
    for x in Tracker():
        LAPS.append(x)
        if x == 2:
            break
    else:
        record("else")


def manual():
    y = iter(Tracker())
    while True:
        try:
            LAPS.append(next(y))
        except StopIteration:
            break


def fourth():
    y = iter(Tracker())
    for _ in range(4):
        next(y)


FORMS = (("for x in n", complete), ("for + break", interrupted),
         ("for + else", finished_else), ("for + break + else", interrupted_else),
         ("iter/next manual", manual), ("fourth next", fourth))

print(f"{'form':<20s} {'laps':>4s} {'__next__':>9s} {'else':>5s}  protocol sequence")
for name, f in FORMS:
    LAPS.clear()
    c = measure(f)
    p = [a for a in c if a != "else"]
    print(f"  {name:<18s} {len(LAPS):4d} {c.count('__next__'):9d}"
          f" {('yes' if 'else' in c else '-'):>5s}  {' '.join(p)}")

print()
print(f"{'item':>4s} {'laps':>4s} {'__next__':>9s}  diff")
for n in range(5):
    LAPS.clear()

    def count(n=n):
        for x in Tracker(range(n)):
            LAPS.append(x)
    c = measure(count)
    print(f"  {n:2d} {len(LAPS):4d} {c.count('__next__'):9d}"
          f"  {c.count('__next__') - n:+d}")

print()
print(f"{'object':<10s} {'defines':<24s} {'calls':>6s}  protocol sequence")
for name, build, defines in (
        ("Tracker", Tracker, "__iter__ and __getitem__"),
        ("Indexed", Indexed, "only __getitem__")):
    def walk(k=build):
        for _ in k():
            pass
    c = measure(walk)
    print(f"  {name:<8s} {defines:<24s} {len(c):6d}  {' '.join(c)}")
print("  fourth index directly:", " ".join(measure(lambda: Indexed()[3])))


OUTER = []


def nested_same():
    n = Tracker()
    for x in n:
        OUTER.append(x)
        for y in n:
            LAPS.append(y)


def nested_separate():
    for x in Tracker():
        OUTER.append(x)
        for y in Tracker():
            LAPS.append(y)


print()
print(f"{'nested loop':<18s} {'outer laps':>10s} {'inner laps':>10s}"
      f" {'__iter__':>9s} {'__next__':>9s}")
for name, f in (("same object", nested_same), ("separate objects", nested_separate)):
    LAPS.clear()
    OUTER.clear()
    c = measure(f)
    print(f"  {name:<16s} {len(OUTER):10d} {len(LAPS):10d}"
          f" {c.count('__iter__'):9d} {c.count('__next__'):9d}")


def while_loop():
    n = Tracker()
    while n:
        n.items.pop()


print()
c = measure(while_loop)
print(f"while n  (until 3 items empty): calls {len(c)}  {' '.join(c)}")
form                 laps  __next__  else  protocol sequence
  for x in n            3         4     -  __iter__ __next__ __next__ __next__ __next__
  for + break           2         2     -  __iter__ __next__ __next__
  for + else            3         4   yes  __iter__ __next__ __next__ __next__ __next__
  for + break + else    2         2     -  __iter__ __next__ __next__
  iter/next manual      3         4     -  __iter__ __next__ __next__ __next__ __next__
  fourth next           0         4     -  __iter__ __next__ __next__ __next__ __next__ !StopIteration

item laps  __next__  diff
   0    0         1  +1
   1    1         2  +1
   2    2         3  +1
   3    3         4  +1
   4    4         5  +1

object     defines                   calls  protocol sequence
  Tracker  __iter__ and __getitem__      5  __iter__ __next__ __next__ __next__ __next__
  Indexed  only __getitem__              4  __getitem__ __getitem__ __getitem__ __getitem__
  fourth index directly: __getitem__ !IndexError

nested loop        outer laps inner laps  __iter__  __next__
  same object               1          3         2         6
  separate objects          3          9         4        16

while n  (until 3 items empty): calls 4  __len__ __len__ __len__ __len__

One Extra Call

The top table’s first row is the shared reference’s third claim. On a three-item object, the loop takes three laps, but __next__ is called four times. The extra fourth call produces no item; it exists to announce that it produced none.

The second table shows this is not tied to a single instance. As item count rises from zero to four, the diff column does not change: +1 on every row. There is a call even on a zero-item object — even a loop that never takes a lap has to ask once, because it cannot know it is empty without asking. Call count is not a function of item count, it is item count plus the termination announcement.

The last row shows that announcement directly. When the iterator is taken by hand and next() is called four times, the fourth does not return a value, it throws StopIteration. We do not see this exception in a for loop because the loop catches it itself and silently carries flow out of the body. Not seeing it does not mean it is absent: every normal termination of a loop gives birth to an exception, and that exception is caught.

The reading that follows is the shared reference’s third claim: the exception is not an error, it is part of the protocol. If it were an error it would announce an exceptional state; instead this exception announces the most ordinary state — the collection ran out.

The Shorthand Expanded

The fifth row proves the for notation really is a shorthand. The hand-written form — iter(), an infinite loop, next(), catching StopIteration — produces exactly the same protocol sequence: one __iter__, four __next__. There is no execution difference between the two notations; the only difference is that the try block is visible.

The second and fourth rows measure a loop cut off by break, and the count here stands apart from all the rest: two laps, two calls. There is no extra call. break ends the loop from outside the protocol; the third item is never requested, StopIteration is never born. A cut-off loop is the only form where the one extra call disappears.

The same topic’s other control statement, continue, needs no separate row in this table, because it does not touch the protocol at all: it skips the rest of the body and carries the loop to the next __next__ call. The lap count does not change, the extra call stays in place. This is exactly the difference between it and break — one skips a step inside the protocol, the other leaves the protocol.

This also explains the else column. A loop’s attached else block runs when the loop ends by its own protocol; it does not run when cut off by break. In the third row else runs, in the fourth it does not. In other words, else is the answer to the question “did it finish with no break at all?” — put differently, did StopIteration really get thrown? The search pattern that breaks on finding a sought item and behaves in the else block when not found is built directly on this distinction.

The Second Protocol

The third table measures which protocol gets chosen, and it repeats the previous lesson’s pattern.

Tracker defines both __iter__ and __getitem__. The loop chooses __iter__, and __getitem__ is never called. Just as __bool__ disabled __len__ in the truthiness rule, here __iter__ disables __getitem__; the two methods do not compete, the first shuts off the second.

The Indexed row shows the second rung. This object has no __iter__, but the loop still runs: __getitem__ is called four times with rising indices starting from zero. For an object with no iterator, the language recognizes a second path of iteration — request indices in order, until an index turns out invalid.

The two roles have to be separated. An iterable object is one that defines __iter__; it is not walked itself, it gives what will walk it. An iterator is one that defines __next__, and it is the one that actually does the walking. It is not forbidden for one object to define both — Tracker does exactly this and returns itself in the body of __iter__. This is why Tracker is both iterable and an iterator; what happens when that distinction collapses is measured below.

The count is again four, one more than the item count. But the exception ending the extra call is different this time: the last row calls Indexed()[3] directly, and what comes out is not StopIteration but IndexError. Two protocols, two separate termination exceptions, the same +1 pattern. In both paths, what tells the loop it has ended is not a return value but a thrown exception.

Where the Multiplication Rule Breaks

The fourth table measures two nested loops and tests the rule the Programming Fundamentals course gave for nested loops — that the total lap count is the product of the two. On separate objects the rule holds: outer 3 laps, inner 9 laps, that is, three times three.

It does not hold on the same object. The outer loop takes not 3 but 1 lap; the inner takes not 9 but 3. The multiplication rule collapses, and the reason is not the loop itself but what __iter__ returns.

Tracker.__iter__ does not produce a new object; it returns itself and resets the counter. After the outer loop takes its first item, the inner loop calls __iter__ on the same object, the counter resets, the inner loop consumes all three items and drives the counter to the end. When the outer loop asks for its next item, the counter is already at the end: StopIteration arrives and the outer loop ends on its first lap. The two loops share the same counter.

The distinction here is that an iterable object and an iterator are separate things. An object returning a fresh iterator on every __iter__ call behaves as expected in nested loops; one returning itself cannot be walked more than once at the same time. The syntax is identical in both cases, the lap count is not — and the only thing that says which behavior applies is the body of __iter__. It is impossible to see this distinction by looking at the object’s type, name, or item count.

The last row gives the same pattern for while. A loop running until three items are emptied calls __len__ four times: three trials for three laps, plus a fourth trial that falsifies the condition. for calls __next__ one extra time, while does one extra truthiness trial. In both cases, the extra one is the trial that ends the loop.

Summary

  • In Python a loop is not a control structure, it is a protocol: for x in n first calls __iter__, then __next__ for every turn; it has no condition it tests.
  • On a three-item object, __next__ is called four times, and the difference is independent of item count — a call happens even on zero items, because emptiness can only be learned by asking.
  • The extra call does not return a value, it throws StopIteration; this is what ends the loop, and it goes unseen because for catches this exception itself. The exception is not an error, it is part of the protocol.
  • The for notation is shorthand for iter, next, and catching StopIteration; the hand-written expansion produces exactly the same call sequence. break ends the loop from outside the protocol and removes the extra call — the else block reads exactly this distinction.
  • While __iter__ is defined, __getitem__ takes no part in iteration at all; on an object defining only __getitem__, the loop calls it with rising indices, and the exception ending this path is IndexError.
  • The multiplication rule for nested loops breaks when __iter__ returns itself: two loops on the same object share a single counter, and the outer loop takes 1 lap instead of 3; on separate objects the rule still holds (3 and 9).

Next Step

A loop body most often calls a function, and that function is passed different values on every turn. The Programming Fundamentals course built the parameter-versus- argument distinction and the call frame; in Python, the forms of passing an argument go beyond what that lesson assumed — positional and keyword arguments, collecting a variable number of arguments, default values. One of these is about timing, and that is what the next lesson measures: when is a parameter’s default value computed — on every call, or once? The answer, together with a mutable default, produces a surprising number.

To keep your progress and take notes, Log in

My notes

Log in to take notes.

Start typing to search.

↑↓ Esc navigate · open · close