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.
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.