Skip to content
System nominalUTC --:--:--Articles 041
MODULE 02 Lesson 06 / 18 30 MIN

Data and control flow

Loops, Decisions, and Getting Out Early

range, while, break, continue, the else clause on loops that nobody knows, and why the clearest function returns before it nests.

Every program is a sequence of decisions and repetitions, and almost every loop you write has a boundary that has to be exactly right. If it is off by one, the failure is not a wrong number in the middle of a report. It is an IndexError on the last iteration, or a total that quietly omits the last record, and the second one is worse because nothing tells you it happened.

The tools in this lesson are small: for, while, range, break, continue, enumerate, zip, comprehensions. What makes them worth an hour is the handful of decisions about which one to reach for, and the exit conditions that most people write from memory instead of from understanding.

A for loop asks an iterable for its values

A for loop does not need an index and does not know what a list is. It asks an object for the next value until the object says there are none. Lists, tuples, strings, dictionaries, files, sets and generators all answer that question, which is why the same loop shape works on all of them:

text = """first line
second line"""

for word in "the quick brown fox".split():
    print(word)

for key, value in {"host": "db.internal", "port": 5432}.items():
    print(key, value)

for line in text.splitlines():
    print(line.rstrip())
the
quick
brown
fox
host db.internal
port 5432
first line
second line

The file loop is worth noticing. Iterating a file handle yields one line at a time and does not load the whole file into memory, so the same three lines work on a file of ten lines and a file of ten gigabytes. line.rstrip() removes the trailing newline that each line carries and leaves leading whitespace alone, which is what you want when indentation means something.

range produces integers and never includes the stop value

range(start, stop, step) builds a sequence of integers, defaults to a start of zero and a step of one, and excludes the stop:

print(list(range(3)))
print(list(range(1, 5)))
print(list(range(0, 10, 3)))
print(list(range(10, 0, -2)))
print(list(range(3, 3)))
print(len(range(1, 5)))
[0, 1, 2]
[1, 2, 3, 4]
[0, 3, 6, 9]
[10, 8, 6, 4, 2]
[]
4

The exclusion is what makes for i in range(len(items)) correct: the highest index a list of n items accepts is n-1, and range(n) stops there. range(3, 3) is empty rather than an error, and len works on a range without building the list, which is why sum(range(1_000_001)) costs nothing in memory.

A range is not a list. It stores the three numbers and computes values on demand, and in current Python it supports membership tests and indexing, so asking whether a number is in a large range is cheap. Comparing it to a list with == is not: equality between a range and a list is always false, however similar the contents look.

while is for when you cannot say how many times

A for loop knows its sequence up front. A while loop repeats while a condition holds, which is what you want for reading until a sentinel, draining a queue or retrying a request. It is also where the off-by-one appears, because the condition is written by hand and the sequence is described by a length:

queue = ["a", "b", "c"]
i = 0
while i <= len(queue):
    print(i, queue[i])
    i += 1
0 a
1 b
2 c
Traceback (most recent call last):
  File "drain.py", line 3, in <module>
    print(i, queue[i])
IndexError: list index out of range

Three items printed, then a fourth iteration where the index is 3 and the list has no index 3. <= reads as "up to and including the length", and the length is not a valid index. The correction is <:

queue = ["a", "b", "c"]
i = 0
while i < len(queue):
    print(i, queue[i])
    i += 1

Both versions are correct as written and one of them is the wrong loop. If you find yourself maintaining the index by hand while walking a sequence, the loop you want is for item in queue or for i, item in enumerate(queue). Save while for the cases where the exit depends on a value you only learn while running, and remember that the condition is checked before the first iteration, so a loop whose condition is false from the start never runs at all.

break leaves the loop, continue skips one iteration

break exits the innermost loop immediately. continue jumps to the next iteration and skips the rest of the body:

events = ["info", "warn", "error", "info"]

for event in events:
    if event == "error":
        print("stopping at", event)
        break
    print("saw", event)

for number in [1, 2, 3, 4]:
    if number % 2:
        continue
    print("even", number)
saw info
saw warn
stopping at error
even 2
even 4

One break inside a nested loop only leaves the inner one. If you need to come out of both, either set a flag that the outer loop checks, return from the function containing the loops, or restructure so the search is a function of its own. The restructuring is almost always the better answer, because a function that returns the thing you searched for is easier to test than a loop that breaks out of two levels.

continue has one cost worth knowing. Any cleanup at the bottom of the loop body gets skipped, so a continue in a loop that opens a file or takes a lock is how resources leak. Put the skip condition at the top and keep the body linear, or use a with block, which releases on every path out including continue and break.

Loops have an else clause and it means "no break"

This feature is documented under for statements, and most people meet it by accident. The else block runs when the loop finished without hitting a break, and it runs when the sequence was empty:

names = ["ana", "bo", "cy"]

for name in names:
    if name == "zed":
        print("found")
        break
else:
    print("zed is not in the list")

for name in []:
    if name == "zed":
        print("found")
        break
else:
    print("the empty list also runs the else block")
zed is not in the list
the empty list also runs the else block

Read the keyword as "else, if we did not break". It lets a search express both outcomes in one structure instead of a sentinel variable, saving the found = False flag that people write in languages without it. The cost is that readers who have not met the construct read else as "the loop was empty" and get the meaning backwards, which is a real argument for the flag on a mixed team. I use it when the search is short and the loop variable already names what is being searched for.

A while loop also supports else, and there the block runs when the condition became false rather than when a break fired.

enumerate and zip replace index arithmetic

When you need the position as well as the value, enumerate produces both and lets you name them. When you need values from two sequences together, zip pairs them by position:

names = ["ana", "bo"]
scores = [8, 9]

for index, (name, score) in enumerate(zip(names, scores), start=1):
    print(index, name, score)

print(list(zip(names, scores)))
print(dict(zip(names, scores)))
print(list(zip(names, ["ana", "bo", "cy"])))
1 ana 8
2 bo 9
[('ana', 8), ('bo', 9)]
{'ana': 8, 'bo': 9}
[('ana', 'ana'), ('bo', 'bo')]

The default start is zero, and start=1 is what you want for anything a human reads. The last line is the trap: zip stops at the shortest input and silently drops the rest, so a name with no score disappears from the report. In current Python you can make that failure loud:

names = ["ana", "bo", "cy"]
scores = [8, 9]

try:
    print(list(zip(names, scores, strict=True)))
except ValueError as exc:
    print(type(exc).__name__, exc)

print(list(zip(names, scores)))
ValueError zip() argument 2 is shorter than argument 1
[('ana', 8), ('bo', 9)]

strict=True turns a silent truncation into an exception, and it is the right default whenever the two sequences are supposed to describe the same records. The old fix was zip_longest from itertools with a fill value, which is still the tool when the shorter sequence legitimately means "missing" rather than "wrong".

Comprehensions are for building a value, not for doing work

A list comprehension builds a new list from an iterable with the loop, the filter and the append collapsed into one expression:

words = ["Ana", "bo", "CY"]

print([word.upper() for word in words if len(word) > 1])
print([word for word in words if word.isupper()])
print({word: len(word) for word in words})
print([(a, b) for a in [1, 2] for b in ["x", "y"]])
['ANA', 'BO', 'CY']
['CY']
{'Ana': 3, 'bo': 2, 'CY': 2}
[(1, 'x'), (1, 'y'), (2, 'x'), (2, 'y')]

The second line is the one to notice. A filter has to test the property you actually care about. len(word) > 1 is true for every word in that list, so the uppercase conversion is the only work being done and the condition is decoration. isupper asks the question the name of the variable suggests, and it finds one word.

The trade stops paying when the expression has to do more than produce a value. A comprehension that calls a function with side effects, nests two conditions, or holds a dictionary literal inside a dictionary literal is a loop wearing a costume. The test I use is whether I can read the line aloud as one sentence. If I have to backtrack, it becomes a for loop with a name and a comment.

The other limit is what a comprehension cannot do: it cannot break, and it cannot carry state from one iteration to the next. Anything that needs either of those is a loop.

Early returns flatten the conditionals

A function that handles every case by nesting produces a shape where the return statement is four levels deep and the reader has to hold every enclosing condition in their head. The same logic reads better inverted: handle the impossible and the trivial first and return, then let the body of the function run at one level of indentation.

def shipping_nested(order):
    if order["total"] > 0:
        if order.get("country") == "PT":
            if order["total"] >= 50:
                return 0
            else:
                return 4.9
        else:
            return 12.5
    else:
        return 0


def shipping_early(order):
    if order["total"] <= 0:
        return 0
    if order.get("country") != "PT":
        return 12.5
    if order["total"] >= 50:
        return 0
    return 4.9


for order in [
    {"total": 0, "country": "PT"},
    {"total": 20, "country": "ES"},
    {"total": 60, "country": "PT"},
    {"total": 20, "country": "PT"},
]:
    print(shipping_early(order))
0
12.5
0
4.9

Both functions return the same four values. The second one has no else at all, which is the part to imitate: after a return, the rest of the block is unreachable, so the else adds a level of nesting and buys nothing.

Two things this does not solve. Guard clauses hide the fact that a long function is doing several jobs, and no amount of early returning fixes that; the function needs splitting. And a guard clause that returns None for a case the caller does not expect converts a crash into a value that travels. The guard should return something the caller can handle or raise an exception that names the broken contract.

Lab 25 minutes

Write scan.py with a function that takes a list of events and returns the index and the name of the first one whose level is "error", or None if there is none.

events = [
    {"name": "boot", "level": "info"},
    {"name": "disk", "level": "warn"},
    {"name": "sync", "level": "info"},
    {"name": "disk2", "level": "error"},
]

Print how many events the function had to look at before it found the error, then run the same function on the same list with the error event removed.

Then answer: what does the function return in the second case, and would a for loop with an else clause or a sentinel flag read better to someone who has never seen a loop else? Write both and decide.

Show the solution and the reasoning

Return as soon as the answer is known. That single decision removes the flag, removes the need to keep scanning, and makes the "not found" case the last line of the function instead of a special branch.

events = [
    {"name": "boot", "level": "info"},
    {"name": "disk", "level": "warn"},
    {"name": "sync", "level": "info"},
    {"name": "disk2", "level": "error"},
]


def first_error(events):
    for index, event in enumerate(events):
        if event["level"] == "error":
            return index, event["name"]
    return None


print(first_error(events))
print(first_error(events[:3]))
print(first_error([]))
(3, 'disk2')
None
None

Counting how many events were looked at needs the index, which enumerate supplies, and after a hit the count is index + 1. On the second list the function returns None after walking all three events; on an empty list it also returns None, without any event to look at. That is the correct answer for "there is no error here", and the caller has to decide what to do with it rather than being handed a KeyError from somewhere else.

The same function written with the loop else clause:

def first_error_with_else(events):
    for index, event in enumerate(events):
        if event["level"] == "error":
            return index, event["name"]
    else:
        return None

That else is unreachable in any meaningful sense. It runs when the loop completes without a break, and this loop never breaks because it returns instead. The construct earns its keep when the loop has to finish normally and you want to distinguish that from an early exit:

def find_any(events, level):
    for index, event in enumerate(events):
        if event["level"] == level:
            break
    else:
        return None
    return index, event["name"]

Here the else is doing real work, but the variable event after the loop only exists because the loop variable survives in the enclosing scope, which is a subtle thing to rely on. I use return inside the loop as in the first version and skip the else entirely. It is shorter, it needs no explanation, and a reader who does not know the clause cannot misread it. Reach for the loop else when you are searching and need to keep working after the hit, not when you can return.

Progress

Mark this lesson done and it stays marked in this browser.