Make a day18 folder for today’s files, open it in your editor, and run each script from its terminal, as on earlier days. A few programs read small text files; save each one into day18 beside the script, with the name shown.
Phase 4 is about the habits of programmers whose code other people rely on. It starts with three tools you have already used without seeing inside. Every for loop you have written follows a hidden two-step agreement; today you see it and make your own objects follow it. Day 11’s generator expressions have a bigger sibling, the generator function, which can produce an endless stream of values or work through a file far too big for memory. And Day 17’s @property was a decorator; today you find out what a decorator is by writing your own, which needs one more idea first: a function that remembers.
18.1 What a for Loop Really Does: iter() and next()
A for loop works on a list, a string, a dictionary, a range, a file, and a generator. Those are very different things, so how can one statement handle them all? And why does a second loop over a list start again from the beginning, while a second loop over a generator (Day 11) or a file (Day 14) finds nothing left? Both questions have the same answer.
Day 10 named anything a for loop can go through an iterable. Day 14 said a file object keeps a bookmark of how far it has read. That bookmark is the key. Before a for loop starts, it asks the iterable for a bookmark object. Then, on every pass, it asks the bookmark for “the next item, please”, until the bookmark says there are none left.
Python has two built-in functions for those two requests:
iter(iterable)returns an iterator: an object that produces the iterable’s items one at a time and remembers which one comes next. It is the bookmark.next(iterator)returns the iterator’s next item and moves the bookmark on by one. When there are no items left, it raises aStopIterationexception, which means “finished”.
The agreement that an iterable hands out an iterator through iter(), and that the iterator hands out items through next() until it raises StopIteration, is called the iteration protocol. A protocol is just an agreed way of doing something, like the order of steps in a handshake.
>>> colors = ["red", "green", "blue"]
>>> bookmark = iter(colors)
>>> next(bookmark)
'red'
>>> next(bookmark)
'green'
>>> next(bookmark)
'blue'
>>> next(bookmark)
Traceback (most recent call last):
...
StopIteration
>>> next(bookmark, "no more")
'no more'bookmark = iter(colors)makes an iterator for the list. The list itself does not change.- Each
next(bookmark)gives the next color, in order. - The fourth
nexthas nothing to give, so it raisesStopIteration. A traceback here is not a bug in the list; it is how an iterator says “done”. next(iterator, default)with a second argument returnsdefaultinstead of raising when the iterator is finished, likeget()with a default on a dictionary (Day 8).
Worked example: A for loop, written out by hand
The program below loops over the same list twice: once with a for loop, and once doing by hand exactly what the for loop does for you.
scores = [85, 92, 78]
for score in scores:
print("for loop:", score)
bookmark = iter(scores)
while True:
try:
score = next(bookmark)
except StopIteration:
break
print("by hand:", score)for loop: 85
for loop: 92
for loop: 78
by hand: 85
by hand: 92
by hand: 78| Line | What the hand-written version does | What the for loop does silently |
|---|---|---|
| 6 | iter(scores) makes an iterator |
Calls iter() once, at the start |
| 9 | next(bookmark) gets the next score and stores it in score |
Calls next() before each pass |
| 12 | Runs the body with that score | Runs the body |
| 10–11 | After 78, next raises StopIteration; except (Day 15) catches it and break leaves |
Catches StopIteration and stops |
The two halves print the same values because they are the same process. The for loop is a short way to write lines 6 to 12, and that is why it works on anything that can give out an iterator.
Now the second question. A list is an iterable, but it is not an iterator: it keeps no bookmark of its own. Every call to iter() on a list makes a fresh iterator that starts at the first item. So every for loop over a list starts from the beginning. A generator, on the other hand, is its own iterator: iter() on a generator hands back the very same generator, bookmark and all. Once the bookmark reaches the end, every later loop finds it there, which is why a generator can be used only once. A file object works the same way.
>>> scores = [85, 92, 78]
>>> first = iter(scores)
>>> second = iter(scores)
>>> next(first)
85
>>> next(first)
92
>>> next(second)
85
>>> iter(scores) is scores
False
>>> squares = (n * n for n in scores)
>>> iter(squares) is squares
Truefirst and second are two independent bookmarks on one list, so second still starts at 85 after first has moved on. iter(scores) is scores is False (Day 4’s is asks “the same object?”): the list hands out a new object. iter(squares) is squares is True: the generator hands out itself.
| Iterable (list, string, dict, range) | Iterator (generator, file, result of iter()) |
|
|---|---|---|
| Works in a for loop? | Yes | Yes |
Works with next()? |
No | Yes |
What iter() gives back |
A fresh iterator each time | Itself |
| Loop over it twice | Both loops see every item | The second loop finds nothing left |
Key idea
A for loop calls iter() to get an iterator, then calls next() before each pass until StopIteration is raised. An
iterable, such as a list, hands out a fresh iterator each time; an iterator, such as a generator or a file, is its own
bookmark, so it can be used only once.
Watch out
Calling next() on a list. next() needs an iterator, and a list is only an iterable:
>>> names = ["Ana", "Ben"]
>>> next(names)
Traceback (most recent call last):
...
TypeError: 'list' object is not an iterator
>>> people = iter(names)
>>> next(people)
'Ana'“Not an iterator” means “this has no bookmark of its own”. Ask the list for one with iter() first, then call next() on the result, as people shows.
Pause and try
Question 1 · Tracing
What does this program print? The for loop and the next calls share one iterator.
letters = iter("cat")
print(next(letters))
for letter in letters:
print(letter.upper())
print(next(letters, "end"))Check your reasoning
c
A
T
endLine 1 makes one iterator over "cat". Line 2’s next takes "c" and moves the bookmark to "a". The for loop on line 3 calls iter() on letters, which is already an iterator, so it gets the same bookmark back and carries on from "a": it prints A and T. When the loop ends, the bookmark is at the end, so line 5’s next has nothing to give and returns its default, "end". If you printed C first, remember the loop did not start from the beginning: next had already used the "c".
Reread 18.1 What a for Loop Really Does: iter() and next() if your output differed.
Question 2 · Multiple choice
Which of these can you pass straight to next(), without calling iter() first?
["a", "b"]"ab"(letter for letter in "ab"){"a": 1, "b": 2}
Check your reasoning
C. A generator expression makes a generator, and a generator is its own iterator, so next() works on it directly and returns "a".
- A is wrong: a list is an iterable but not an iterator, so
next()raisesTypeError: 'list' object is not an iterator. - B is wrong for the same reason: a string is an iterable, not an iterator.
- D is wrong too: a dictionary is an iterable (looping over it gives its keys), but it keeps no bookmark, so
next()on it raises aTypeError.
Reread 18.1 What a for Loop Really Does: iter() and next() if you chose differently.
18.2 Making Your Own Iterator: __iter__ and __next__
A rocket launch counts down 3, 2, 1. You would like to write for number in Countdown(3): with a class of your own, the way you loop over a range. Day 17 showed how to plug your objects into Python’s built-ins: len(obj) calls obj.__len__(), and a < b calls a.__lt__(b). The iteration protocol works the same way. iter(obj) calls the special method __iter__, and next(obj) calls __next__. A class that defines both is an iterator, and for loops, list(), sum(), and every other tool that loops will accept it.
The rules for the two methods come straight from 18.1:
__iter__(self)must return an iterator. An object that is its own bookmark simply returnsself.__next__(self)must return the next value and update the bookmark, orraise StopIteration(Day 15’sraise) when there are no values left.
Worked example: A countdown you can loop over
class Countdown:
def __init__(self, start):
self.current = start
def __iter__(self):
return self
def __next__(self):
if self.current <= 0:
raise StopIteration
value = self.current
self.current -= 1
return value
for number in Countdown(3):
print(number)
print("Liftoff!")
launch = Countdown(2)
print(list(launch))
print(list(launch))3
2
1
Liftoff!
[2, 1]
[]The attribute current is the bookmark: it holds the next number to hand out. Here is the for loop on line 16, step by step:
| Step | current before |
What __next__ does |
Loop gets |
|---|---|---|---|
iter() calls line 5 |
3 | (__iter__ returns the object itself) |
|
1st next() |
3 | 3 is not <= 0; saves 3, lowers current to 2 |
3 |
2nd next() |
2 | saves 2, lowers current to 1 |
2 |
3rd next() |
1 | saves 1, lowers current to 0 |
1 |
4th next() |
0 | 0 <= 0, so line 10 raises StopIteration |
(loop ends) |
Lines 11 to 13 save the value before lowering current, so the method can return the old value after updating the bookmark. Lines 20 to 22 show that a Countdown behaves like a generator: it is its own iterator, so the first list() uses it up, and the second finds current already at 0 and gets an empty list.
That is a lot of bookkeeping for “count down from 3”. The class has to store its position in an attribute, update it on every call, and remember to raise StopIteration at the end. If the raise line were missing, __next__ would return None forever (Day 9: a function that reaches its end returns None), and the for loop would never stop until you pressed Ctrl+C. The next subsection shows a much shorter way to write the same thing, but this is what happens underneath it.
Key idea
A class becomes an iterator when it defines __iter__, which returns self, and __next__, which returns the next
value or raises StopIteration. An attribute holds the position between calls.
Watch out
Leaving out __iter__. __next__ alone is not enough, because the for loop’s first step is to call iter():
class Countdown:
def __init__(self, start):
self.current = start
def __next__(self):
if self.current <= 0:
raise StopIteration
value = self.current
self.current -= 1
return value
for number in Countdown(3):
print(number)Traceback (most recent call last):
File ".../day18/countdown_no_iter.py", line 13, in <module>
for number in Countdown(3):
~~~~~~~~~^^^
TypeError: 'Countdown' object is not iterable“Not iterable” means iter() found no __iter__ method. Add the two-line __iter__ that returns self, as in countdown_class.py.
Pause and try
Question 3 · Tracing
What does this program print?
class Evens:
def __init__(self, limit):
self.limit = limit
self.number = 0
def __iter__(self):
return self
def __next__(self):
if self.number > self.limit:
raise StopIteration
value = self.number
self.number += 2
return value
evens = Evens(5)
print(next(evens))
print(list(evens))
print(sum(Evens(6)))Check your reasoning
0
[2, 4]
12| Line | Call | number before |
Result |
|---|---|---|---|
| 18 | next(evens) |
0 | returns 0; number becomes 2 |
| 19 | list(evens) |
2, 4, 6 | collects 2 and 4; at 6, 6 > 5 raises StopIteration |
| 20 | sum(Evens(6)) |
0, 2, 4, 6, 8 | a new object: 0 + 2 + 4 + 6 = 12; stops at 8 |
list() continues from where next() left the bookmark, so 0 is not in the list. Line 20 makes a fresh Evens with its own number starting at 0, and 6 is allowed because the test is > limit, not >= limit.
Reread 18.2 Making Your Own Iterator: __iter__ and __next__ if your output differed.
18.3 Generator Functions and yield
Here is the same countdown with none of the bookkeeping:
def countdown(start):
current = start
while current > 0:
yield current
current -= 1
for number in countdown(3):
print(number)
print("Liftoff!")
launch = countdown(2)
print(list(launch))
print(list(launch))3
2
1
Liftoff!
[2, 1]
[]
The output is identical to countdown_class.py, from five lines instead of thirteen. The new keyword is yield. A def whose body contains yield anywhere is a generator function. It looks like an ordinary function, but it behaves differently in two ways:
- Calling it does not run its body.
countdown(3)runs none of the lines inside. It immediately returns a generator (the same kind of object a Day 11 generator expression makes), which is an iterator ready to run the body later. - Each
next()runs the body only as far as the nextyield.yield valuehandsvalueto whoever callednext(), and then the function pauses right there, keeping all its local variables. The nextnext()resumes on the line after theyield. When the body reaches its end, the generator raisesStopIterationfor you.
So the generator function keeps its bookmark automatically: the bookmark is “which line it paused on, and what its local variables held”. In countdown_gen.py, current stays alive between calls, which is exactly what the class had to store in self.current.
Worked example: Watching a generator pause and resume
To see when the body runs, this generator prints a message between its yields:
def three_steps():
print("start")
yield 1
print("after the first yield")
yield 2
print("after the second yield")
steps = three_steps()
print("made the generator")
print(next(steps))
print(next(steps))
print(next(steps, "finished"))made the generator
start
1
after the first yield
2
after the second yield
finished| Line | Call | Body runs | Pauses at | Printed by line 11–13 |
|---|---|---|---|---|
| 9 | three_steps() |
nothing | the start | (line 10 prints made the generator) |
| 11 | next(steps) |
line 2 prints start; line 3 yields 1 |
line 3 | 1 |
| 12 | next(steps) |
line 4 prints; line 5 yields 2 | line 5 | 2 |
| 13 | next(steps, "finished") |
line 6 prints; the body ends | (finished) | finished |
made the generator comes before start, because line 9 only creates the generator. On line 13 the body runs to its end and raises StopIteration, which next’s default turns into "finished".
Now compare yield with the return you have used since Day 9. Both functions below produce the squares of 1 to 4:
def squares_list(limit):
result = []
for n in range(1, limit + 1):
result.append(n * n)
return result
def squares_gen(limit):
for n in range(1, limit + 1):
yield n * n
print(squares_list(4))
print(list(squares_gen(4)))
print(sum(squares_gen(4)))[1, 4, 9, 16]
[1, 4, 9, 16]
30
squares_list builds a whole list, then returns it in one go. squares_gen yields each square as it is asked for and never builds a list. list(squares_gen(4)) collects the yielded values into a list, and sum adds them one at a time (1 + 4 + 9 + 16 = 30) without one. A generator expression, (n * n for n in range(1, limit + 1)), is a one-line short form of squares_gen; a generator function is what you write when the work needs more than one expression, such as several lines, an if, or a while loop.
return value |
yield value |
|
|---|---|---|
| What the call gives you | The value, when the body finishes | A generator, at once, before the body runs |
| How many values | One (perhaps a list holding many) | As many as the body yields, one per next() |
| After handing a value | The function ends; its locals are gone | The function pauses; its locals are kept |
| Next time | A new call starts again from line 1 | next() resumes after the yield |
A return inside a generator function just ends it early: the generator raises StopIteration, and any value after return is not given to the loop.
Key idea
A function containing yield is a generator function: calling it returns a generator without running the body. Each
next() runs the body to the next yield, hands over that value, and pauses with its local variables kept. return
ends a function for good with one value; yield hands over a value and waits to continue.
Watch out
Writing return where you mean yield. This function should produce every even number in the list:
def evens(numbers):
for n in numbers:
if n % 2 == 0:
return n
print(evens([3, 4, 7, 6, 8]))4The first even number, 4, reaches return, which ends the function at once, so 6 and 8 are never seen. There is no error, just a wrong answer. When a function should produce several values one after another, use yield:
def evens(numbers):
for n in numbers:
if n % 2 == 0:
yield n
print(list(evens([3, 4, 7, 6, 8])))[4, 6, 8]Pause and try
Question 4 · Tracing
What does this program print? Notice when each letter is printed.
def snacks():
print("A")
yield "apple"
print("B")
yield "pear"
print("C")
menu = snacks()
print("ready")
for snack in menu:
print(snack)
print("done")Check your reasoning
ready
A
apple
B
pear
C
doneLine 9 makes the generator without running the body, so ready prints first. The for loop’s first next runs line 2 (A) and pauses at line 3, and the loop prints apple. The second next resumes at line 4 (B) and pauses at line 5; the loop prints pear. The third next resumes at line 6, prints C, and reaches the end of the body, which raises StopIteration and ends the loop quietly. Then done. If you left out C, remember that the generator does run the rest of its body before it can report that it has finished.
Reread 18.3 Generator Functions and yield if your output differed.
Question 5 · Multiple choice
A function scores has two lines in its body: yield 90, then yield 75. What does the call scores() give you?
90[90, 75]- A generator that has not run any of the body yet
None, because there is noreturn
Check your reasoning
C. Calling a generator function runs none of its body. It returns a generator, and the body runs only as next() asks for values.
- A is wrong: 90 is what the first
next()on the generator gives, not the call itself. - B is wrong: no list is built.
list(scores())would make[90, 75]by asking for every value. - D is wrong: the
yieldmakes this a generator function, and those always return a generator, with or withoutreturn.
Reread 18.3 Generator Functions and yield if you chose differently.
18.4 Lazy Evaluation: Endless and Large Inputs
A shop hands out ticket numbers T-100, T-101, T-102, and so on, for as long as customers keep arriving. How many should a function make? A list has to stop somewhere, and you cannot know where. A generator does not have to stop at all.
Day 11 called a generator lazy: it does the work only when a value is asked for. The proper name for this is lazy evaluation: working out each value at the moment it is needed, not before. Its opposite, working everything out up front, is eager evaluation, which is what a list comprehension does. Lazy evaluation gives generators two powers a list cannot have.
Endless sequences
Because a generator only works out the values it is asked for, its body can loop forever with while True:. Each next() runs one pass and pauses at the yield; values nobody asks for are never made.
To take only the first few values of an endless generator, use islice from the standard library module itertools (tools for iterators). islice(iterator, n) gives the first n items of an iterator, lazily, like slicing [:n] on a list. A list slice does not work here, because a generator cannot be indexed (Day 11).
Worked example: Ticket numbers that never run out
from itertools import islice
def ticket_numbers(start):
number = start
while True:
yield f"T-{number}"
number += 1
tickets = ticket_numbers(100)
print(next(tickets))
print(next(tickets))
print(list(islice(tickets, 3)))
print(next(tickets))T-100
T-101
['T-102', 'T-103', 'T-104']
T-105- Line 11 makes the generator; the endless loop has not started.
- Lines 12 and 13: each
nextruns one pass of thewhileloop, yields one ticket, and pauses.numberis kept between calls, so it goes 100, 101. - Line 14:
islice(tickets, 3)asks for the next three values only, andlist()collects them. The generator then sits paused, ready for more. - Line 15 carries on from the bookmark with T-105.
Never pass an endless generator straight to something that wants every value, such as list(ticket_numbers(100)), sum, or sorted. They keep asking for values forever, using more and more memory, until you press Ctrl+C. Take a limited number first with islice, or stop a for loop with break (Day 5).
Files bigger than memory
A server writes a line to its log every time something happens, and a busy server’s log can be gigabytes long, far more than a program should load with read() or readlines() (Day 14). Day 14 showed that for line in file: reads one line at a time. Generators let you build several processing steps on top of that, and keep the one-line-at-a-time property.
Save this as server_log.txt in day18:
INFO server started
INFO user ana signed in
ERROR disk almost full
INFO user ben signed in
ERROR payment service timed out
INFO backup finished
ERROR disk almost full
Worked example: A pipeline of generators
def error_lines(lines):
for line in lines:
if line.startswith("ERROR"):
yield line.strip()
def messages(lines):
for line in lines:
yield line[6:]
with open("server_log.txt", encoding="utf-8") as file:
errors = messages(error_lines(file))
for number, message in enumerate(errors, 1):
print(number, message)1 disk almost full
2 payment service timed out
3 disk almost fullEach generator function takes an iterable of lines and yields lines. error_lines passes on only the lines that start with ERROR, stripped of their "\n"; messages cuts off the first six characters, "ERROR ", with a Day 3 slice. Line 13 connects them: the file feeds error_lines, which feeds messages. Chained generators like this are called a pipeline, because each value flows through every step in turn, like water through pipes.
Line 13 itself does no work at all; it only connects three iterators. The work happens when line 14’s loop asks for a value:
The loop asks messages |
messages asks error_lines |
error_lines asks the file |
Result |
|---|---|---|---|
| 1st value | first error line | reads 3 lines; the 3rd is ERROR |
disk almost full |
| 2nd value | next error line | reads 2 more lines | payment service timed out |
| 3rd value | next error line | reads 2 more lines | disk almost full |
| 4th value | next error line | reaches the end of the file | StopIteration; loop ends |
At any moment, only one line of the file is in memory. The same program would work, unchanged, on a log of ten million lines.
Key idea
Lazy evaluation means working out each value only when it is asked for. It lets a generator be endless (take what you
need with itertools.islice), and it lets a pipeline of generators handle a huge file one line at a time.
Watch out
Using a file’s generator after the with block. Because nothing is read until a value is asked for, this program has read nothing when the with block closes the file (Day 14):
def error_lines(lines):
for line in lines:
if line.startswith("ERROR"):
yield line.strip()
with open("server_log.txt", encoding="utf-8") as file:
errors = error_lines(file)
for line in errors:
print(line)Traceback (most recent call last):
File ".../day18/log_closed.py", line 10, in <module>
for line in errors:
^^^^^^
File ".../day18/log_closed.py", line 2, in error_lines
for line in lines:
^^^^^
ValueError: I/O operation on closed file.Line 8 only creates the generator. The first real read happens on line 10, after the file is closed. Keep the loop inside the with block, as log_pipeline.py does, or open the file inside the generator function itself, so the file stays open exactly as long as the generator is being used.
Pause and try
Question 6 · Tracing
What does this program print?
from itertools import islice
def multiples(step):
total = 0
while True:
total += step
yield total
fives = multiples(5)
print(next(fives))
print(list(islice(fives, 2)))
print(next(fives) + next(fives))Check your reasoning
5
[10, 15]
45Each next runs one pass of the endless loop: add 5 to total, yield it, pause.
| Line | Asks for | total values yielded |
Printed |
|---|---|---|---|
| 12 | 1 value | 5 | 5 |
| 13 | 2 values (islice) |
10, 15 | [10, 15] |
| 14 | 2 values | 20, 25 | 20 + 25 = 45 |
The generator is never restarted: each line carries on from where the previous one paused. The program ends normally even though the loop is endless, because nobody asks for a sixth value.
Reread 18.4 Lazy Evaluation: Endless and Large Inputs if your output differed.
Question 7 · Short answer
A classmate rewrites log_pipeline.py so that error_lines builds a list of every error line and returns it, and messages does the same. The output does not change. What does change, and when would it matter?
Check your reasoning
Model answer: with lists, the work becomes eager. error_lines reads the whole file before returning, and holds every error line in memory at once; messages then builds a second complete list. Only after both lists exist does the loop print anything. With generators, each line flows through all the steps and is printed before the next line is read, so only one line is in memory at a time.
For a seven-line file, there is no visible difference. For a log of millions of lines, the list version may use a large amount of memory (or run out), and it shows nothing until the whole file has been read; the generator version uses almost no memory and starts printing straight away.
A correct answer mentions that the list version holds all the lines at once while the generator version holds one at a time, and that this matters for very large inputs.
Reread 18.4 Lazy Evaluation: Endless and Large Inputs if your reasoning differed.
18.5 Closures: Functions That Remember
A teacher wants several test functions for scores: “at least 60” to pass, “at least 80” for honors, “at least 90” for a prize. Writing is_passing, is_honors, and is_prize repeats the same line with a different number each time. It would be better to have one function that makes the others: you tell it the limit, and it hands back a ready-made test.
Day 10 showed that functions are values: you can pass a function as an argument, and a function can also return one. And Day 9 said that a function’s local variables are thrown away when the call ends. Today’s idea seems to break that rule, so read carefully.
You can write a def inside another function’s body. The inner function is a nested function, and it exists only inside the outer function, like any other local variable. A nested function can read the outer function’s local variables. When Python looks up a name inside the nested function, it searches the nested function’s own locals first, then the enclosing function’s locals, then the globals (Day 9), then the built-in names such as print.
Worked example: A function that makes multiplying functions
def make_multiplier(factor):
def multiply(number):
return number * factor
return multiply
double = make_multiplier(2)
triple = make_multiplier(3)
print(double(10), triple(10))
print(double(7))20 30
14| Step | What happens |
|---|---|
| 1 | Line 8 calls make_multiplier(2). Inside, factor is 2, and lines 2–3 make a new function multiply |
| 2 | Line 5 returns multiply, the function itself (no parentheses), and line 8 names it double |
| 3 | The call to make_multiplier is over, but double still remembers that factor is 2 |
| 4 | Line 9 does the same with 3: a second, separate multiply, which remembers factor = 3, named triple |
| 5 | Line 10: double(10) runs number * factor = 10 × 2 = 20; triple(10) is 10 × 3 = 30 |
| 6 | Line 11: double(7) still uses its own factor: 14 |
Step 3 is the new idea. Normally factor would be thrown away when make_multiplier returns. But the returned function uses factor, so Python keeps that variable alive, attached to the function. A function that remembers variables from the enclosing function it was made in, even after that function has returned, is called a closure. Think of a closure as a function carrying a small backpack: the backpack holds the outer variables it uses, and every call can look inside it. Each call to make_multiplier packs a new backpack, which is why double and triple do not interfere.
Here is the teacher’s problem solved with a closure. The made functions are passed around like any other function value:
def at_least(limit):
def check(score):
return score >= limit
return check
scores = [55, 72, 90, 64, 81]
passing = at_least(60)
honors = at_least(80)
print([score for score in scores if passing(score)])
print([score for score in scores if honors(score)])
print(passing(59), honors(80))[72, 90, 64, 81]
[90, 81]
False True
at_least(60) returns a check function that remembers limit = 60, and at_least(80) returns another that remembers 80. Each comprehension (Day 11) calls its own test on every score. A prize test is now one more line, prize = at_least(90), not one more function.
Key idea
A function defined inside another function can read the outer function’s variables. When the outer function returns the inner one, the inner function is a closure: it keeps those variables alive and uses them on every call. Each call of the outer function makes a new closure with its own variables.
Watch out
Returning the inner function’s result instead of the function. Parentheses after multiply call it (Day 10):
def make_multiplier(factor):
def multiply(number):
return number * factor
return multiply()
double = make_multiplier(2)
print(double(10))Traceback (most recent call last):
File ".../day18/return_called.py", line 8, in <module>
double = make_multiplier(2)
File ".../day18/return_called.py", line 5, in make_multiplier
return multiply()
~~~~~~~~^^
TypeError: make_multiplier.<locals>.multiply() missing 1 required positional argument: 'number'make_multiplier.<locals>.multiply is Python’s full name for “the function multiply defined inside make_multiplier”. Line 5 tries to run multiply straight away, with no number, which fails. Even with a number, the outer function would return a result, not a function to keep. Return the function itself: return multiply.
Pause and try
Question 8 · Tracing
What does this program print? The last line is the tricky one.
def make_greeter(greeting):
punctuation = "!"
def greet(name):
return f"{greeting}, {name}{punctuation}"
return greet
hello = make_greeter("Hello")
hola = make_greeter("Hola")
print(hello("Ana"))
print(hola("Ben"))
print(hello(hola("Cy")))Check your reasoning
Hello, Ana!
Hola, Ben!
Hello, Hola, Cy!!hello is a closure remembering greeting = "Hello" and punctuation = "!"; hola is a separate closure remembering "Hola" and its own "!". Line 14 works from the inside out, as on Day 10: hola("Cy") gives "Hola, Cy!", and that whole string is then the name passed to hello, which puts "Hello, " in front and another "!" at the end.
Reread 18.5 Closures: Functions That Remember if your output differed.
Question 9 · Multiple choice
After double = make_multiplier(2) has finished, why does double(10) still know that factor is 2?
factorbecame a global variable whenmake_multiplierrandoubleis a closure, which keeps the variables it uses from the function that made it- Python stores the last argument of every function call
double(10)callsmake_multiplieragain
Check your reasoning
B. The inner function uses factor, so Python keeps that variable alive with the function after the outer call returns. That is what makes it a closure.
- A is wrong:
factoris a parameter, so it is local tomake_multiplier. If it were global,triple = make_multiplier(3)would change it fordoubletoo, anddouble(10)would give 30. - C is wrong: Python keeps no such record. The variable survives only because a closure uses it.
- D is wrong:
doubleis the innermultiplyfunction. Calling it runs onlyreturn number * factor.
Reread 18.5 Closures: Functions That Remember if you chose differently.
18.6 Changing a Remembered Variable with nonlocal
A web page wants to count its clicks. A closure can remember a count between calls, so the plan is a function make_counter that returns an increment function, where each call adds 1 to the remembered count and returns it. The first try fails:
def make_counter():
count = 0
def increment():
count += 1
return count
return increment
clicks = make_counter()
print(clicks())Traceback (most recent call last):
File ".../day18/counter_no_nonlocal.py", line 12, in <module>
print(clicks())
~~~~~~^^
File ".../day18/counter_no_nonlocal.py", line 5, in increment
count += 1
^^^^^
UnboundLocalError: cannot access local variable 'count' where it is not associated with a value
You have seen this error before, on Day 9, with a global variable. The rule is the same: a name that is assigned anywhere in a function is local to that function. count += 1 assigns to count, so inside increment, count is a brand-new local variable, not the enclosing one, and it has no value yet when += 1 reads it. A closure can read an enclosing variable freely, but assigning to it makes a local instead.
Day 9’s fix for a global was the global statement. For an enclosing function’s variable, the statement is nonlocal: nonlocal count inside the nested function means “count here is the variable of the enclosing function, not a new local”. Then assigning to it changes the remembered variable.
Worked example: Independent counters
def make_counter():
count = 0
def increment():
nonlocal count
count += 1
return count
return increment
clicks = make_counter()
views = make_counter()
print(clicks(), clicks(), clicks())
print(views())
print(clicks())1 2 3
1
4| Line | Call | clicks’ count |
views’ count |
Returns |
|---|---|---|---|---|
| 12 | (made) | 0 | ||
| 13 | (made) | 0 | 0 | |
| 14 | clicks() ×3 |
1, 2, 3 | 0 | 1, 2, 3 |
| 15 | views() |
3 | 1 | 1 |
| 16 | clicks() |
4 | 1 | 4 |
Each make_counter() call makes a separate count, so the two counters never mix. Nothing outside can change a counter’s count except by calling it, which is the kind of protection Day 16 used a class for. A closure with nonlocal is like a tiny object with one method.
Do you always need nonlocal to change something remembered? Only when you assign to the name. Changing a remembered list in place, with append, assigns nothing, so it needs no nonlocal, just as a function could append to a list passed in on Day 9.
| Inside the nested function | Needs nonlocal? |
Why |
|---|---|---|
return number * factor |
No | Only reads factor |
count += 1 or count = count + 1 |
Yes | Assigns to count, which would otherwise be local |
history.append(item) |
No | Changes the list; the name history is not assigned |
Key idea
A closure can read enclosing variables, but assigning to one makes a new local variable instead. Write nonlocal name
in the nested function to assign to the enclosing function’s variable, the way global does for a global.
Watch out
Using global instead of nonlocal. global count makes Python look for count among the file’s global variables, not in make_counter:
def make_counter():
count = 0
def increment():
global count
count += 1
return count
return increment
clicks = make_counter()
print(clicks())Traceback (most recent call last):
File ".../day18/counter_global.py", line 13, in <module>
print(clicks())
~~~~~~^^
File ".../day18/counter_global.py", line 6, in increment
count += 1
^^^^^
NameError: name 'count' is not defined. Did you mean: 'round'?There is no global count, only the one inside make_counter, so Python finds nothing. (Its guess, round, is just the built-in name spelled most like count; ignore it.) If the file did have a global count, every counter would share it, and the separate counters would be lost. A variable that belongs to an enclosing function needs nonlocal.
Pause and try
Question 10 · Debugging
This program should keep a running total: it should print 5, then 8. It crashes. Explain the error and fix it.
def make_running_total():
total = 0
def add(amount):
total = total + amount
return total
return add
till = make_running_total()
print(till(5))
print(till(3))Check your reasoning
The traceback ends with:
UnboundLocalError: cannot access local variable 'total' where it is not associated with a valueLine 5 assigns to total, so total is local to add, and the right side, total + amount, reads that local before it has a value. The enclosing total on line 2 is never used. Declare that total belongs to the enclosing function:
def make_running_total():
total = 0
def add(amount):
nonlocal total
total = total + amount
return total
return add
till = make_running_total()
print(till(5))
print(till(3))5
8Now line 6 changes the remembered total: 0 + 5 = 5, then 5 + 3 = 8.
Reread 18.6 Changing a Remembered Variable with nonlocal if you missed it.
Question 11 · Tracing
What does this program print? Notice that there is no nonlocal.
def make_recorder():
history = []
def record(item):
history.append(item)
return len(history)
return record
songs = make_recorder()
games = make_recorder()
print(songs("Help"), songs("Yellow"))
print(games("chess"))
print(songs("Rain"))Check your reasoning
1 2
1
3append changes the remembered list without assigning to history, so no nonlocal is needed and there is no error. songs and games each have their own history list. songs gets "Help" (length 1) and "Yellow" (length 2); games gets "chess" in its own list (length 1); songs then gets "Rain", its third item.
Reread 18.6 Changing a Remembered Variable with nonlocal if your output differed.
18.7 Decorators: Functions That Wrap Functions
While testing a program, you want every call of certain functions to print a line saying which function is running. You could add two print lines to each function, but that changes code that already works, repeats itself, and has to be undone afterwards. It would be better to keep each function exactly as it is and wrap it: make a new function that prints the message, calls the original, and passes its result back.
You now have everything this needs. A function can take a function as an argument (Day 10). A nested function can remember a variable from the function that made it (18.5), including a parameter holding a function. And *args and **kwargs (Day 10) let one function accept any arguments at all and pass them on unchanged.
One more fact: every function has an attribute __name__ that holds the name it was defined with, just as every module has a __name__ (Day 13). For def add(a, b):, add.__name__ is "add".
A decorator is a function that takes a function and returns a replacement for it, usually a wrapper: a nested function that does some extra work and calls the original. Here is one, applied by hand:
Worked example: Wrapping a function by hand
def log_calls(func):
def wrapper(*args, **kwargs):
print("Calling", func.__name__)
result = func(*args, **kwargs)
print("Finished", func.__name__)
return result
return wrapper
def add(a, b):
return a + b
add = log_calls(add)
print(add(2, 3))Calling add
Finished add
5- Line 1:
log_callstakes a function,func. - Lines 2 to 6 define the wrapper.
*args, **kwargscollect whatever arguments the wrapper is called with, and line 4 spreads them back out into the callfunc(*args, **kwargs), so the original gets exactly the arguments the caller gave. Line 6 passes the original’s result back. - Line 8 returns the wrapper, which is a closure: it remembers
func. - Line 15 is the key line.
log_calls(add)returns a wrapper around the originaladd, and the nameaddis pointed at the wrapper. - Line 16 calls
add(2, 3), which now means the wrapper:argsis(2, 3). It printsCalling add, calls the originaladd(2, 3), which returns 5, printsFinished add, and returns 5, whichprintshows.
Line 15’s pattern, name = decorator(name) right after the def, is so common that Python has a short form. Writing @log_calls on the line directly above def add means exactly “define add, then do add = log_calls(add)”. This is the @ you met on Day 17: @property above a method is area = property(area), with a decorator that comes with Python. The @ line runs once, when the def runs, not each time the function is called.
def log_calls(func):
def wrapper(*args, **kwargs):
print("Calling", func.__name__)
result = func(*args, **kwargs)
print("Finished", func.__name__)
return result
return wrapper
@log_calls
def greet(name, greeting="Hello"):
return f"{greeting}, {name}!"
@log_calls
def area(width, height):
return width * height
print(greet("Ana"))
print(greet("Ben", greeting="Hi"))
print(area(4, 3))Calling greet
Finished greet
Hello, Ana!
Calling greet
Finished greet
Hi, Ben!
Calling area
Finished area
12
One decorator, written once, wraps two quite different functions. greet("Ben", greeting="Hi") arrives in the wrapper as args = ("Ben",) and kwargs = {"greeting": "Hi"}, and line 4 passes both on, so the original greet gets its keyword argument. Because the wrapper accepts anything, it fits any function. To stop wrapping a function, delete the @ line; the function’s own code never changed.
A wrapper can also change the result instead of just reporting. This decorator rounds whatever the wrapped function returns:
def two_decimals(func):
def wrapper(*args, **kwargs):
return round(func(*args, **kwargs), 2)
return wrapper
@two_decimals
def average(*scores):
return sum(scores) / len(scores)
@two_decimals
def with_tax(price, rate=0.08):
return price * (1 + rate)
print(average(85, 90, 78))
print(with_tax(19.99))
print(with_tax(5, rate=0.2))84.33
21.59
6.0
average(85, 90, 78) is 84.333…, which the wrapper rounds to 84.33 with Day 2’s round. with_tax(19.99) is 21.5892, rounded to 21.59. Each function stays focused on its own job, and the rounding rule lives in one place. Programmers use decorators like this for logging, for checking arguments, for counting calls, and for timing how long a function takes, which is today’s third practice exercise.
Key idea
A decorator takes a function and returns a wrapper that does extra work around a call to the original. Give the
wrapper *args, **kwargs so it accepts and passes on any arguments, and make it return the original’s result.
@decorator above a def means name = decorator(name), done once when the function is defined.
Watch out
Forgetting to return the result. If the wrapper calls the original but does not return what it got back, the wrapper returns None (Day 9), and the result is lost:
def log_calls(func):
def wrapper(*args, **kwargs):
print("Calling", func.__name__)
func(*args, **kwargs)
return wrapper
@log_calls
def area(width, height):
return width * height
print(area(4, 3))Calling area
Nonearea(4, 3) worked out 12, but the wrapper dropped it. Every function a decorator wraps would now return None. Store the result and return it, as on lines 4 and 6 of log_calls.py, or write return func(*args, **kwargs).
Pause and try
Question 12 · Tracing
What does this program print?
def fence(func):
def wrapper(*args, **kwargs):
print("+-----+")
result = func(*args, **kwargs)
print("+-----+")
return result
return wrapper
@fence
def show(word):
print("|", word)
return len(word)
size = show("hi")
print(size)Check your reasoning
+-----+
| hi
+-----+
2@fence replaced show with the wrapper. Line 17 calls the wrapper with args = ("hi",): it prints the top line, calls the original show("hi"), which prints | hi and returns len("hi") = 2, then prints the bottom line and returns 2. Line 18 prints that 2. The fence lines come from the wrapper, around the original’s own output.
Reread 18.7 Decorators: Functions That Wrap Functions if your output differed.
Question 13 · Multiple choice
Which code does exactly what @two_decimals above def total(prices): does?
total = two_decimals(total), after thedeftotal = two_decimals(total()), after thedeftwo_decimals = total(two_decimals), after thedef- Calling
two_decimals(total)inside every call tototal
Check your reasoning
A. The @ line passes the newly defined function to the decorator and puts the name back on whatever the decorator returns: the wrapper.
- B is wrong:
total()calls the function (with noprices, so it fails) and would pass its result, not the function, to the decorator. - C is wrong: it has the roles backwards and replaces the decorator’s name.
- D is wrong: decorating happens once, when the
defruns. Each call tototalruns the wrapper, which already exists.
Reread 18.7 Decorators: Functions That Wrap Functions if you chose differently.
18.8 Keeping a Function’s Name with functools.wraps
Decorating has a side effect you have not seen yet. After @log_calls, the name area refers to the wrapper, so everything Python can tell you about area now describes the wrapper instead. Day 9’s help() and docstrings show the problem. A function’s docstring is stored in its attribute __doc__:
def log_calls(func):
def wrapper(*args, **kwargs):
print("Calling", func.__name__)
return func(*args, **kwargs)
return wrapper
@log_calls
def area(width, height):
"""Return the area of a rectangle."""
return width * height
print(area.__name__)
print(area.__doc__)
help(area)wrapper
None
Help on function wrapper in module __main__:
wrapper(*args, **kwargs)
The function’s name says wrapper, its docstring is gone (None), and help() shows parameters that tell a user nothing about how to call area. Tracebacks, help(), and debugging and testing tools would all report wrapper for every function this decorator touches, which makes them much harder to tell apart.
The standard library’s functools module (tools for functions) has the fix. functools.wraps(func) is a decorator you put on the wrapper, inside your decorator. It copies the original function’s name, docstring, and parameter details onto the wrapper, so the wrapper presents itself as the function it wraps.
Worked example: A decorator that keeps the original's identity
from functools import wraps
def log_calls(func):
@wraps(func)
def wrapper(*args, **kwargs):
print("Calling", func.__name__)
return func(*args, **kwargs)
return wrapper
@log_calls
def area(width, height):
"""Return the area of a rectangle."""
return width * height
print(area.__name__)
print(area.__doc__)
help(area)
print(area(4, 3))area
Return the area of a rectangle.
Help on function area in module __main__:
area(width, height)
Return the area of a rectangle.
Calling area
12- Line 1 imports
wrapswith Day 13’sfrom ... import. - Line 5 puts
@wraps(func)directly abovedef wrapper. Unlike@property, this decorator line has parentheses and an argument:wraps(func)is a call that returns a decorator which remembersfunc(a closure, 18.5), and that decorator is then applied towrapper. - Lines 19 to 21 now report
area, its docstring, and its real parameters,widthandheight. - Line 22 shows the wrapper still works exactly as before: only the labels changed.
With wraps, here is the shape of nearly every decorator you will write. Copy it, then fill in the extra work:
from functools import wraps
def my_decorator(func):
@wraps(func)
def wrapper(*args, **kwargs):
# work before the call
result = func(*args, **kwargs)
# work after the call
return result
return wrapper
Key idea
A wrapper hides the original function’s name and docstring. Put @wraps(func), from functools, directly above def wrapper in every decorator you write, so the decorated function keeps its own name, docstring, and parameters.
Watch out
Putting @wraps(func) on the decorator instead of the wrapper. func exists only inside the decorator, as its parameter:
from functools import wraps
@wraps(func)
def log_calls(func):
def wrapper(*args, **kwargs):
print("Calling", func.__name__)
return func(*args, **kwargs)
return wrapperTraceback (most recent call last):
File ".../day18/wraps_outside.py", line 4, in <module>
@wraps(func)
^^^^
NameError: name 'func' is not definedLine 4 runs when the def log_calls line is reached, and no variable called func exists out there. @wraps(func) belongs one level in, directly above def wrapper, where func is the parameter it should copy from.
Pause and try
Question 14 · Tracing
What does this program print?
from functools import wraps
def plain(func):
def wrapper(*args, **kwargs):
return func(*args, **kwargs)
return wrapper
def careful(func):
@wraps(func)
def wrapper(*args, **kwargs):
return func(*args, **kwargs)
return wrapper
@plain
def shout(text):
"""Return text in capitals."""
return text.upper()
@careful
def whisper(text):
"""Return text in small letters."""
return text.lower()
print(shout("hi"), shout.__name__)
print(whisper("HI"), whisper.__name__, whisper.__doc__)Check your reasoning
HI wrapper
hi whisper Return text in small letters.Both decorators make a wrapper that passes the call through, so shout("hi") and whisper("HI") work normally. plain does not use wraps, so shout now names a function called wrapper. careful uses @wraps(func), which copies the name whisper and its docstring onto its wrapper.
Reread 18.8 Keeping a Function’s Name with functools.wraps if your output differed.
Putting it together
A fitness tracker writes one line per day to a file, as date,steps. Lines starting with # are notes. Save this as steps.txt in day18:
# date,steps
2026-10-01,6210
2026-10-02,10432
# watch battery died
2026-10-03,3105
2026-10-04,12877
2026-10-05,9050
2026-10-06,11240
The program below reads it lazily, uses closures for the step goals, and a decorator to label each report.
from functools import wraps
def log_calls(func):
@wraps(func)
def wrapper(*args, **kwargs):
print(f"[{func.__name__}]")
return func(*args, **kwargs)
return wrapper
def read_days(path):
with open(path, encoding="utf-8") as file:
for line in file:
if line.startswith("#"):
continue
date, steps = line.strip().split(",")
yield date, int(steps)
def at_least(goal):
def reached(day):
return day[1] >= goal
return reached
@log_calls
def report(days, test):
count = 0
for day in days:
if test(day):
count += 1
print(f" {day[0]}: {day[1]}")
return count
days = read_days("steps.txt")
first_date, first_steps = next(days)
print("First day:", first_date, first_steps)
total = report(days, at_least(10000))
print("Goal reached on", total, "of the remaining days")
busy = report(read_days("steps.txt"), at_least(12000))
print("Very busy days:", busy)First day: 2026-10-01 6210
[report]
2026-10-02: 10432
2026-10-04: 12877
2026-10-06: 11240
Goal reached on 3 of the remaining days
[report]
2026-10-04: 12877
Very busy days: 1
| Lines | What happens | Idea |
|---|---|---|
| 4–10 | A decorator with @wraps, so the label prints the real name, report, not wrapper |
18.7, 18.8 |
| 13–19 | A generator function that opens the file itself, skips notes, and yields one (date, steps) tuple per day |
18.3, 18.4 |
| 22–26 | at_least(goal) returns a closure that remembers goal |
18.5 |
| 29–36 | report loops over any iterable of days and uses whatever test it is given |
18.1 |
| 39–41 | next(days) takes the first day off the generator by hand |
18.1 |
| 43 | The same generator carries on from its bookmark, so 2026-10-01 is not counted again | 18.1, 18.4 |
| 46 | A new generator reads the file from the start for the second report | 18.3 |
Follow the data. Line 39 runs no code in read_days. Line 40’s next runs the body: it opens the file, skips the note on the first line, and pauses at yield with the first day. Line 43’s report then pulls the remaining five days through the same paused generator. Of those, 10432, 12877, and 11240 reach 10000, so the count is 3. When the loop has the last line, the generator’s body finishes, which ends its with block and closes the file. Line 46 needs every day again, so it calls read_days again for a fresh generator; reusing days would find nothing left. Only one line of the file is in memory at any moment, so the same program would work on years of data.
Common mistakes
Several of today’s errors appear far from the line that caused them. Use this table to go from a message to its usual cause.
| Message or symptom | Usual cause | Fix |
|---|---|---|
TypeError: 'list' object is not an iterator |
Called next() on an iterable |
Call iter() first |
StopIteration traceback |
Called next() on a finished iterator |
Give a default: next(it, None) |
TypeError: 'Countdown' object is not iterable |
A class has __next__ but no __iter__ |
Add __iter__ that returns self |
| A second loop over a generator does nothing | The generator is used up | Call the generator function again |
| Only the first value comes back | return where yield was meant |
Use yield |
| The program never ends | list(), sum(), or sorted() on an endless generator |
Take a few with islice |
ValueError: I/O operation on closed file. |
A file’s generator was used after its with block |
Loop inside the block, or open in the generator |
TypeError: make_multiplier.<locals>.multiply() missing 1 required ... |
return multiply() instead of return multiply |
Return the function, no parentheses |
UnboundLocalError: cannot access local variable 'count' ... |
A closure assigns to an enclosing variable | Add nonlocal count |
A decorated function returns None |
The wrapper does not return the result |
return func(*args, **kwargs) |
TypeError: log_calls.<locals>.wrapper() takes 1 positional argument ... |
The wrapper does not accept every argument | def wrapper(*args, **kwargs): |
__name__ is wrapper; help() shows wrapper(*args, **kwargs) |
The decorator does not use wraps |
@wraps(func) above def wrapper |
NameError: name 'func' is not defined on an @wraps line |
@wraps(func) above the decorator instead of the wrapper |
Move it above def wrapper |
Older versions of Python may word some of these messages differently.
Vocabulary
| Term | Definition | Example |
|---|---|---|
| iterator | An object that hands out items one at a time with next() and remembers which comes next |
iter(colors) |
iter() |
Returns an iterator for an iterable | bookmark = iter(scores) |
next() |
Returns an iterator’s next item, or a default if one is given and the iterator is finished | next(bookmark, "no more") |
StopIteration |
The exception an iterator raises when it has no items left | raise StopIteration |
| iteration protocol | The agreement: iter() gives an iterator, and next() gives items until StopIteration |
what every for loop does |
| protocol | An agreed way of doing something | the iteration protocol |
__iter__ / __next__ |
The special methods iter() and next() call |
def __next__(self): |
| generator function | A function containing yield; calling it returns a generator without running the body |
def countdown(start): |
yield |
Hands a value to next() and pauses the generator, keeping its local variables |
yield current |
| lazy evaluation | Working out each value only when it is asked for | a generator |
| eager evaluation | Working out every value up front | a list comprehension |
itertools.islice() |
Gives the first n items of an iterator, lazily |
islice(tickets, 3) |
| pipeline | Generators chained so each value flows through every step in turn | messages(error_lines(file)) |
| nested function | A function defined inside another function’s body | def multiply(number): inside make_multiplier |
| closure | A nested function that keeps the enclosing function’s variables after that function returns | double = make_multiplier(2) |
nonlocal |
Says that a name in a nested function is the enclosing function’s variable, so it can be assigned | nonlocal count |
| decorator | A function that takes a function and returns a replacement, usually a wrapper | log_calls |
| wrapper | The nested function a decorator returns, which does extra work around a call to the original | def wrapper(*args, **kwargs): |
__name__ (of a function) |
The name a function was defined with | add.__name__ is "add" |
__doc__ |
A function’s docstring | area.__doc__ |
functools.wraps() |
A decorator for a wrapper that copies the original’s name, docstring, and parameters onto it | @wraps(func) |
Summary
- A for loop follows the iteration protocol:
iter()gets an iterator, andnext()asks it for items until it raisesStopIteration. A list hands out a fresh iterator each time; a generator or a file is its own iterator, so it can be used only once. A class becomes an iterator with__iter__and__next__. - A function containing
yieldis a generator function. Calling it runs nothing and returns a generator; eachnext()runs the body to the nextyield, hands over that value, and pauses with its local variables kept.returnends a function with one value;yieldhands over a value and waits to continue. - Generators are lazy: they make each value only when asked. That allows endless generators (take what you need with
islice) and pipelines that handle huge files one line at a time. - A closure is a nested function that remembers the variables of the function that made it. It can read them freely; to assign to one, declare it
nonlocal. - A decorator takes a function and returns a wrapper that adds work around a call to the original. The wrapper takes
*args, **kwargsand returns the result,@decoratorabove adefapplies it once, and@wraps(func)keeps the original’s name and docstring.
Practice
Do these in your day18 folder. Type each program yourself, run it, and only then open the answer.
Question 15 · Short answer
Endless Fibonacci. The Fibonacci numbers start 0, 1, and each one after that is the sum of the two before it: 0, 1, 1, 2, 3, 5, 8, … Write a generator function that yields Fibonacci numbers forever, and use it to make a list of the first 20.
Check your reasoning
One possible solution:
from itertools import islice
def fibonacci():
current, following = 0, 1
while True:
yield current
current, following = following, current + following
first_20 = list(islice(fibonacci(), 20))
print(first_20)
print(len(first_20), first_20[-1])[0, 1, 1, 2, 3, 5, 8, 13, 21, 34, 55, 89, 144, 233, 377, 610, 987, 1597, 2584, 4181]
20 4181How it works: the generator keeps only the two most recent numbers, current and following. Each pass yields current and then moves both along at once with Day 7’s tuple swap: the new current is the old following, and the new following is their sum. Because the swap happens in one line, neither value is overwritten before it is used. while True: makes the generator endless (18.4), which is safe because islice(fibonacci(), 20) asks for exactly 20 values. Calling list(fibonacci()) without islice would never finish.
Reread 18.3 Generator Functions and yield and 18.4 Lazy Evaluation: Endless and Large Inputs if your result differed.
Question 16 · Short answer
Skipping blank lines. Write a generator function that takes a file path, reads the file, and yields only the lines that are not blank, without their "\n". A line holding only spaces counts as blank. Test it on a notes file with some blank lines, numbering the lines it gives back.
Check your reasoning
Save this as notes.txt. Spaces do not show up below, so to test the all-spaces case, type three spaces on line 3, and two spaces before and after the last line:
Buy milk
Call Ana about the project
Water the plants
Return library booksOne possible solution:
def non_blank_lines(path):
with open(path, encoding="utf-8") as file:
for line in file:
text = line.strip()
if text:
yield text
for number, line in enumerate(non_blank_lines("notes.txt"), 1):
print(f"{number}. {line}")
print(sum(1 for _ in non_blank_lines("notes.txt")))1. Buy milk
2. Call Ana about the project
3. Water the plants
4. Return library books
4How it works: the generator opens the file itself, inside its own with block, so the file is open exactly while the lines are being used and is closed when the generator finishes (18.4). line.strip() removes the "\n" and any spaces, so a blank or all-spaces line becomes "", which is falsy (Day 4) and is skipped. Only one line is in memory at a time, so this works on any size of file. The last line counts the non-blank lines with Day 11’s sum over a generator expression; it calls non_blank_lines again, because the first generator was used up by the for loop.
Reread 18.4 Lazy Evaluation: Endless and Large Inputs if your result differed.
Question 17 · Short answer
A timer decorator. Write a @timer decorator that prints how long each call to the decorated function took. Use the standard library’s time module: time.perf_counter() returns a number of seconds as a float, and the difference between two readings is how long the code between them took. time.sleep(seconds) pauses a program, which makes a handy slow function to test with. Keep the decorated function’s name and docstring.
Check your reasoning
One possible solution:
import time
from functools import wraps
def timer(func):
@wraps(func)
def wrapper(*args, **kwargs):
start = time.perf_counter()
result = func(*args, **kwargs)
elapsed = time.perf_counter() - start
print(f"{func.__name__} took {elapsed:.4f} seconds")
return result
return wrapper
@timer
def sum_of_squares(limit):
"""Add up the squares of 1 to limit."""
return sum(n * n for n in range(1, limit + 1))
@timer
def slow_hello(name, seconds=0.5):
time.sleep(seconds)
return f"Hello, {name}!"
print(sum_of_squares(1000000))
print(slow_hello("Ana"))
print(sum_of_squares.__name__, "-", sum_of_squares.__doc__)The times change on every run and on every computer, so your numbers will differ. The output has five lines. First comes a line such as sum_of_squares took 0.0213 seconds, then the sum itself, 333333833333500000 (the same total as Day 11’s big sum). Next, slow_hello took a little over 0.5 seconds, then Hello, Ana!. The last line is sum_of_squares - Add up the squares of 1 to limit.
How it works: the wrapper reads the clock, calls the original with all its arguments (18.7), reads the clock again, and prints the difference with Day 3’s :.4f. It prints before the caller’s print, because the wrapper finishes, timing line and all, before its return value reaches print. It returns the result, so the decorated functions still give their answers. @wraps(func) keeps the name and docstring (18.8), which is why the last line shows sum_of_squares and its docstring, not wrapper and None.
Reread 18.7 Decorators: Functions That Wrap Functions and 18.8 Keeping a Function’s Name with functools.wraps if your result differed.
Review questions
Question 18 · Tracing
What does this program print?
def numbered(items, start=1):
number = start
for item in items:
yield f"{number}. {item}"
number += 1
chores = numbered(["wash", "dry", "fold"])
print(next(chores))
for line in chores:
print(line.upper())
print(list(chores))
print(list(numbered("ab", start=5)))Check your reasoning
1. wash
2. DRY
3. FOLD
[]
['5. a', '6. b']| Line | What asks | Generator yields (and pauses) | Printed |
|---|---|---|---|
| 9 | next(chores) |
"1. wash"; number becomes 2 on resume |
1. wash |
| 10–11 | the for loop | "2. dry", "3. fold", then the body ends |
2. DRY, 3. FOLD |
| 12 | list(chores) |
nothing: the generator is used up | [] |
| 13 | a new generator | "5. a", "6. b" (a string is iterable) |
the list |
The for loop carries on from the same bookmark as next, so wash is not repeated, and upper() changes the whole string, including dry. Line 13 makes a fresh generator with start=5.
Reread 18.1 What a for Loop Really Does: iter() and next() and 18.3 Generator Functions and yield if your output differed.
Question 19 · Tracing
What does this program print? This decorator returns the original function instead of a wrapper.
def register(func):
print("registering", func.__name__)
return func
@register
def hello():
print("hello")
print("program starts")
hello()
hello()Check your reasoning
registering hello
program starts
hello
helloThe @register line runs hello = register(hello) once, as soon as the def has run, which is before line 11. So registering hello prints first. register returns func itself, so hello is still the original function, and each call prints only hello; the registering message does not repeat. A decorator does not have to make a wrapper: real programs use this shape to keep a list of functions (for example, every menu command) without changing them.
Reread 18.7 Decorators: Functions That Wrap Functions if your output differed.
Question 20 · Debugging
This decorator works on double but crashes on add. Explain the error and fix it.
def log_calls(func):
def wrapper(value):
print("Calling", func.__name__)
return func(value)
return wrapper
@log_calls
def double(number):
return number * 2
@log_calls
def add(a, b):
return a + b
print(double(4))
print(add(2, 3))Check your reasoning
It prints Calling double and 8, then stops with:
TypeError: log_calls.<locals>.wrapper() takes 1 positional argument but 2 were givenAfter decorating, add is the wrapper, and the wrapper has exactly one parameter, value. add(2, 3) passes two arguments to it, so the call fails before the original add even runs. The message names log_calls.<locals>.wrapper, the function defined inside log_calls, which is the clue that the problem is in the decorator, not in add. Let the wrapper accept and pass on any arguments:
def log_calls(func):
def wrapper(*args, **kwargs):
print("Calling", func.__name__)
return func(*args, **kwargs)
return wrapper
@log_calls
def double(number):
return number * 2
@log_calls
def add(a, b):
return a + b
print(double(4))
print(add(2, 3))Calling double
8
Calling add
5Reread 18.7 Decorators: Functions That Wrap Functions if you missed it.
Question 21 · Tracing
What does this program print? The decorator uses a closure with nonlocal.
from functools import wraps
def count_calls(func):
calls = 0
@wraps(func)
def wrapper(*args, **kwargs):
nonlocal calls
calls += 1
print(f"{func.__name__} call {calls}")
return func(*args, **kwargs)
return wrapper
@count_calls
def square(n):
return n * n
@count_calls
def cube(n):
return n ** 3
print(square(3))
print(square(4))
print(cube(2))
print(square(5))Check your reasoning
square call 1
9
square call 2
16
cube call 1
8
square call 3
25@count_calls runs once for square and once for cube, and each run makes its own calls variable, starting at 0, with its own wrapper remembering it (18.5). nonlocal calls lets the wrapper add 1 to that remembered count (18.6). So square’s count goes 1, 2, 3 across its three calls, while cube’s separate count is 1. func.__name__ gives the original names, and each wrapper returns the original’s result: 3 × 3, 4 × 4, 2 ** 3, and 5 × 5.
Reread 18.6 Changing a Remembered Variable with nonlocal and 18.7 Decorators: Functions That Wrap Functions if your output differed.
Question 22 · Short answer
Rewrite the Evens class from Question 3 as a generator function, evens(limit), that gives the same results. Then name two jobs the class had to do by hand that the generator function does for you.
Check your reasoning
One possible solution, with the same test lines as Question 3:
def evens(limit):
number = 0
while number <= limit:
yield number
number += 2
numbers = evens(5)
print(next(numbers))
print(list(numbers))
print(sum(evens(6)))0
[2, 4]
12Jobs the generator function does for you:
- Keeping the position. The class stored the next number in
self.numberand updated it on every__next__call. In the generator,numberis an ordinary local variable that stays alive while the generator is paused. - Signalling the end. The class had to
raise StopIterationitself. The generator raises it automatically when thewhileloop ends and the body finishes.
It also supplies __iter__ and __next__: a generator already is an iterator, so there is nothing to write for them. A correct answer names at least two of these.
Reread 18.2 Making Your Own Iterator: __iter__ and __next__ and 18.3 Generator Functions and yield if your result differed.
Question 23 · Multiple choice
For which job is a generator the worst fit, compared with a list?
- Adding up a number from each line of a 2 GB file
- Finding the first line of a log that contains
"ERROR" - Sorting a class’s test scores and printing the middle one, then the top three
- Handing out a new order number every time one is needed
Check your reasoning
C. Sorting needs every value at once, and the job then uses the scores again, by position (the middle one, then the top three). A list holds all the values, can be indexed, has a length, and can be used as often as needed; a generator can do none of those.
- A is wrong: adding needs each number only once, one at a time, so a generator avoids holding a 2 GB file in memory.
- B is wrong: a generator can stop as soon as the first match is found, without reading the rest of the log.
- D is wrong: an endless supply is exactly what a generator with
while True:gives, and a list cannot be endless.
Reread 18.4 Lazy Evaluation: Endless and Large Inputs if you chose differently.
Question 24 · Short answer
A friend says: “yield is just return for generators.” Explain what yield does differently from return, using what happens to the function’s local variables and what the next request does.
Check your reasoning
Model answer: return ends the function call. It hands back one value, the function’s local variables are thrown away, and calling the function again starts a brand-new run from the first line. yield hands back a value but does not end the function: it pauses it, with its local variables and its place kept. The next next() resumes on the line after the yield, with the variables as they were, so one generator can hand out many values over time, one per request. Also, calling a function with yield does not run its body at all; it returns a generator, and the body runs only as values are asked for.
A correct answer says that return ends the call and loses the local variables, while yield pauses and keeps them, so the next request continues where it left off.
Reread 18.3 Generator Functions and yield if your reasoning differed.
Go further
The official Python Tutorial explains the iteration protocol and generators in Classes: Iterators and Classes: Generators. The functools module documentation describes wraps and other tools for working with functions.
Reflection
Check each statement. If the answer is “not yet,” reread the linked subsection.
- I can explain what a for loop does with iter(), next(), and StopIteration. Reread
- I can tell an iterable from an iterator, and say why a generator can be used only once. Reread
- I can write a class with __iter__ and __next__ that works in a for loop. Reread
- I can write a generator function and explain what yield does differently from return. Reread
- I can use an endless generator safely with islice, and process a large file with a generator pipeline. Reread
- I can write a function that returns a closure, and explain what the closure remembers. Reread
- I can use nonlocal to change a variable a closure remembers. Reread
- I can write a decorator with a wrapper that takes *args and **kwargs, and apply it with @. Reread
- I can use functools.wraps so a decorated function keeps its name and docstring. Reread