Chapter 5. Saving Time and Memory
"It's not the daily increase but daily decrease. Hack away at the unessential." | ||
--Bruce Lee |
I love this quote from Bruce Lee, he was such a wise man! Especially, the second part, hack away at the unessential, is to me what makes a computer program elegant. After all, if there is a better way of doing things so that we don't waste time or memory, why not?
Sometimes, there are valid reasons for not pushing our code up to the maximum limit: for example, sometimes to achieve a negligible improvement, we have to sacrifice on readability or maintainability. Does it make any sense to have a web page served in 1 second with unreadable, complicated code, when we can serve it in 1.05 seconds with readable, clean code? No, it makes no sense.
On the other hand, sometimes it's perfectly licit to try and shave off a millisecond from a function, especially when the function is meant to be called thousands of times. Every millisecond you save there means one second saved per thousand of calls, and this could be meaningful for your application.
In light of these considerations, the focus of this chapter will not be to give you the tools to push your code to the absolute limits of performance and optimization "no matter what", but rather, to give you the tools to write efficient, elegant code that reads well, runs fast, and doesn't waste resources in an obvious way.
In this chapter, I will perform several measurements and comparisons, and cautiously draw some conclusions. Please do keep in mind that on a different box with a different setup or a different operating system, results may vary. Take a look at this code:
squares.py
def square1(n): return n ** 2 # squaring through the power operator def square2(n): return n * n # squaring through multiplication
Both functions return the square of n, but which is faster? From a simple benchmark I ran on them, it looks like the second is slightly faster. If you think about it, it makes sense: calculating the power of a number involves multiplication and therefore, whatever algorithm you may use to perform the power operation, it's not likely to beat a simple multiplication like the one in square2
.
Do we care about this result? In most cases no. If you're coding an e-commerce website, chances are you won't ever even need to raise a number to the second power, and if you do, you probably will have to do it a few times per page. You don't need to concern yourself on saving a few microseconds on a function you call a few times.
So, when does optimization become important? One very common case is when you have to deal with huge collections of data. If you're applying the same function on a million customer
objects, then you want your function to be tuned up to its best. Gaining 1/10 of a second on a function called one million times saves you 100,000 seconds, which are about 27.7 hours. That's not the same, right? So, let's focus on collections, and let's see which tools Python gives you to handle them with efficiency and grace.
Note
Many of the concepts we will see in this chapter are based on those of iterator and iterable. Simply put, the ability for an object to return its next element when asked, and to raise a StopIteration
exception when exhausted. We'll see how to code a custom iterator and iterable objects in the next chapter.
map, zip, and filter
We'll start by reviewing map
, filter
, and zip
, which are the main built-in functions one can employ when handling collections, and then we'll learn how to achieve the same results using two very important constructs: comprehensions and generators. Fasten your seat belt!
map
According to the official Python documentation:
map(function, iterable, ...)
returns an iterator that applies function to every item of iterable, yielding the results. If additional iterable arguments are passed, function must take that many arguments and is applied to the items from all iterables in parallel. With multiple iterables, the iterator stops when the shortest iterable is exhausted.
We will explain the concept of yielding later on in the chapter. For now, let's translate this into code: we'll use a lambda function that takes a variable number of positional arguments, and just returns them as a tuple. Also, as map
returns an iterator, we'll need to wrap each call to it within a list
constructor so that we exhaust the iterable by putting all of its elements into a list (you'll see an example of this in the code):
map.example.py
>>> map(lambda *a: a, range(3)) # without wrapping in list... <map object at 0x7f563513b518> # we get the iterator object >>> list(map(lambda *a: a, range(3))) # wrapping in list... [(0,), (1,), (2,)] # we get a list with its elements >>> list(map(lambda *a: a, range(3), 'abc')) # 2 iterables [(0, 'a'), (1, 'b'), (2, 'c')] >>> list(map(lambda *a: a, range(3), 'abc', range(4, 7))) # 3 [(0, 'a', 4), (1, 'b', 5), (2, 'c', 6)] >>> # map stops at the shortest iterator >>> list(map(lambda *a: a, (), 'abc')) # empty tuple is shortest [] >>> list(map(lambda *a: a, (1, 2), 'abc')) # (1, 2) shortest [(1, 'a'), (2, 'b')] >>> list(map(lambda *a: a, (1, 2, 3, 4), 'abc')) # 'abc' shortest [(1, 'a'), (2, 'b'), (3, 'c')]
In the preceding code you can see why, in order to present you with the results, I have to wrap the calls to map
within a list
constructor, otherwise I get the string representation of a map
object, which is not really useful in this context, is it?
You can also notice how the elements of each iterable are applied to the function: at first, the first element of each iterable, then the second one of each iterable, and so on. Notice also that map stops when the shortest of the iterables we called it with is exhausted. This is actually a very nice behavior: it doesn't force us to level off all the iterables to a common length, and it doesn't break if they aren't all the same length.
map
is very useful when you have to apply the same function to one or more collections of objects. As a more interesting example, let's see the decorate-sort-undecorate idiom (also known as Schwartzian transform). It's a technique that was extremely popular when Python sorting wasn't providing key-functions, and therefore today is less used, but it's a cool trick that still comes at hand once in a while.
Let's see a variation of it in the next example: we want to sort in descending order by the sum of credits accumulated by students, so to have the best student at position 0. We write a function to produce a decorated object, we sort, and then we undecorate. Each student has credits in three (possibly different) subjects. To decorate an object means to transform it, either adding extra data to it, or putting it into another object, in a way that allows us to be able to sort the original objects the way we want. After the sorting, we revert the decorated objects to get the original ones from them. This is called to undecorate.
decorate.sort.undecorate.py
students = [ dict(id=0, credits=dict(math=9, physics=6, history=7)), dict(id=1, credits=dict(math=6, physics=7, latin=10)), dict(id=2, credits=dict(history=8, physics=9, chemistry=10)), dict(id=3, credits=dict(math=5, physics=5, geography=7)), ] def decorate(student): # create a 2-tuple (sum of credits, student) from student dict return (sum(student['credits'].values()), student) def undecorate(decorated_student): # discard sum of credits, return original student dict return decorated_student[1] students = sorted(map(decorate, students), reverse=True) students = list(map(undecorate, students))
In the preceding code, I highlighted the tricky and important parts. Let's start by understanding what each student object is. In fact, let's print the first one:
{'credits': {'history': 7, 'math': 9, 'physics': 6}, 'id': 0}
You can see that it's a dictionary with two keys: id
and credit
. The value of credit
is also a dictionary in which there are three subject/grade key/value pairs. As I'm sure you recall from our visit in the data structures world, calling dict.values()
returns an object similar to an iterable
, with only the values. Therefore, sum(student['credits'].values())
, for the first student is equivalent to sum(9, 6, 7)
(or any permutation of those numbers because dictionaries don't retain order, but luckily for us, addition is commutative).
With that out of the way, it's easy to see what is the result of calling decorate with any of the students. Let's print the result of decorate(students[0])
:
(22, {'credits': {'history': 7, 'math': 9, 'physics': 6}, 'id': 0})
That's nice! If we decorate all the students like this, we can sort them on their total amount of credits but just sorting the list of tuples. In order to apply the decoration to each item in students, we call map(decorate, students)
. Then we sort the result, and then we undecorate in a similar fashion. If you have gone through the previous chapters correctly, understanding this code shouldn't be too hard.
Printing students after running the whole code yields:
$ python decorate.sort.undecorate.py [{'credits': {'chemistry': 10, 'history': 8, 'physics': 9}, 'id': 2}, {'credits': {'latin': 10, 'math': 6, 'physics': 7}, 'id': 1}, {'credits': {'history': 7, 'math': 9, 'physics': 6}, 'id': 0}, {'credits': {'geography': 7, 'math': 5, 'physics': 5}, 'id': 3}]
And you can see, by the order of the student objects, that they have indeed been sorted by the sum of their credits.
Note
For more on the decorate-sort-undecorate idiom, there's a very nice introduction in the sorting how-to section of the official Python documentation (https://docs.python.org/3.4/howto/sorting.html#the-old-way-using-decorate-sort-undecorate).
One thing to notice about the sorting part: what if two or more students share the same total sum? The sorting algorithm would then proceed sorting the tuples by comparing the student
objects with each other. This doesn't make any sense, and in more complex cases could lead to unpredictable results, or even errors. If you want to be sure to avoid this issue, one simple solution is to create a 3-tuple instead of a 2-tuple, having the sum of credits in the first position, the position of the student
object in the students
list in the second one, and the student
object itself in the third one. This way, if the sum of credits is the same, the tuples will be sorted against the position, which will always be different and therefore enough to resolve the sorting between any pair of tuples. For more considerations on this topic, please check out the sorting how-to section on the official Python documentation.
zip
We've already covered zip
in the previous chapters, so let's just define it properly and then I want to show you how you could combine it with map
.
According to the Python documentation:
zip(*iterables)
returns an iterator of tuples, where the i-th tuple contains the i-th element from each of the argument sequences or iterables. The iterator stops when the shortest input iterable is exhausted. With a single iterable argument, it returns an iterator of 1-tuples. With no arguments, it returns an empty iterator.
Let's see an example:
zip.grades.py
>>> grades = [18, 23, 30, 27, 15, 9, 22] >>> avgs = [22, 21, 29, 24, 18, 18, 24] >>> list(zip(avgs, grades)) [(22, 18), (21, 23), (29, 30), (24, 27), (18, 15), (18, 9), (24, 22)] >>> list(map(lambda *a: a, avgs, grades)) # equivalent to zip [(22, 18), (21, 23), (29, 30), (24, 27), (18, 15), (18, 9), (24, 22)]
In the preceding code, we're zipping together the average and the grade for the last exam, per each student. Notice how the code inside the two list calls produces exactly the same result, showing how easy it is to reproduce zip
using map
. Notice also that, as we do for map
, we have to feed the result of the zip
call to a list
constructor.
A simple example on the combined use of map
and zip
could be a way of calculating the element-wise maximum amongst sequences, that is, the maximum of the first element of each sequence, then the maximum of the second one, and so on:
maxims.py
>>> a = [5, 9, 2, 4, 7] >>> b = [3, 7, 1, 9, 2] >>> c = [6, 8, 0, 5, 3] >>> maxs = map(lambda n: max(*n), zip(a, b, c)) >>> list(maxs) [6, 9, 2, 9, 7]
Notice how easy it is to calculate the max values of three sequences. zip
is not strictly needed of course, we could just use map
, but this would require us to write a much more complicated function to feed map
with. Sometimes we may be in a situation where changing the function we feed to map
is not even possible. In cases like these, being able to massage the data (like we're doing in this example with zip
) is very helpful.
filter
According to the Python documentation:
filter(function, iterable)
construct an iterator from those elements of iterable for which function returns True. iterable may be either a sequence, a container which supports iteration, or an iterator. If function isNone
, the identity function is assumed, that is, all elements of iterable that are false are removed.
Let's see a very quick example:
filter.py
>>> test = [2, 5, 8, 0, 0, 1, 0] >>> list(filter(None, test)) [2, 5, 8, 1] >>> list(filter(lambda x: x, test)) # equivalent to previous one [2, 5, 8, 1] >>> list(filter(lambda x: x > 4, test)) # keep only items > 4 [5, 8]
In the preceding code, notice how the second call to filter is equivalent to the first one. If we pass a function that takes one argument and returns the argument itself, only those arguments that are True
will make the function return True
, therefore this behavior is exactly the same as passing None
. It's often a very good exercise to mimic some of the built-in Python behaviors. When you succeed you can say you fully understand how Python behaves in a specific situation.
Armed with map
, zip
, and filter
(and several other functions from the Python standard library) we can massage sequences very effectively. But those functions are not the only way to do it. So let's see one of the nicest features of Python: comprehensions.
Comprehensions
Python offers you different types of comprehensions: list
, dict
, and set
.
We'll concentrate on the first one for now, and then it will be easy to explain the other two.
A list
comprehension is a quick way of making a list. Usually the list is the result of some operation that may involve applying a function, filtering, or building a different data structure.
Let's start with a very simple example I want to calculate a list with the squares of the first 10 natural numbers. How would you do it? There are a couple of equivalent ways:
squares.map.py
# If you code like this you are not a Python guy! ;) >>> squares = [] >>> for n in range(10): ... squares.append(n ** 2) ... >>> list(squares) [0, 1, 4, 9, 16, 25, 36, 49, 64, 81] # This is better, one line, nice and readable >>> squares = map(lambda n: n**2, range(10)) >>> list(squares) [0, 1, 4, 9, 16, 25, 36, 49, 64, 81]
The preceding example should be nothing new for you. Let's see how to achieve the same result using a list comprehension:
squares.comprehension.py
>>> [n ** 2 for n in range(10)] [0, 1, 4, 9, 16, 25, 36, 49, 64, 81]
As simple as that. Isn't it elegant? Basically we have put a for
loop within square brackets. Let's now filter out the odd squares. I'll show you how to do it with map
and filter
, and then using a list
comprehension again.
even.squares.py
# using map and filter sq1 = list( filter(lambda n: not n % 2, map(lambda n: n ** 2, range(10))) ) # equivalent, but using list comprehensions sq2 = [n ** 2 for n in range(10) if not n % 2] print(sq1, sq1 == sq2) # prints: [0, 4, 16, 36, 64] True
I think that now the difference in readability is evident. The list comprehension reads much better. It's almost English: give me all squares (n ** 2) for n between 0 and 9 if n is even.
According to the Python documentation:
A list comprehension consists of brackets containing an expression followed by a
for
clause, then zero or morefor
orif
clauses. The result will be a new list resulting from evaluating the expression in the context of thefor
andif
clauses which follow it".
Nested comprehensions
Let's see an example of nested loops. It's very common when dealing with algorithms to have to iterate on a sequence using two placeholders. The first one runs through the whole sequence, left to right. The second one as well, but it starts from the first one, instead of 0. The concept is that of testing all pairs without duplication. Let's see the classical for
loop equivalent.
pairs.for.loop.py
items = 'ABCDE' pairs = [] for a in range(len(items)): for b in range(a, len(items)): pairs.append((items[a], items[b]))
If you print pairs at the end, you get:
[('A', 'A'), ('A', 'B'), ('A', 'C'), ('A', 'D'), ('A', 'E'), ('B', 'B'), ('B', 'C'), ('B', 'D'), ('B', 'E'), ('C', 'C'), ('C', 'D'), ('C', 'E'), ('D', 'D'), ('D', 'E'), ('E', 'E')]
All the tuples with the same letter are those for which b
is at the same position as a
. Now, let's see how we can translate this in a list comprehension:
pairs.list.comprehension.py
items = 'ABCDE' pairs = [(items[a], items[b]) for a in range(len(items)) for b in range(a, len(items))]
This version is just two lines long and achieves the same result. Notice that in this particular case, because the for
loop over b
has a dependency on a
, it must follow the for
loop over a
in the comprehension. If you swap them around, you'll get a name error.
Filtering a comprehension
We can apply filtering to a comprehension. Let's first do it with filter
. Let's find all Pythagorean triples whose short sides are numbers smaller than 10. We obviously don't want to test a combination twice, and therefore we'll use a trick like the one we saw in the previous example.
Note
A Pythagorean triple is a triple (a, b, c) of integer numbers satisfying the equation .
pythagorean.triple.py
from math import sqrt
# this will generate all possible pairs
mx = 10
legs = [(a, b, sqrt(a**2 + b**2))
for a in range(1, mx) for b in range(a, mx)]
# this will filter out all non pythagorean triples
legs = list(
filter(lambda triple: triple[2].is_integer(), legs))
print(legs) # prints: [(3, 4, 5.0), (6, 8, 10.0)]
In the preceding code, we generated a list of 3-tuples, legs. Each tuple contains two integer numbers (the legs) and the hypotenuse of the Pythagorean triangle whose legs are the first two numbers in the tuple. For example, when a = 3 and b = 4, the tuple will be (3, 4, 5.0), and when a = 5 and b = 7, the tuple will be (5, 7, 8.602325267042627).
After having all the triples done, we need to filter out all those that don't have a hypotenuse that is an integer number. In order to do this, we filter based on float_number.is_integer()
being True
. This means that of the two example tuples I showed you before, the one with hypotenuse 5.0 will be retained, while the one with hypotenuse 8.602325267042627 will be discarded.
This is good, but I don't like that the triple has two integer numbers and a float. They are supposed to be all integers, so let's use map to fix this:
pythagorean.triple.int.py
from math import sqrt
mx = 10
legs = [(a, b, sqrt(a**2 + b**2))
for a in range(1, mx) for b in range(a, mx)]
legs = filter(lambda triple: triple[2].is_integer(), legs)
# this will make the third number in the tuples integer
legs = list(
map(lambda triple: triple[:2] + (int(triple[2]), ), legs))
print(legs) # prints: [(3, 4, 5), (6, 8, 10)]
Notice the step we added. We take each element in legs and we slice it, taking only the first two elements in it. Then, we concatenate the slice with a 1-tuple, in which we put the integer version of that float number that we didn't like.
Seems like a lot of work, right? Indeed it is. Let's see how to do all this with a list comprehension:
pythagorean.triple.comprehension.py
from math import sqrt
# this step is the same as before
mx = 10
legs = [(a, b, sqrt(a**2 + b**2))
for a in range(1, mx) for b in range(a, mx)]
# here we combine filter and map in one CLEAN list comprehension
legs = [(a, b, int(c)) for a, b, c in legs if c.is_integer()]
print(legs) # prints: [(3, 4, 5), (6, 8, 10)]
I know. It's much better, isn't it? It's clean, readable, shorter. In other words, elegant.
Tip
I'm going quite fast here, as anticipated in the summary of the last chapter. Are you playing with this code? If not, I suggest you do. It's very important that you play around, break things, change things, see what happens. Make sure you have a clear understanding of what is going on. You want to become a ninja, right?
dict comprehensions
Dictionary and set comprehensions work exactly like the list ones, only there is a little difference in the syntax. The following example will suffice to explain everything you need to know:
dictionary.comprehensions.py
from string import ascii_lowercase lettermap = dict((c, k) for k, c in enumerate(ascii_lowercase, 1))
If you print lettermap
, you will see the following (I omitted the middle results, you get the gist):
{'a': 1, 'b': 2, 'c': 3, ... omitted results ... 'x': 24, 'y': 25, 'z': 26}
What happens in the preceding code is that we're feeding the dict
constructor with a comprehension (technically, a generator expression, we'll see it in a bit). We tell the dict
constructor to make key/value pairs from each tuple in the comprehension. We enumerate the sequence of all lowercase ASCII letters, starting from 1, using enumerate
. Piece of cake. There is also another way to do the same thing, which is closer to the other dictionary syntax:
lettermap = {c: k for k, c in enumerate(ascii_lowercase, 1)}
It does exactly the same thing, with a slightly different syntax that highlights a bit more of the key: value part.
Dictionaries do not allow duplication in the keys, as shown in the following example:
dictionary.comprehensions.duplicates.py
word = 'Hello'
swaps = {c: c.swapcase() for c in word}
print(swaps) # prints: {'o': 'O', 'l': 'L', 'e': 'E', 'H': 'h'}
We create a dictionary with keys, the letters in the string 'Hello'
, and values of the same letters, but with the case swapped. Notice there is only one 'l': 'L'
pair. The constructor doesn't complain, simply reassigns duplicates to the latest value. Let's make this clearer with another example; let's assign to each key its position in the string:
dictionary.comprehensions.positions.py
word = 'Hello'
positions = {c: k for k, c in enumerate(word)}
print(positions) # prints: {'l': 3, 'o': 4, 'e': 1, 'H': 0}
Notice the value associated to the letter 'l': 3
. The pair 'l': 2
isn't there, it has been overridden by 'l': 3
.
set comprehensions
Set comprehensions are very similar to list and dictionary ones. Python allows both the set()
constructor to be used, or the explicit {}
syntax. Let's see one quick example:
set.comprehensions.py
word = 'Hello' letters1 = set(c for c in word) letters2 = {c for c in word} print(letters1) # prints: {'l', 'o', 'H', 'e'} print(letters1 == letters2) # prints: True
Notice how for set comprehensions, as for dictionaries, duplication is not allowed and therefore the resulting set has only four letters. Also, notice that the expressions assigned to letters1
and letters2
produce equivalent sets.
The syntax used to create letters2
is very similar to the one we can use to create a dictionary comprehension. You can spot the difference only by the fact that dictionaries require keys and values, separated by columns, while sets don't.
Nested comprehensions
Let's see an example of nested loops. It's very common when dealing with algorithms to have to iterate on a sequence using two placeholders. The first one runs through the whole sequence, left to right. The second one as well, but it starts from the first one, instead of 0. The concept is that of testing all pairs without duplication. Let's see the classical for
loop equivalent.
pairs.for.loop.py
items = 'ABCDE' pairs = [] for a in range(len(items)): for b in range(a, len(items)): pairs.append((items[a], items[b]))
If you print pairs at the end, you get:
[('A', 'A'), ('A', 'B'), ('A', 'C'), ('A', 'D'), ('A', 'E'), ('B', 'B'), ('B', 'C'), ('B', 'D'), ('B', 'E'), ('C', 'C'), ('C', 'D'), ('C', 'E'), ('D', 'D'), ('D', 'E'), ('E', 'E')]
All the tuples with the same letter are those for which b
is at the same position as a
. Now, let's see how we can translate this in a list comprehension:
pairs.list.comprehension.py
items = 'ABCDE' pairs = [(items[a], items[b]) for a in range(len(items)) for b in range(a, len(items))]
This version is just two lines long and achieves the same result. Notice that in this particular case, because the for
loop over b
has a dependency on a
, it must follow the for
loop over a
in the comprehension. If you swap them around, you'll get a name error.
Filtering a comprehension
We can apply filtering to a comprehension. Let's first do it with filter
. Let's find all Pythagorean triples whose short sides are numbers smaller than 10. We obviously don't want to test a combination twice, and therefore we'll use a trick like the one we saw in the previous example.
Note
A Pythagorean triple is a triple (a, b, c) of integer numbers satisfying the equation .
pythagorean.triple.py
from math import sqrt
# this will generate all possible pairs
mx = 10
legs = [(a, b, sqrt(a**2 + b**2))
for a in range(1, mx) for b in range(a, mx)]
# this will filter out all non pythagorean triples
legs = list(
filter(lambda triple: triple[2].is_integer(), legs))
print(legs) # prints: [(3, 4, 5.0), (6, 8, 10.0)]
In the preceding code, we generated a list of 3-tuples, legs. Each tuple contains two integer numbers (the legs) and the hypotenuse of the Pythagorean triangle whose legs are the first two numbers in the tuple. For example, when a = 3 and b = 4, the tuple will be (3, 4, 5.0), and when a = 5 and b = 7, the tuple will be (5, 7, 8.602325267042627).
After having all the triples done, we need to filter out all those that don't have a hypotenuse that is an integer number. In order to do this, we filter based on float_number.is_integer()
being True
. This means that of the two example tuples I showed you before, the one with hypotenuse 5.0 will be retained, while the one with hypotenuse 8.602325267042627 will be discarded.
This is good, but I don't like that the triple has two integer numbers and a float. They are supposed to be all integers, so let's use map to fix this:
pythagorean.triple.int.py
from math import sqrt
mx = 10
legs = [(a, b, sqrt(a**2 + b**2))
for a in range(1, mx) for b in range(a, mx)]
legs = filter(lambda triple: triple[2].is_integer(), legs)
# this will make the third number in the tuples integer
legs = list(
map(lambda triple: triple[:2] + (int(triple[2]), ), legs))
print(legs) # prints: [(3, 4, 5), (6, 8, 10)]
Notice the step we added. We take each element in legs and we slice it, taking only the first two elements in it. Then, we concatenate the slice with a 1-tuple, in which we put the integer version of that float number that we didn't like.
Seems like a lot of work, right? Indeed it is. Let's see how to do all this with a list comprehension:
pythagorean.triple.comprehension.py
from math import sqrt
# this step is the same as before
mx = 10
legs = [(a, b, sqrt(a**2 + b**2))
for a in range(1, mx) for b in range(a, mx)]
# here we combine filter and map in one CLEAN list comprehension
legs = [(a, b, int(c)) for a, b, c in legs if c.is_integer()]
print(legs) # prints: [(3, 4, 5), (6, 8, 10)]
I know. It's much better, isn't it? It's clean, readable, shorter. In other words, elegant.
Tip
I'm going quite fast here, as anticipated in the summary of the last chapter. Are you playing with this code? If not, I suggest you do. It's very important that you play around, break things, change things, see what happens. Make sure you have a clear understanding of what is going on. You want to become a ninja, right?
dict comprehensions
Dictionary and set comprehensions work exactly like the list ones, only there is a little difference in the syntax. The following example will suffice to explain everything you need to know:
dictionary.comprehensions.py
from string import ascii_lowercase lettermap = dict((c, k) for k, c in enumerate(ascii_lowercase, 1))
If you print lettermap
, you will see the following (I omitted the middle results, you get the gist):
{'a': 1, 'b': 2, 'c': 3, ... omitted results ... 'x': 24, 'y': 25, 'z': 26}
What happens in the preceding code is that we're feeding the dict
constructor with a comprehension (technically, a generator expression, we'll see it in a bit). We tell the dict
constructor to make key/value pairs from each tuple in the comprehension. We enumerate the sequence of all lowercase ASCII letters, starting from 1, using enumerate
. Piece of cake. There is also another way to do the same thing, which is closer to the other dictionary syntax:
lettermap = {c: k for k, c in enumerate(ascii_lowercase, 1)}
It does exactly the same thing, with a slightly different syntax that highlights a bit more of the key: value part.
Dictionaries do not allow duplication in the keys, as shown in the following example:
dictionary.comprehensions.duplicates.py
word = 'Hello'
swaps = {c: c.swapcase() for c in word}
print(swaps) # prints: {'o': 'O', 'l': 'L', 'e': 'E', 'H': 'h'}
We create a dictionary with keys, the letters in the string 'Hello'
, and values of the same letters, but with the case swapped. Notice there is only one 'l': 'L'
pair. The constructor doesn't complain, simply reassigns duplicates to the latest value. Let's make this clearer with another example; let's assign to each key its position in the string:
dictionary.comprehensions.positions.py
word = 'Hello'
positions = {c: k for k, c in enumerate(word)}
print(positions) # prints: {'l': 3, 'o': 4, 'e': 1, 'H': 0}
Notice the value associated to the letter 'l': 3
. The pair 'l': 2
isn't there, it has been overridden by 'l': 3
.
set comprehensions
Set comprehensions are very similar to list and dictionary ones. Python allows both the set()
constructor to be used, or the explicit {}
syntax. Let's see one quick example:
set.comprehensions.py
word = 'Hello' letters1 = set(c for c in word) letters2 = {c for c in word} print(letters1) # prints: {'l', 'o', 'H', 'e'} print(letters1 == letters2) # prints: True
Notice how for set comprehensions, as for dictionaries, duplication is not allowed and therefore the resulting set has only four letters. Also, notice that the expressions assigned to letters1
and letters2
produce equivalent sets.
The syntax used to create letters2
is very similar to the one we can use to create a dictionary comprehension. You can spot the difference only by the fact that dictionaries require keys and values, separated by columns, while sets don't.
Filtering a comprehension
We can apply filtering to a comprehension. Let's first do it with filter
. Let's find all Pythagorean triples whose short sides are numbers smaller than 10. We obviously don't want to test a combination twice, and therefore we'll use a trick like the one we saw in the previous example.
Note
A Pythagorean triple is a triple (a, b, c) of integer numbers satisfying the equation .
pythagorean.triple.py
from math import sqrt
# this will generate all possible pairs
mx = 10
legs = [(a, b, sqrt(a**2 + b**2))
for a in range(1, mx) for b in range(a, mx)]
# this will filter out all non pythagorean triples
legs = list(
filter(lambda triple: triple[2].is_integer(), legs))
print(legs) # prints: [(3, 4, 5.0), (6, 8, 10.0)]
In the preceding code, we generated a list of 3-tuples, legs. Each tuple contains two integer numbers (the legs) and the hypotenuse of the Pythagorean triangle whose legs are the first two numbers in the tuple. For example, when a = 3 and b = 4, the tuple will be (3, 4, 5.0), and when a = 5 and b = 7, the tuple will be (5, 7, 8.602325267042627).
After having all the triples done, we need to filter out all those that don't have a hypotenuse that is an integer number. In order to do this, we filter based on float_number.is_integer()
being True
. This means that of the two example tuples I showed you before, the one with hypotenuse 5.0 will be retained, while the one with hypotenuse 8.602325267042627 will be discarded.
This is good, but I don't like that the triple has two integer numbers and a float. They are supposed to be all integers, so let's use map to fix this:
pythagorean.triple.int.py
from math import sqrt
mx = 10
legs = [(a, b, sqrt(a**2 + b**2))
for a in range(1, mx) for b in range(a, mx)]
legs = filter(lambda triple: triple[2].is_integer(), legs)
# this will make the third number in the tuples integer
legs = list(
map(lambda triple: triple[:2] + (int(triple[2]), ), legs))
print(legs) # prints: [(3, 4, 5), (6, 8, 10)]
Notice the step we added. We take each element in legs and we slice it, taking only the first two elements in it. Then, we concatenate the slice with a 1-tuple, in which we put the integer version of that float number that we didn't like.
Seems like a lot of work, right? Indeed it is. Let's see how to do all this with a list comprehension:
pythagorean.triple.comprehension.py
from math import sqrt
# this step is the same as before
mx = 10
legs = [(a, b, sqrt(a**2 + b**2))
for a in range(1, mx) for b in range(a, mx)]
# here we combine filter and map in one CLEAN list comprehension
legs = [(a, b, int(c)) for a, b, c in legs if c.is_integer()]
print(legs) # prints: [(3, 4, 5), (6, 8, 10)]
I know. It's much better, isn't it? It's clean, readable, shorter. In other words, elegant.
Tip
I'm going quite fast here, as anticipated in the summary of the last chapter. Are you playing with this code? If not, I suggest you do. It's very important that you play around, break things, change things, see what happens. Make sure you have a clear understanding of what is going on. You want to become a ninja, right?
dict comprehensions
Dictionary and set comprehensions work exactly like the list ones, only there is a little difference in the syntax. The following example will suffice to explain everything you need to know:
dictionary.comprehensions.py
from string import ascii_lowercase lettermap = dict((c, k) for k, c in enumerate(ascii_lowercase, 1))
If you print lettermap
, you will see the following (I omitted the middle results, you get the gist):
{'a': 1, 'b': 2, 'c': 3, ... omitted results ... 'x': 24, 'y': 25, 'z': 26}
What happens in the preceding code is that we're feeding the dict
constructor with a comprehension (technically, a generator expression, we'll see it in a bit). We tell the dict
constructor to make key/value pairs from each tuple in the comprehension. We enumerate the sequence of all lowercase ASCII letters, starting from 1, using enumerate
. Piece of cake. There is also another way to do the same thing, which is closer to the other dictionary syntax:
lettermap = {c: k for k, c in enumerate(ascii_lowercase, 1)}
It does exactly the same thing, with a slightly different syntax that highlights a bit more of the key: value part.
Dictionaries do not allow duplication in the keys, as shown in the following example:
dictionary.comprehensions.duplicates.py
word = 'Hello'
swaps = {c: c.swapcase() for c in word}
print(swaps) # prints: {'o': 'O', 'l': 'L', 'e': 'E', 'H': 'h'}
We create a dictionary with keys, the letters in the string 'Hello'
, and values of the same letters, but with the case swapped. Notice there is only one 'l': 'L'
pair. The constructor doesn't complain, simply reassigns duplicates to the latest value. Let's make this clearer with another example; let's assign to each key its position in the string:
dictionary.comprehensions.positions.py
word = 'Hello'
positions = {c: k for k, c in enumerate(word)}
print(positions) # prints: {'l': 3, 'o': 4, 'e': 1, 'H': 0}
Notice the value associated to the letter 'l': 3
. The pair 'l': 2
isn't there, it has been overridden by 'l': 3
.
set comprehensions
Set comprehensions are very similar to list and dictionary ones. Python allows both the set()
constructor to be used, or the explicit {}
syntax. Let's see one quick example:
set.comprehensions.py
word = 'Hello' letters1 = set(c for c in word) letters2 = {c for c in word} print(letters1) # prints: {'l', 'o', 'H', 'e'} print(letters1 == letters2) # prints: True
Notice how for set comprehensions, as for dictionaries, duplication is not allowed and therefore the resulting set has only four letters. Also, notice that the expressions assigned to letters1
and letters2
produce equivalent sets.
The syntax used to create letters2
is very similar to the one we can use to create a dictionary comprehension. You can spot the difference only by the fact that dictionaries require keys and values, separated by columns, while sets don't.
dict comprehensions
Dictionary and set comprehensions work exactly like the list ones, only there is a little difference in the syntax. The following example will suffice to explain everything you need to know:
dictionary.comprehensions.py
from string import ascii_lowercase lettermap = dict((c, k) for k, c in enumerate(ascii_lowercase, 1))
If you print lettermap
, you will see the following (I omitted the middle results, you get the gist):
{'a': 1, 'b': 2, 'c': 3, ... omitted results ... 'x': 24, 'y': 25, 'z': 26}
What happens in the preceding code is that we're feeding the dict
constructor with a comprehension (technically, a generator expression, we'll see it in a bit). We tell the dict
constructor to make key/value pairs from each tuple in the comprehension. We enumerate the sequence of all lowercase ASCII letters, starting from 1, using enumerate
. Piece of cake. There is also another way to do the same thing, which is closer to the other dictionary syntax:
lettermap = {c: k for k, c in enumerate(ascii_lowercase, 1)}
It does exactly the same thing, with a slightly different syntax that highlights a bit more of the key: value part.
Dictionaries do not allow duplication in the keys, as shown in the following example:
dictionary.comprehensions.duplicates.py
word = 'Hello'
swaps = {c: c.swapcase() for c in word}
print(swaps) # prints: {'o': 'O', 'l': 'L', 'e': 'E', 'H': 'h'}
We create a dictionary with keys, the letters in the string 'Hello'
, and values of the same letters, but with the case swapped. Notice there is only one 'l': 'L'
pair. The constructor doesn't complain, simply reassigns duplicates to the latest value. Let's make this clearer with another example; let's assign to each key its position in the string:
dictionary.comprehensions.positions.py
word = 'Hello'
positions = {c: k for k, c in enumerate(word)}
print(positions) # prints: {'l': 3, 'o': 4, 'e': 1, 'H': 0}
Notice the value associated to the letter 'l': 3
. The pair 'l': 2
isn't there, it has been overridden by 'l': 3
.
set comprehensions
Set comprehensions are very similar to list and dictionary ones. Python allows both the set()
constructor to be used, or the explicit {}
syntax. Let's see one quick example:
set.comprehensions.py
word = 'Hello' letters1 = set(c for c in word) letters2 = {c for c in word} print(letters1) # prints: {'l', 'o', 'H', 'e'} print(letters1 == letters2) # prints: True
Notice how for set comprehensions, as for dictionaries, duplication is not allowed and therefore the resulting set has only four letters. Also, notice that the expressions assigned to letters1
and letters2
produce equivalent sets.
The syntax used to create letters2
is very similar to the one we can use to create a dictionary comprehension. You can spot the difference only by the fact that dictionaries require keys and values, separated by columns, while sets don't.
set comprehensions
Set comprehensions are very similar to list and dictionary ones. Python allows both the set()
constructor to be used, or the explicit {}
syntax. Let's see one quick example:
set.comprehensions.py
word = 'Hello' letters1 = set(c for c in word) letters2 = {c for c in word} print(letters1) # prints: {'l', 'o', 'H', 'e'} print(letters1 == letters2) # prints: True
Notice how for set comprehensions, as for dictionaries, duplication is not allowed and therefore the resulting set has only four letters. Also, notice that the expressions assigned to letters1
and letters2
produce equivalent sets.
The syntax used to create letters2
is very similar to the one we can use to create a dictionary comprehension. You can spot the difference only by the fact that dictionaries require keys and values, separated by columns, while sets don't.
Generators
Generators are one very powerful tool that Python gifts us with. They are based on the concepts of iteration, as we said before, and they allow for coding patterns that combine elegance with efficiency.
Generators are of two types:
- Generator functions: These are very similar to regular functions, but instead of returning results through return statements, they use yield, which allows them to suspend and resume their state between each call
- Generator expressions: These are very similar to the list comprehensions we've seen in this chapter, but instead of returning a list they return an object that produces results one by one
Generator functions
Generator functions come under all aspects like regular functions, with one difference: instead of collecting results and returning them at once, they can start the computation, yield one value, suspend their state saving everything they need to be able to resume and, if called again, resume and perform another step. Generator functions are automatically turned into their own iterators by Python, so you can call next
on them.
This is all very theoretical so, let's make it clear why such a mechanism is so powerful, and then let's see an example.
Say I asked you to count out loud from 1 to a million. You start, and at some point I ask you to stop. After some time, I ask you to resume. At this point, what is the minimum information you need to be able to resume correctly? Well, you need to remember the last number you called. If I stopped you after 31415, you will just go on with 31416, and so on.
The point is, you don't need to remember all the numbers you said before 31415, nor do you need them to be written down somewhere. Well, you may not know it, but you're behaving like a generator already!
Take a good look at the following code:
first.n.squares.py
def get_squares(n): # classic function approach return [x ** 2 for x in range(n)] print(get_squares(10)) def get_squares_gen(n): # generator approach for x in range(n): yield x ** 2 # we yield, we don't return print(list(get_squares_gen(10)))
The result of the prints will be the same: [0, 1, 4, 9, 16, 25, 36, 49, 64, 81]
. But there is a huge difference between the two functions. get_squares
is a classic function that collects all the squares of numbers in [0, n) in a list, and returns it. On the other hand, get_squares_gen
is a generator, and behaves very differently. Each time the interpreter reaches the yield
line, its execution is suspended. The only reason those prints return the same result is because we fed get_squares_gen
to the list
constructor, which when called like that exhausts the generator completely by asking the next element until a StopIteration
is raised. Let's see this in detail:
first.n.squares.manual.py
def get_squares_gen(n): for x in range(n): yield x ** 2 squares = get_squares_gen(4) # this creates a generator object print(squares) # <generator object get_squares_gen at 0x7f158...> print(next(squares)) # prints: 0 print(next(squares)) # prints: 1 print(next(squares)) # prints: 4 print(next(squares)) # prints: 9 # the following raises StopIteration, the generator is exhausted, # any further call to next will keep raising StopIteration print(next(squares))
In the preceding code, each time we call next
on the generator object, we either start it (first next
) or make it resume from the last suspension point (any other next
).
The first time we call next
on it, we get 0
, which is the square of 0
, then 1
, then 4
, then 9
and since the for
loop stops after that (n
is 4
), then the generator naturally ends. A classic function would at that point just return None
, but in order to comply with the iteration protocol, a generator will instead raise a StopIteration
exception.
This explains how a for
loop works for example. When you call for k in range(n)
, what happens under the hood is that the for
loop gets an iterator out of range(n)
and starts calling next
on it, until StopIteration
is raised, which tells the for
loop that the iteration has reached its end.
Having this behavior built-in in every iteration aspect of Python makes generators even more powerful because once we write them, we'll be able to plug them in whatever iteration mechanism we want.
At this point, you're probably asking yourself why would you want to use a generator instead of a regular function. Well, the title of this chapter should suggest the answer. I'll talk about performances later, so for now let's concentrate on another aspect: sometimes generators allow you to do something that wouldn't be possible with a simple list. For example, say you want to analyze all permutations of a sequence. If the sequence has length N, then the number of its permutations is N!. This means that if the sequence is 10 elements long, the number of permutations is 3628800. But a sequence of 20 elements would have 2432902008176640000 permutations. They grow factorially.
Now imagine you have a classic function that is attempting to calculate all permutations, put them in a list, and return it to you. With 10 elements, it would require probably a few tens of seconds, but for 20 elements there is simply no way that it can be done.
On the other hand, a generator function will be able to start the computation and give you back the first permutation, then the second, and so on. Of course you won't have the time to parse them all, they are too many, but at least you'll be able to work with some of them.
Remember when we were talking about the break
statement in for
loops? When we found a number dividing a candidate prime we were breaking the loop, no need to go on.
Sometimes it's exactly the same, only the amount of data you have to iterate over is so huge that you cannot keep it all in memory in a list. In this case, generators are invaluable: they make possible what wouldn't be possible otherwise.
So, in order to save memory (and time), use generator functions whenever possible.
It's also worth noting that you can use the return statement in a generator function. It will produce a StopIteration
exception to be raised, effectively ending the iteration. This is extremely important. If a return
statement were actually to make the function return something, it would break the iteration protocol. Python consistency prevents this, and allows us great ease when coding. Let's see a quick example:
gen.yield.return.py
def geometric_progression(a, q): k = 0 while True: result = a * q**k if result <= 100000: yield result else: return k += 1 for n in geometric_progression(2, 5): print(n)
The preceding code yields all terms of the geometric progression a, aq, , , .... When the progression produces a term that is greater than 100,000, the generator stops (with a return
statement). Running the code produces the following result:
$ python gen.yield.return.py 2 10 50 250 1250 6250 31250
The next term would have been 156250, which is too big.
Going beyond next
At the beginning of this chapter, I told you that generator objects are based on the iteration protocol. We'll see in the next chapter a complete example of how to write a custom iterator/iterable object. For now, I just want you to understand how next()
works.
What happens when you call next(generator)
is that you're calling the generator.__next__()
method. Remember, a method is just a function that belongs to an object, and objects in Python can have special methods. Our friend __next__()
is just one of these and its purpose is to return the next element of the iteration, or to raise StopIteration
when the iteration is over and there are no more elements to return.
Note
In Python, an object's special methods are also called magic methods, or dunder (from "double underscore") methods.
When we write a generator function, Python automatically transforms it into an object that is very similar to an iterator, and when we call next(generator)
, that call is transformed in generator.__next__()
. Let's revisit the previous example about generating squares:
first.n.squares.manual.method.py
def get_squares_gen(n):
for x in range(n):
yield x ** 2
squares = get_squares_gen(3)
print(squares.__next__()) # prints: 0
print(squares.__next__()) # prints: 1
print(squares.__next__()) # prints: 4
# the following raises StopIteration, the generator is exhausted,
# any further call to next will keep raising StopIteration
print(squares.__next__())
The result is exactly as the previous example, only this time instead of using the proxy call next(squares)
, we're directly calling squares.__next__()
.
Generator objects have also three other methods that allow controlling their behavior: send
, throw
, and close
. send
allows us to communicate a value back to the generator object, while throw
and close
respectively allow raising an exception within the generator and closing it. Their use is quite advanced and I won't be covering them here in detail, but I want to spend a few words at least about send
, with a simple example.
Take a look at the following code:
gen.send.preparation.py
def counter(start=0):
n = start
while True:
yield n
n += 1
c = counter()
print(next(c)) # prints: 0
print(next(c)) # prints: 1
print(next(c)) # prints: 2
The preceding iterator creates a generator object that will run forever. You can keep calling it, it will never stop. Alternatively, you can put it in a for
loop, for example, for n in counter(): ...
and it will go on forever as well.
Now, what if you wanted to stop it at some point? One solution is to use a variable to control the while
loop. Something like this:
gen.send.preparation.stop.py
stop = False def counter(start=0): n = start while not stop: yield n n += 1 c = counter() print(next(c)) # prints: 0 print(next(c)) # prints: 1 stop = True print(next(c)) # raises StopIteration
This will do it. We start with stop = False
, and until we change it to True
, the generator will just keep going, like before. The moment we change stop to True
though, the while
loop will exit, and the next call will raise a StopIteration
exception. This trick works, but I don't like it. We depend on an external variable, and this can lead to issues: what if another function changes that stop
? Moreover, the code is scattered. In a nutshell, this isn't good enough.
We can make it better by using generator.send()
. When we call generator.send()
, the value that we feed to send
will be passed in to the generator, execution is resumed, and we can fetch it via the yield
expression. This is all very complicated when explained with words, so let's see an example:
gen.send.py
def counter(start=0): n = start while True: result = yield n # A print(type(result), result) # B if result == 'Q': break n += 1 c = counter() print(next(c)) # C print(c.send('Wow!')) # D print(next(c)) # E print(c.send('Q')) # F
Execution of the preceding code produces the following:
$ python gen.send.py 0 <class 'str'> Wow! 1 <class 'NoneType'> None 2 <class 'str'> Q Traceback (most recent call last): File "gen.send.py", line 14, in <module> print(c.send('Q')) # F StopIteration
I think it's worth going through this code line by line, like if we were executing it, and see if we can understand what's going on.
We start the generator execution with a call to next
(#C
). Within the generator, n
is set to the same value of start
. The while
loop is entered, execution stops (#A
) and n
(0) is yielded back to the caller. 0 is printed on the console.
We then call send
(#D
), execution resumes and result
is set to 'Wow!'
(still #A
), then its type and value are printed on the console (#B
). result
is not 'Q'
, therefore n
is incremented by 1 and execution goes back to the while
condition, which, being True
, evaluates to True
(that wasn't hard to guess, right?). Another loop cycle begins, execution stops again (#A
), and n
(1) is yielded back to the caller. 1 is printed on the console.
At this point, we call next
(#E
), execution is resumed again (#A
), and because we are not sending anything to the generator explicitly, Python behaves exactly like functions that are not using the return
statement: the yield n
expression (#A
) returns None
. result
therefore is set to None
, and its type and value are yet again printed on the console (#B
). Execution continues, result
is not 'Q'
so n
is incremented by 1, and we start another loop again. Execution stops again (#A
) and n
(2) is yielded back to the caller. 2 is printed on the console.
And now for the grand finale: we call send
again (#F
), but this time we pass in 'Q'
, therefore when execution is resumed, result
is set to 'Q'
(#A
). Its type and value are printed on the console (#B
), and then finally the if
clause evaluates to True
and the while
loop is stopped by the break
statement. The generator naturally terminates and this means a StopIteration
exception is raised. You can see the print of its traceback on the last few lines printed on the console.
This is not at all simple to understand at first, so if it's not clear to you, don't be discouraged. You can keep reading on and then you can come back to this example after some time.
Using send
allows for interesting patterns, and it's worth noting that send
can only be used to resume the execution, not to start it. Only next
starts the execution of a generator.
The yield from expression
Another interesting construct is the yield from
expression. This expression allows you to yield values from a subiterator. Its use allows for quite advanced patterns, so let's just see a very quick example of it:
gen.yield.for.py
def print_squares(start, end): for n in range(start, end): yield n ** 2 for n in print_squares(2, 5): print(n)
The previous code prints the numbers 4
, 9
, 16
on the console (on separate lines). By now, I expect you to be able to understand it by yourself, but let's quickly recap what happens. The for
loop outside the function gets an iterator from print_squares(2, 5)
and calls next
on it until iteration is over. Every time the generator is called, execution is suspended (and later resumed) on yield n ** 2
, which returns the square of the current n
.
Let's see how we can transform this code benefiting from the yield from
expression:
gen.yield.from.py
def print_squares(start, end):
yield from (n ** 2 for n in range(start, end))
for n in print_squares(2, 5):
print(n)
This code produces the same result, but as you can see the yield from
is actually running a subiterator (n ** 2 …)
. The yield from
expression returns to the caller each value the subiterator is producing. It's shorter and it reads better.
Generator expressions
Let's now talk about the other techniques to generate values one at a time.
The syntax is exactly the same as list comprehensions, only, instead of wrapping the comprehension with square brackets, you wrap it with round braces. That is called a generator expression.
In general, generator expressions behave like equivalent list comprehensions, but there is one very important thing to remember: generators allow for one iteration only, then they will be exhausted. Let's see an example:
generator.expressions.py
>>> cubes = [k**3 for k in range(10)] # regular list >>> cubes [0, 1, 8, 27, 64, 125, 216, 343, 512, 729] >>> type(cubes) <class 'list'> >>> cubes_gen = (k**3 for k in range(10)) # create as generator >>> cubes_gen <generator object <genexpr> at 0x7ff26b5db990> >>> type(cubes_gen) <class 'generator'> >>> list(cubes_gen) # this will exhaust the generator [0, 1, 8, 27, 64, 125, 216, 343, 512, 729] >>> list(cubes_gen) # nothing more to give []
Look at the line in which the generator expression is created and assigned the name cubes_gen
. You can see it's a generator object. In order to see its elements, we can use a for
loop, a manual set of calls to next
, or simply, feed it to a list
constructor, which is what I did.
Notice how, once the generator has been exhausted, there is no way to recover the same elements from it again. We need to recreate it, if we want to use it from scratch again.
In the next few examples, let's see how to reproduce map
and filter
using generator expressions:
gen.map.py
def adder(*n): return sum(n) s1 = sum(map(lambda n: adder(*n), zip(range(100), range(1, 101)))) s2 = sum(adder(*n) for n in zip(range(100), range(1, 101)))
In the previous example, s1
and s2
are exactly the same: they are the sum of adder(0, 1), adder(1, 2), adder(2, 3)
, and so on, which translates to sum(1, 3, 5, ...)
. The syntax is different though, I find the generator expression to be much more readable:
gen.filter.py
cubes = [x**3 for x in range(10)] odd_cubes1 = filter(lambda cube: cube % 2, cubes) odd_cubes2 = (cube for cube in cubes if cube % 2)
In the previous example, odd_cubes1
and odd_cubes2
are the same: they generate a sequence of odd cubes. Yet again, I prefer the generator syntax. This should be evident when things get a little more complicated:
gen.map.filter.py
N = 20 cubes1 = map( lambda n: (n, n**3), filter(lambda n: n % 3 == 0 or n % 5 == 0, range(N)) ) cubes2 = ( (n, n**3) for n in range(N) if n % 3 == 0 or n % 5 == 0)
The preceding code creates to generators cubes1
and cubes2
. They are exactly the same, and return 2-tuples (n, ) when n is a multiple of 3 or 5.
If you print the list (cubes1
), you get: [(0, 0), (3, 27), (5, 125), (6, 216), (9, 729), (10, 1000), (12, 1728), (15, 3375), (18, 5832)]
.
See how much better the generator expression reads? It may be debatable when things are very simple, but as soon as you start nesting functions a bit, like we did in this example, the superiority of the generator syntax is evident. Shorter, simpler, more elegant.
Now, let me ask you a question: what is the difference between the following lines of code?
sum.example.py
s1 = sum([n**2 for n in range(10**6)]) s2 = sum((n**2 for n in range(10**6))) s3 = sum(n**2 for n in range(10**6))
Strictly speaking, they all produce the same sum. The expressions to get s2
and s3
are exactly the same because the braces in s2
are redundant. They are both generator expressions inside the sum
function. The expression to get s1
is different though. Inside sum
, we find a list comprehension. This means that in order to calculate s1
, the sum
function has to call next
on a list, a million times.
Do you see where we're loosing time and memory? Before sum
can start calling next
on that list, the list needs to have been created, which is a waste of time and space. It's much better for sum
to call next
on a simple generator expression. There is no need to have all the numbers from range(10**6)
stored in a list.
So, watch out for extra parentheses when you write your expressions: sometimes it's easy to skip on these details, which makes our code much different. Don't believe me?
sum.example.2.py
s = sum([n**2 for n in range(10**8)]) # this is killed # s = sum(n**2 for n in range(10**8)) # this succeeds print(s)
Try running the preceding example. If I run the first line, this is what I get:
$ python sum.example.2.py Killed
On the other hand, if I comment out the first line, and uncomment the second one, this is the result:
$ python sum.example.2.py 333333328333333350000000
Sweet generator expressions. The difference between the two lines is that in the first one, a list with the squares of the first hundred million numbers must be made before being able to sum them up. That list is huge, and we run out of memory (at least, my box did, if yours doesn't try a bigger number), therefore Python kills the process for us. Sad face.
But when we remove the square brackets, we don't make a list any more. The sum function receives 0, 1, 4, 9, and so on until the last one, and sums them up. No problems, happy face.
Generator functions
Generator functions come under all aspects like regular functions, with one difference: instead of collecting results and returning them at once, they can start the computation, yield one value, suspend their state saving everything they need to be able to resume and, if called again, resume and perform another step. Generator functions are automatically turned into their own iterators by Python, so you can call next
on them.
This is all very theoretical so, let's make it clear why such a mechanism is so powerful, and then let's see an example.
Say I asked you to count out loud from 1 to a million. You start, and at some point I ask you to stop. After some time, I ask you to resume. At this point, what is the minimum information you need to be able to resume correctly? Well, you need to remember the last number you called. If I stopped you after 31415, you will just go on with 31416, and so on.
The point is, you don't need to remember all the numbers you said before 31415, nor do you need them to be written down somewhere. Well, you may not know it, but you're behaving like a generator already!
Take a good look at the following code:
first.n.squares.py
def get_squares(n): # classic function approach return [x ** 2 for x in range(n)] print(get_squares(10)) def get_squares_gen(n): # generator approach for x in range(n): yield x ** 2 # we yield, we don't return print(list(get_squares_gen(10)))
The result of the prints will be the same: [0, 1, 4, 9, 16, 25, 36, 49, 64, 81]
. But there is a huge difference between the two functions. get_squares
is a classic function that collects all the squares of numbers in [0, n) in a list, and returns it. On the other hand, get_squares_gen
is a generator, and behaves very differently. Each time the interpreter reaches the yield
line, its execution is suspended. The only reason those prints return the same result is because we fed get_squares_gen
to the list
constructor, which when called like that exhausts the generator completely by asking the next element until a StopIteration
is raised. Let's see this in detail:
first.n.squares.manual.py
def get_squares_gen(n): for x in range(n): yield x ** 2 squares = get_squares_gen(4) # this creates a generator object print(squares) # <generator object get_squares_gen at 0x7f158...> print(next(squares)) # prints: 0 print(next(squares)) # prints: 1 print(next(squares)) # prints: 4 print(next(squares)) # prints: 9 # the following raises StopIteration, the generator is exhausted, # any further call to next will keep raising StopIteration print(next(squares))
In the preceding code, each time we call next
on the generator object, we either start it (first next
) or make it resume from the last suspension point (any other next
).
The first time we call next
on it, we get 0
, which is the square of 0
, then 1
, then 4
, then 9
and since the for
loop stops after that (n
is 4
), then the generator naturally ends. A classic function would at that point just return None
, but in order to comply with the iteration protocol, a generator will instead raise a StopIteration
exception.
This explains how a for
loop works for example. When you call for k in range(n)
, what happens under the hood is that the for
loop gets an iterator out of range(n)
and starts calling next
on it, until StopIteration
is raised, which tells the for
loop that the iteration has reached its end.
Having this behavior built-in in every iteration aspect of Python makes generators even more powerful because once we write them, we'll be able to plug them in whatever iteration mechanism we want.
At this point, you're probably asking yourself why would you want to use a generator instead of a regular function. Well, the title of this chapter should suggest the answer. I'll talk about performances later, so for now let's concentrate on another aspect: sometimes generators allow you to do something that wouldn't be possible with a simple list. For example, say you want to analyze all permutations of a sequence. If the sequence has length N, then the number of its permutations is N!. This means that if the sequence is 10 elements long, the number of permutations is 3628800. But a sequence of 20 elements would have 2432902008176640000 permutations. They grow factorially.
Now imagine you have a classic function that is attempting to calculate all permutations, put them in a list, and return it to you. With 10 elements, it would require probably a few tens of seconds, but for 20 elements there is simply no way that it can be done.
On the other hand, a generator function will be able to start the computation and give you back the first permutation, then the second, and so on. Of course you won't have the time to parse them all, they are too many, but at least you'll be able to work with some of them.
Remember when we were talking about the break
statement in for
loops? When we found a number dividing a candidate prime we were breaking the loop, no need to go on.
Sometimes it's exactly the same, only the amount of data you have to iterate over is so huge that you cannot keep it all in memory in a list. In this case, generators are invaluable: they make possible what wouldn't be possible otherwise.
So, in order to save memory (and time), use generator functions whenever possible.
It's also worth noting that you can use the return statement in a generator function. It will produce a StopIteration
exception to be raised, effectively ending the iteration. This is extremely important. If a return
statement were actually to make the function return something, it would break the iteration protocol. Python consistency prevents this, and allows us great ease when coding. Let's see a quick example:
gen.yield.return.py
def geometric_progression(a, q): k = 0 while True: result = a * q**k if result <= 100000: yield result else: return k += 1 for n in geometric_progression(2, 5): print(n)
The preceding code yields all terms of the geometric progression a, aq, , , .... When the progression produces a term that is greater than 100,000, the generator stops (with a return
statement). Running the code produces the following result:
$ python gen.yield.return.py 2 10 50 250 1250 6250 31250
The next term would have been 156250, which is too big.
Going beyond next
At the beginning of this chapter, I told you that generator objects are based on the iteration protocol. We'll see in the next chapter a complete example of how to write a custom iterator/iterable object. For now, I just want you to understand how next()
works.
What happens when you call next(generator)
is that you're calling the generator.__next__()
method. Remember, a method is just a function that belongs to an object, and objects in Python can have special methods. Our friend __next__()
is just one of these and its purpose is to return the next element of the iteration, or to raise StopIteration
when the iteration is over and there are no more elements to return.
Note
In Python, an object's special methods are also called magic methods, or dunder (from "double underscore") methods.
When we write a generator function, Python automatically transforms it into an object that is very similar to an iterator, and when we call next(generator)
, that call is transformed in generator.__next__()
. Let's revisit the previous example about generating squares:
first.n.squares.manual.method.py
def get_squares_gen(n):
for x in range(n):
yield x ** 2
squares = get_squares_gen(3)
print(squares.__next__()) # prints: 0
print(squares.__next__()) # prints: 1
print(squares.__next__()) # prints: 4
# the following raises StopIteration, the generator is exhausted,
# any further call to next will keep raising StopIteration
print(squares.__next__())
The result is exactly as the previous example, only this time instead of using the proxy call next(squares)
, we're directly calling squares.__next__()
.
Generator objects have also three other methods that allow controlling their behavior: send
, throw
, and close
. send
allows us to communicate a value back to the generator object, while throw
and close
respectively allow raising an exception within the generator and closing it. Their use is quite advanced and I won't be covering them here in detail, but I want to spend a few words at least about send
, with a simple example.
Take a look at the following code:
gen.send.preparation.py
def counter(start=0):
n = start
while True:
yield n
n += 1
c = counter()
print(next(c)) # prints: 0
print(next(c)) # prints: 1
print(next(c)) # prints: 2
The preceding iterator creates a generator object that will run forever. You can keep calling it, it will never stop. Alternatively, you can put it in a for
loop, for example, for n in counter(): ...
and it will go on forever as well.
Now, what if you wanted to stop it at some point? One solution is to use a variable to control the while
loop. Something like this:
gen.send.preparation.stop.py
stop = False def counter(start=0): n = start while not stop: yield n n += 1 c = counter() print(next(c)) # prints: 0 print(next(c)) # prints: 1 stop = True print(next(c)) # raises StopIteration
This will do it. We start with stop = False
, and until we change it to True
, the generator will just keep going, like before. The moment we change stop to True
though, the while
loop will exit, and the next call will raise a StopIteration
exception. This trick works, but I don't like it. We depend on an external variable, and this can lead to issues: what if another function changes that stop
? Moreover, the code is scattered. In a nutshell, this isn't good enough.
We can make it better by using generator.send()
. When we call generator.send()
, the value that we feed to send
will be passed in to the generator, execution is resumed, and we can fetch it via the yield
expression. This is all very complicated when explained with words, so let's see an example:
gen.send.py
def counter(start=0): n = start while True: result = yield n # A print(type(result), result) # B if result == 'Q': break n += 1 c = counter() print(next(c)) # C print(c.send('Wow!')) # D print(next(c)) # E print(c.send('Q')) # F
Execution of the preceding code produces the following:
$ python gen.send.py 0 <class 'str'> Wow! 1 <class 'NoneType'> None 2 <class 'str'> Q Traceback (most recent call last): File "gen.send.py", line 14, in <module> print(c.send('Q')) # F StopIteration
I think it's worth going through this code line by line, like if we were executing it, and see if we can understand what's going on.
We start the generator execution with a call to next
(#C
). Within the generator, n
is set to the same value of start
. The while
loop is entered, execution stops (#A
) and n
(0) is yielded back to the caller. 0 is printed on the console.
We then call send
(#D
), execution resumes and result
is set to 'Wow!'
(still #A
), then its type and value are printed on the console (#B
). result
is not 'Q'
, therefore n
is incremented by 1 and execution goes back to the while
condition, which, being True
, evaluates to True
(that wasn't hard to guess, right?). Another loop cycle begins, execution stops again (#A
), and n
(1) is yielded back to the caller. 1 is printed on the console.
At this point, we call next
(#E
), execution is resumed again (#A
), and because we are not sending anything to the generator explicitly, Python behaves exactly like functions that are not using the return
statement: the yield n
expression (#A
) returns None
. result
therefore is set to None
, and its type and value are yet again printed on the console (#B
). Execution continues, result
is not 'Q'
so n
is incremented by 1, and we start another loop again. Execution stops again (#A
) and n
(2) is yielded back to the caller. 2 is printed on the console.
And now for the grand finale: we call send
again (#F
), but this time we pass in 'Q'
, therefore when execution is resumed, result
is set to 'Q'
(#A
). Its type and value are printed on the console (#B
), and then finally the if
clause evaluates to True
and the while
loop is stopped by the break
statement. The generator naturally terminates and this means a StopIteration
exception is raised. You can see the print of its traceback on the last few lines printed on the console.
This is not at all simple to understand at first, so if it's not clear to you, don't be discouraged. You can keep reading on and then you can come back to this example after some time.
Using send
allows for interesting patterns, and it's worth noting that send
can only be used to resume the execution, not to start it. Only next
starts the execution of a generator.
The yield from expression
Another interesting construct is the yield from
expression. This expression allows you to yield values from a subiterator. Its use allows for quite advanced patterns, so let's just see a very quick example of it:
gen.yield.for.py
def print_squares(start, end): for n in range(start, end): yield n ** 2 for n in print_squares(2, 5): print(n)
The previous code prints the numbers 4
, 9
, 16
on the console (on separate lines). By now, I expect you to be able to understand it by yourself, but let's quickly recap what happens. The for
loop outside the function gets an iterator from print_squares(2, 5)
and calls next
on it until iteration is over. Every time the generator is called, execution is suspended (and later resumed) on yield n ** 2
, which returns the square of the current n
.
Let's see how we can transform this code benefiting from the yield from
expression:
gen.yield.from.py
def print_squares(start, end):
yield from (n ** 2 for n in range(start, end))
for n in print_squares(2, 5):
print(n)
This code produces the same result, but as you can see the yield from
is actually running a subiterator (n ** 2 …)
. The yield from
expression returns to the caller each value the subiterator is producing. It's shorter and it reads better.
Generator expressions
Let's now talk about the other techniques to generate values one at a time.
The syntax is exactly the same as list comprehensions, only, instead of wrapping the comprehension with square brackets, you wrap it with round braces. That is called a generator expression.
In general, generator expressions behave like equivalent list comprehensions, but there is one very important thing to remember: generators allow for one iteration only, then they will be exhausted. Let's see an example:
generator.expressions.py
>>> cubes = [k**3 for k in range(10)] # regular list >>> cubes [0, 1, 8, 27, 64, 125, 216, 343, 512, 729] >>> type(cubes) <class 'list'> >>> cubes_gen = (k**3 for k in range(10)) # create as generator >>> cubes_gen <generator object <genexpr> at 0x7ff26b5db990> >>> type(cubes_gen) <class 'generator'> >>> list(cubes_gen) # this will exhaust the generator [0, 1, 8, 27, 64, 125, 216, 343, 512, 729] >>> list(cubes_gen) # nothing more to give []
Look at the line in which the generator expression is created and assigned the name cubes_gen
. You can see it's a generator object. In order to see its elements, we can use a for
loop, a manual set of calls to next
, or simply, feed it to a list
constructor, which is what I did.
Notice how, once the generator has been exhausted, there is no way to recover the same elements from it again. We need to recreate it, if we want to use it from scratch again.
In the next few examples, let's see how to reproduce map
and filter
using generator expressions:
gen.map.py
def adder(*n): return sum(n) s1 = sum(map(lambda n: adder(*n), zip(range(100), range(1, 101)))) s2 = sum(adder(*n) for n in zip(range(100), range(1, 101)))
In the previous example, s1
and s2
are exactly the same: they are the sum of adder(0, 1), adder(1, 2), adder(2, 3)
, and so on, which translates to sum(1, 3, 5, ...)
. The syntax is different though, I find the generator expression to be much more readable:
gen.filter.py
cubes = [x**3 for x in range(10)] odd_cubes1 = filter(lambda cube: cube % 2, cubes) odd_cubes2 = (cube for cube in cubes if cube % 2)
In the previous example, odd_cubes1
and odd_cubes2
are the same: they generate a sequence of odd cubes. Yet again, I prefer the generator syntax. This should be evident when things get a little more complicated:
gen.map.filter.py
N = 20 cubes1 = map( lambda n: (n, n**3), filter(lambda n: n % 3 == 0 or n % 5 == 0, range(N)) ) cubes2 = ( (n, n**3) for n in range(N) if n % 3 == 0 or n % 5 == 0)
The preceding code creates to generators cubes1
and cubes2
. They are exactly the same, and return 2-tuples (n, ) when n is a multiple of 3 or 5.
If you print the list (cubes1
), you get: [(0, 0), (3, 27), (5, 125), (6, 216), (9, 729), (10, 1000), (12, 1728), (15, 3375), (18, 5832)]
.
See how much better the generator expression reads? It may be debatable when things are very simple, but as soon as you start nesting functions a bit, like we did in this example, the superiority of the generator syntax is evident. Shorter, simpler, more elegant.
Now, let me ask you a question: what is the difference between the following lines of code?
sum.example.py
s1 = sum([n**2 for n in range(10**6)]) s2 = sum((n**2 for n in range(10**6))) s3 = sum(n**2 for n in range(10**6))
Strictly speaking, they all produce the same sum. The expressions to get s2
and s3
are exactly the same because the braces in s2
are redundant. They are both generator expressions inside the sum
function. The expression to get s1
is different though. Inside sum
, we find a list comprehension. This means that in order to calculate s1
, the sum
function has to call next
on a list, a million times.
Do you see where we're loosing time and memory? Before sum
can start calling next
on that list, the list needs to have been created, which is a waste of time and space. It's much better for sum
to call next
on a simple generator expression. There is no need to have all the numbers from range(10**6)
stored in a list.
So, watch out for extra parentheses when you write your expressions: sometimes it's easy to skip on these details, which makes our code much different. Don't believe me?
sum.example.2.py
s = sum([n**2 for n in range(10**8)]) # this is killed # s = sum(n**2 for n in range(10**8)) # this succeeds print(s)
Try running the preceding example. If I run the first line, this is what I get:
$ python sum.example.2.py Killed
On the other hand, if I comment out the first line, and uncomment the second one, this is the result:
$ python sum.example.2.py 333333328333333350000000
Sweet generator expressions. The difference between the two lines is that in the first one, a list with the squares of the first hundred million numbers must be made before being able to sum them up. That list is huge, and we run out of memory (at least, my box did, if yours doesn't try a bigger number), therefore Python kills the process for us. Sad face.
But when we remove the square brackets, we don't make a list any more. The sum function receives 0, 1, 4, 9, and so on until the last one, and sums them up. No problems, happy face.
Going beyond next
At the beginning of this chapter, I told you that generator objects are based on the iteration protocol. We'll see in the next chapter a complete example of how to write a custom iterator/iterable object. For now, I just want you to understand how next()
works.
What happens when you call next(generator)
is that you're calling the generator.__next__()
method. Remember, a method is just a function that belongs to an object, and objects in Python can have special methods. Our friend __next__()
is just one of these and its purpose is to return the next element of the iteration, or to raise StopIteration
when the iteration is over and there are no more elements to return.
Note
In Python, an object's special methods are also called magic methods, or dunder (from "double underscore") methods.
When we write a generator function, Python automatically transforms it into an object that is very similar to an iterator, and when we call next(generator)
, that call is transformed in generator.__next__()
. Let's revisit the previous example about generating squares:
first.n.squares.manual.method.py
def get_squares_gen(n):
for x in range(n):
yield x ** 2
squares = get_squares_gen(3)
print(squares.__next__()) # prints: 0
print(squares.__next__()) # prints: 1
print(squares.__next__()) # prints: 4
# the following raises StopIteration, the generator is exhausted,
# any further call to next will keep raising StopIteration
print(squares.__next__())
The result is exactly as the previous example, only this time instead of using the proxy call next(squares)
, we're directly calling squares.__next__()
.
Generator objects have also three other methods that allow controlling their behavior: send
, throw
, and close
. send
allows us to communicate a value back to the generator object, while throw
and close
respectively allow raising an exception within the generator and closing it. Their use is quite advanced and I won't be covering them here in detail, but I want to spend a few words at least about send
, with a simple example.
Take a look at the following code:
gen.send.preparation.py
def counter(start=0):
n = start
while True:
yield n
n += 1
c = counter()
print(next(c)) # prints: 0
print(next(c)) # prints: 1
print(next(c)) # prints: 2
The preceding iterator creates a generator object that will run forever. You can keep calling it, it will never stop. Alternatively, you can put it in a for
loop, for example, for n in counter(): ...
and it will go on forever as well.
Now, what if you wanted to stop it at some point? One solution is to use a variable to control the while
loop. Something like this:
gen.send.preparation.stop.py
stop = False def counter(start=0): n = start while not stop: yield n n += 1 c = counter() print(next(c)) # prints: 0 print(next(c)) # prints: 1 stop = True print(next(c)) # raises StopIteration
This will do it. We start with stop = False
, and until we change it to True
, the generator will just keep going, like before. The moment we change stop to True
though, the while
loop will exit, and the next call will raise a StopIteration
exception. This trick works, but I don't like it. We depend on an external variable, and this can lead to issues: what if another function changes that stop
? Moreover, the code is scattered. In a nutshell, this isn't good enough.
We can make it better by using generator.send()
. When we call generator.send()
, the value that we feed to send
will be passed in to the generator, execution is resumed, and we can fetch it via the yield
expression. This is all very complicated when explained with words, so let's see an example:
gen.send.py
def counter(start=0): n = start while True: result = yield n # A print(type(result), result) # B if result == 'Q': break n += 1 c = counter() print(next(c)) # C print(c.send('Wow!')) # D print(next(c)) # E print(c.send('Q')) # F
Execution of the preceding code produces the following:
$ python gen.send.py 0 <class 'str'> Wow! 1 <class 'NoneType'> None 2 <class 'str'> Q Traceback (most recent call last): File "gen.send.py", line 14, in <module> print(c.send('Q')) # F StopIteration
I think it's worth going through this code line by line, like if we were executing it, and see if we can understand what's going on.
We start the generator execution with a call to next
(#C
). Within the generator, n
is set to the same value of start
. The while
loop is entered, execution stops (#A
) and n
(0) is yielded back to the caller. 0 is printed on the console.
We then call send
(#D
), execution resumes and result
is set to 'Wow!'
(still #A
), then its type and value are printed on the console (#B
). result
is not 'Q'
, therefore n
is incremented by 1 and execution goes back to the while
condition, which, being True
, evaluates to True
(that wasn't hard to guess, right?). Another loop cycle begins, execution stops again (#A
), and n
(1) is yielded back to the caller. 1 is printed on the console.
At this point, we call next
(#E
), execution is resumed again (#A
), and because we are not sending anything to the generator explicitly, Python behaves exactly like functions that are not using the return
statement: the yield n
expression (#A
) returns None
. result
therefore is set to None
, and its type and value are yet again printed on the console (#B
). Execution continues, result
is not 'Q'
so n
is incremented by 1, and we start another loop again. Execution stops again (#A
) and n
(2) is yielded back to the caller. 2 is printed on the console.
And now for the grand finale: we call send
again (#F
), but this time we pass in 'Q'
, therefore when execution is resumed, result
is set to 'Q'
(#A
). Its type and value are printed on the console (#B
), and then finally the if
clause evaluates to True
and the while
loop is stopped by the break
statement. The generator naturally terminates and this means a StopIteration
exception is raised. You can see the print of its traceback on the last few lines printed on the console.
This is not at all simple to understand at first, so if it's not clear to you, don't be discouraged. You can keep reading on and then you can come back to this example after some time.
Using send
allows for interesting patterns, and it's worth noting that send
can only be used to resume the execution, not to start it. Only next
starts the execution of a generator.
The yield from expression
Another interesting construct is the yield from
expression. This expression allows you to yield values from a subiterator. Its use allows for quite advanced patterns, so let's just see a very quick example of it:
gen.yield.for.py
def print_squares(start, end): for n in range(start, end): yield n ** 2 for n in print_squares(2, 5): print(n)
The previous code prints the numbers 4
, 9
, 16
on the console (on separate lines). By now, I expect you to be able to understand it by yourself, but let's quickly recap what happens. The for
loop outside the function gets an iterator from print_squares(2, 5)
and calls next
on it until iteration is over. Every time the generator is called, execution is suspended (and later resumed) on yield n ** 2
, which returns the square of the current n
.
Let's see how we can transform this code benefiting from the yield from
expression:
gen.yield.from.py
def print_squares(start, end):
yield from (n ** 2 for n in range(start, end))
for n in print_squares(2, 5):
print(n)
This code produces the same result, but as you can see the yield from
is actually running a subiterator (n ** 2 …)
. The yield from
expression returns to the caller each value the subiterator is producing. It's shorter and it reads better.
Generator expressions
Let's now talk about the other techniques to generate values one at a time.
The syntax is exactly the same as list comprehensions, only, instead of wrapping the comprehension with square brackets, you wrap it with round braces. That is called a generator expression.
In general, generator expressions behave like equivalent list comprehensions, but there is one very important thing to remember: generators allow for one iteration only, then they will be exhausted. Let's see an example:
generator.expressions.py
>>> cubes = [k**3 for k in range(10)] # regular list >>> cubes [0, 1, 8, 27, 64, 125, 216, 343, 512, 729] >>> type(cubes) <class 'list'> >>> cubes_gen = (k**3 for k in range(10)) # create as generator >>> cubes_gen <generator object <genexpr> at 0x7ff26b5db990> >>> type(cubes_gen) <class 'generator'> >>> list(cubes_gen) # this will exhaust the generator [0, 1, 8, 27, 64, 125, 216, 343, 512, 729] >>> list(cubes_gen) # nothing more to give []
Look at the line in which the generator expression is created and assigned the name cubes_gen
. You can see it's a generator object. In order to see its elements, we can use a for
loop, a manual set of calls to next
, or simply, feed it to a list
constructor, which is what I did.
Notice how, once the generator has been exhausted, there is no way to recover the same elements from it again. We need to recreate it, if we want to use it from scratch again.
In the next few examples, let's see how to reproduce map
and filter
using generator expressions:
gen.map.py
def adder(*n): return sum(n) s1 = sum(map(lambda n: adder(*n), zip(range(100), range(1, 101)))) s2 = sum(adder(*n) for n in zip(range(100), range(1, 101)))
In the previous example, s1
and s2
are exactly the same: they are the sum of adder(0, 1), adder(1, 2), adder(2, 3)
, and so on, which translates to sum(1, 3, 5, ...)
. The syntax is different though, I find the generator expression to be much more readable:
gen.filter.py
cubes = [x**3 for x in range(10)] odd_cubes1 = filter(lambda cube: cube % 2, cubes) odd_cubes2 = (cube for cube in cubes if cube % 2)
In the previous example, odd_cubes1
and odd_cubes2
are the same: they generate a sequence of odd cubes. Yet again, I prefer the generator syntax. This should be evident when things get a little more complicated:
gen.map.filter.py
N = 20 cubes1 = map( lambda n: (n, n**3), filter(lambda n: n % 3 == 0 or n % 5 == 0, range(N)) ) cubes2 = ( (n, n**3) for n in range(N) if n % 3 == 0 or n % 5 == 0)
The preceding code creates to generators cubes1
and cubes2
. They are exactly the same, and return 2-tuples (n, ) when n is a multiple of 3 or 5.
If you print the list (cubes1
), you get: [(0, 0), (3, 27), (5, 125), (6, 216), (9, 729), (10, 1000), (12, 1728), (15, 3375), (18, 5832)]
.
See how much better the generator expression reads? It may be debatable when things are very simple, but as soon as you start nesting functions a bit, like we did in this example, the superiority of the generator syntax is evident. Shorter, simpler, more elegant.
Now, let me ask you a question: what is the difference between the following lines of code?
sum.example.py
s1 = sum([n**2 for n in range(10**6)]) s2 = sum((n**2 for n in range(10**6))) s3 = sum(n**2 for n in range(10**6))
Strictly speaking, they all produce the same sum. The expressions to get s2
and s3
are exactly the same because the braces in s2
are redundant. They are both generator expressions inside the sum
function. The expression to get s1
is different though. Inside sum
, we find a list comprehension. This means that in order to calculate s1
, the sum
function has to call next
on a list, a million times.
Do you see where we're loosing time and memory? Before sum
can start calling next
on that list, the list needs to have been created, which is a waste of time and space. It's much better for sum
to call next
on a simple generator expression. There is no need to have all the numbers from range(10**6)
stored in a list.
So, watch out for extra parentheses when you write your expressions: sometimes it's easy to skip on these details, which makes our code much different. Don't believe me?
sum.example.2.py
s = sum([n**2 for n in range(10**8)]) # this is killed # s = sum(n**2 for n in range(10**8)) # this succeeds print(s)
Try running the preceding example. If I run the first line, this is what I get:
$ python sum.example.2.py Killed
On the other hand, if I comment out the first line, and uncomment the second one, this is the result:
$ python sum.example.2.py 333333328333333350000000
Sweet generator expressions. The difference between the two lines is that in the first one, a list with the squares of the first hundred million numbers must be made before being able to sum them up. That list is huge, and we run out of memory (at least, my box did, if yours doesn't try a bigger number), therefore Python kills the process for us. Sad face.
But when we remove the square brackets, we don't make a list any more. The sum function receives 0, 1, 4, 9, and so on until the last one, and sums them up. No problems, happy face.
The yield from expression
Another interesting construct is the yield from
expression. This expression allows you to yield values from a subiterator. Its use allows for quite advanced patterns, so let's just see a very quick example of it:
gen.yield.for.py
def print_squares(start, end): for n in range(start, end): yield n ** 2 for n in print_squares(2, 5): print(n)
The previous code prints the numbers 4
, 9
, 16
on the console (on separate lines). By now, I expect you to be able to understand it by yourself, but let's quickly recap what happens. The for
loop outside the function gets an iterator from print_squares(2, 5)
and calls next
on it until iteration is over. Every time the generator is called, execution is suspended (and later resumed) on yield n ** 2
, which returns the square of the current n
.
Let's see how we can transform this code benefiting from the yield from
expression:
gen.yield.from.py
def print_squares(start, end):
yield from (n ** 2 for n in range(start, end))
for n in print_squares(2, 5):
print(n)
This code produces the same result, but as you can see the yield from
is actually running a subiterator (n ** 2 …)
. The yield from
expression returns to the caller each value the subiterator is producing. It's shorter and it reads better.
Generator expressions
Let's now talk about the other techniques to generate values one at a time.
The syntax is exactly the same as list comprehensions, only, instead of wrapping the comprehension with square brackets, you wrap it with round braces. That is called a generator expression.
In general, generator expressions behave like equivalent list comprehensions, but there is one very important thing to remember: generators allow for one iteration only, then they will be exhausted. Let's see an example:
generator.expressions.py
>>> cubes = [k**3 for k in range(10)] # regular list >>> cubes [0, 1, 8, 27, 64, 125, 216, 343, 512, 729] >>> type(cubes) <class 'list'> >>> cubes_gen = (k**3 for k in range(10)) # create as generator >>> cubes_gen <generator object <genexpr> at 0x7ff26b5db990> >>> type(cubes_gen) <class 'generator'> >>> list(cubes_gen) # this will exhaust the generator [0, 1, 8, 27, 64, 125, 216, 343, 512, 729] >>> list(cubes_gen) # nothing more to give []
Look at the line in which the generator expression is created and assigned the name cubes_gen
. You can see it's a generator object. In order to see its elements, we can use a for
loop, a manual set of calls to next
, or simply, feed it to a list
constructor, which is what I did.
Notice how, once the generator has been exhausted, there is no way to recover the same elements from it again. We need to recreate it, if we want to use it from scratch again.
In the next few examples, let's see how to reproduce map
and filter
using generator expressions:
gen.map.py
def adder(*n): return sum(n) s1 = sum(map(lambda n: adder(*n), zip(range(100), range(1, 101)))) s2 = sum(adder(*n) for n in zip(range(100), range(1, 101)))
In the previous example, s1
and s2
are exactly the same: they are the sum of adder(0, 1), adder(1, 2), adder(2, 3)
, and so on, which translates to sum(1, 3, 5, ...)
. The syntax is different though, I find the generator expression to be much more readable:
gen.filter.py
cubes = [x**3 for x in range(10)] odd_cubes1 = filter(lambda cube: cube % 2, cubes) odd_cubes2 = (cube for cube in cubes if cube % 2)
In the previous example, odd_cubes1
and odd_cubes2
are the same: they generate a sequence of odd cubes. Yet again, I prefer the generator syntax. This should be evident when things get a little more complicated:
gen.map.filter.py
N = 20 cubes1 = map( lambda n: (n, n**3), filter(lambda n: n % 3 == 0 or n % 5 == 0, range(N)) ) cubes2 = ( (n, n**3) for n in range(N) if n % 3 == 0 or n % 5 == 0)
The preceding code creates to generators cubes1
and cubes2
. They are exactly the same, and return 2-tuples (n, ) when n is a multiple of 3 or 5.
If you print the list (cubes1
), you get: [(0, 0), (3, 27), (5, 125), (6, 216), (9, 729), (10, 1000), (12, 1728), (15, 3375), (18, 5832)]
.
See how much better the generator expression reads? It may be debatable when things are very simple, but as soon as you start nesting functions a bit, like we did in this example, the superiority of the generator syntax is evident. Shorter, simpler, more elegant.
Now, let me ask you a question: what is the difference between the following lines of code?
sum.example.py
s1 = sum([n**2 for n in range(10**6)]) s2 = sum((n**2 for n in range(10**6))) s3 = sum(n**2 for n in range(10**6))
Strictly speaking, they all produce the same sum. The expressions to get s2
and s3
are exactly the same because the braces in s2
are redundant. They are both generator expressions inside the sum
function. The expression to get s1
is different though. Inside sum
, we find a list comprehension. This means that in order to calculate s1
, the sum
function has to call next
on a list, a million times.
Do you see where we're loosing time and memory? Before sum
can start calling next
on that list, the list needs to have been created, which is a waste of time and space. It's much better for sum
to call next
on a simple generator expression. There is no need to have all the numbers from range(10**6)
stored in a list.
So, watch out for extra parentheses when you write your expressions: sometimes it's easy to skip on these details, which makes our code much different. Don't believe me?
sum.example.2.py
s = sum([n**2 for n in range(10**8)]) # this is killed # s = sum(n**2 for n in range(10**8)) # this succeeds print(s)
Try running the preceding example. If I run the first line, this is what I get:
$ python sum.example.2.py Killed
On the other hand, if I comment out the first line, and uncomment the second one, this is the result:
$ python sum.example.2.py 333333328333333350000000
Sweet generator expressions. The difference between the two lines is that in the first one, a list with the squares of the first hundred million numbers must be made before being able to sum them up. That list is huge, and we run out of memory (at least, my box did, if yours doesn't try a bigger number), therefore Python kills the process for us. Sad face.
But when we remove the square brackets, we don't make a list any more. The sum function receives 0, 1, 4, 9, and so on until the last one, and sums them up. No problems, happy face.
Generator expressions
Let's now talk about the other techniques to generate values one at a time.
The syntax is exactly the same as list comprehensions, only, instead of wrapping the comprehension with square brackets, you wrap it with round braces. That is called a generator expression.
In general, generator expressions behave like equivalent list comprehensions, but there is one very important thing to remember: generators allow for one iteration only, then they will be exhausted. Let's see an example:
generator.expressions.py
>>> cubes = [k**3 for k in range(10)] # regular list >>> cubes [0, 1, 8, 27, 64, 125, 216, 343, 512, 729] >>> type(cubes) <class 'list'> >>> cubes_gen = (k**3 for k in range(10)) # create as generator >>> cubes_gen <generator object <genexpr> at 0x7ff26b5db990> >>> type(cubes_gen) <class 'generator'> >>> list(cubes_gen) # this will exhaust the generator [0, 1, 8, 27, 64, 125, 216, 343, 512, 729] >>> list(cubes_gen) # nothing more to give []
Look at the line in which the generator expression is created and assigned the name cubes_gen
. You can see it's a generator object. In order to see its elements, we can use a for
loop, a manual set of calls to next
, or simply, feed it to a list
constructor, which is what I did.
Notice how, once the generator has been exhausted, there is no way to recover the same elements from it again. We need to recreate it, if we want to use it from scratch again.
In the next few examples, let's see how to reproduce map
and filter
using generator expressions:
gen.map.py
def adder(*n): return sum(n) s1 = sum(map(lambda n: adder(*n), zip(range(100), range(1, 101)))) s2 = sum(adder(*n) for n in zip(range(100), range(1, 101)))
In the previous example, s1
and s2
are exactly the same: they are the sum of adder(0, 1), adder(1, 2), adder(2, 3)
, and so on, which translates to sum(1, 3, 5, ...)
. The syntax is different though, I find the generator expression to be much more readable:
gen.filter.py
cubes = [x**3 for x in range(10)] odd_cubes1 = filter(lambda cube: cube % 2, cubes) odd_cubes2 = (cube for cube in cubes if cube % 2)
In the previous example, odd_cubes1
and odd_cubes2
are the same: they generate a sequence of odd cubes. Yet again, I prefer the generator syntax. This should be evident when things get a little more complicated:
gen.map.filter.py
N = 20 cubes1 = map( lambda n: (n, n**3), filter(lambda n: n % 3 == 0 or n % 5 == 0, range(N)) ) cubes2 = ( (n, n**3) for n in range(N) if n % 3 == 0 or n % 5 == 0)
The preceding code creates to generators cubes1
and cubes2
. They are exactly the same, and return 2-tuples (n, ) when n is a multiple of 3 or 5.
If you print the list (cubes1
), you get: [(0, 0), (3, 27), (5, 125), (6, 216), (9, 729), (10, 1000), (12, 1728), (15, 3375), (18, 5832)]
.
See how much better the generator expression reads? It may be debatable when things are very simple, but as soon as you start nesting functions a bit, like we did in this example, the superiority of the generator syntax is evident. Shorter, simpler, more elegant.
Now, let me ask you a question: what is the difference between the following lines of code?
sum.example.py
s1 = sum([n**2 for n in range(10**6)]) s2 = sum((n**2 for n in range(10**6))) s3 = sum(n**2 for n in range(10**6))
Strictly speaking, they all produce the same sum. The expressions to get s2
and s3
are exactly the same because the braces in s2
are redundant. They are both generator expressions inside the sum
function. The expression to get s1
is different though. Inside sum
, we find a list comprehension. This means that in order to calculate s1
, the sum
function has to call next
on a list, a million times.
Do you see where we're loosing time and memory? Before sum
can start calling next
on that list, the list needs to have been created, which is a waste of time and space. It's much better for sum
to call next
on a simple generator expression. There is no need to have all the numbers from range(10**6)
stored in a list.
So, watch out for extra parentheses when you write your expressions: sometimes it's easy to skip on these details, which makes our code much different. Don't believe me?
sum.example.2.py
s = sum([n**2 for n in range(10**8)]) # this is killed # s = sum(n**2 for n in range(10**8)) # this succeeds print(s)
Try running the preceding example. If I run the first line, this is what I get:
$ python sum.example.2.py Killed
On the other hand, if I comment out the first line, and uncomment the second one, this is the result:
$ python sum.example.2.py 333333328333333350000000
Sweet generator expressions. The difference between the two lines is that in the first one, a list with the squares of the first hundred million numbers must be made before being able to sum them up. That list is huge, and we run out of memory (at least, my box did, if yours doesn't try a bigger number), therefore Python kills the process for us. Sad face.
But when we remove the square brackets, we don't make a list any more. The sum function receives 0, 1, 4, 9, and so on until the last one, and sums them up. No problems, happy face.
Some performance considerations
So, we've seen that we have many different ways to achieve the same result. We can use any combination of map
, zip
, filter
, or choose to go with a comprehension, or maybe choose to use a generator, either function or expression. We may even decide to go with for
loops: when the logic to apply to each running parameter isn't simple, they may be the best option.
Other than readability concerns though, let's talk about performances. When it comes to performances, usually there are two factors which play a major role: space and time.
Space means the size of the memory that a data structure is going to take up. The best way to choose is to ask yourself if you really need a list (or tuple) or if a simple generator function would work as well. If the answer is yes, go with the generator, it'll save a lot of space. Same goes with functions: if you don't actually need them to return a list or tuple, then you can transform them in generator functions as well.
Sometimes, you will have to use lists (or tuples), for example there are algorithms that scan sequences using multiple pointers or maybe they run over the sequence more than once. A generator function (or expression) can be iterated over only once and then it's exhausted, so in these situations, it wouldn't be the right choice.
Time is a bit harder than space because it depends on more variables and therefore it isn't possible to state that X is faster than Y with absolute certainty for all cases. However, based on tests run on Python today, we can say that map
calls can be twice as fast as equivalent for
loops, and list comprehensions can be (always generally speaking) even faster than equivalent map
calls.
In order to fully appreciate the reason behind these statements, we need to understand how Python works, and this is a bit outside the scope of this book, for it's too technical in detail. Let's just say that map
and list
comprehensions run at C language speed within the interpreter, while a Python for
loop is run as Python bytecode within the Python Virtual Machine, which is often much slower.
Note
There are several different implementations of Python. The original one, and still the most common one, is the one written in C. C is one of the most powerful and popular programming languages still used today.
These claims I made come from books and articles that you can find on the Web, but how about we do a small exercise and try to find out for ourselves? I will write a small piece of code that collects the results of divmod(a, b)
for a certain set of integer pairs (a, b)
. I will use the time
function from the time
module to calculate the elapsed time of the operations that I will perform. Let's go!
performances.py
from time import time mx = 5500 # this is the max I could reach with my computer... t = time() # start time for the for loop dmloop = [] for a in range(1, mx): for b in range(a, mx): dmloop.append(divmod(a, b)) print('for loop: {:.4f} s'.format(time() - t)) # elapsed time t = time() # start time for the list comprehension dmlist = [ divmod(a, b) for a in range(1, mx) for b in range(a, mx)] print('list comprehension: {:.4f} s'.format(time() - t)) t = time() # start time for the generator expression dmgen = list( divmod(a, b) for a in range(1, mx) for b in range(a, mx)) print('generator expression: {:.4f} s'.format(time() - t)) # verify correctness of results and number of items in each list print(dmloop == dmlist == dmgen, len(dmloop))
As you can see, we're creating three lists: dmloop
, dmlist
, dmgen
(divmod
-for
loop, divmod
-list
comprehension, divmod
-generator expression). We start with the slowest option, the for
loops. Then we have a list
comprehension, and finally a generator expression. Let's see the output:
$ python performances.py for loop: 4.3433 s list comprehension: 2.7238 s generator expression: 3.1380 s True 15122250
The list
comprehension runs in 63% of the time taken by the for
loop. That's impressive. The generator expression came quite close to that, with a good 72%. The reason the generator expression is slower is that we need to feed it to the list()
constructor and this has a little bit more overhead compared to a sheer list comprehension.
I would never go with a generator expression in a similar case though, there is no point if at the end we want a list. I would just use a list comprehension, and the result of the previous example proves me right. On the other hand, if I just had to do those divmod
calculations without retaining the results, then a generator expression would be the way to go because in such a situation a list comprehension would unnecessarily consume a lot of space.
So, to recap: generators are very fast and allow you to save on space. List comprehensions are in general even faster, but don't save on space. Pure Python for
loops are the slowest option. Let's see a similar example that compares a for
loop and a map
call:
performances.map.py
from time import time mx = 2 * 10 ** 7 t = time() absloop = [] for n in range(mx): absloop.append(abs(n)) print('for loop: {:.4f} s'.format(time() - t)) t = time() abslist = [abs(n) for n in range(mx)] print('list comprehension: {:.4f} s'.format(time() - t)) t = time() absmap = list(map(abs, range(mx))) print('map: {:.4f} s'.format(time() - t)) print(absloop == abslist == absmap)
This code is conceptually very similar to the previous example. The only thing that has changed is that we're applying the abs
function instead of the divmod
one, and we have only one loop instead of two nested ones. Execution gives the following result:
$ python performances.map.py for loop: 3.1283 s list comprehension: 1.3966 s map: 1.2319 s True
And map
wins the race! As I told you before, giving a statement of what is faster than what is very tricky. In this case, the map
call is faster than the list comprehension.
Apart from the case by case little differences though, it's quite clear that the for
loop option is the slowest one, so let's see what are the reasons we still want to use it.
Don't overdo comprehensions and generators
We've seen how powerful list comprehensions and generator expressions can be. And they are, don't get me wrong, but the feeling that I have when I deal with them is that their complexity grows exponentially. The more you try to do within a single comprehension or a generator expression, the harder it becomes to read, understand, and therefore to maintain or change.
Open a Python console and type in import this
, let's read the Zen of Python again, in particular, there are a few lines that I think are very important to keep in mind:
>>> import this The Zen of Python, by Tim Peters Beautiful is better than ugly. Explicit is better than implicit. # Simple is better than complex. # Complex is better than complicated. Flat is better than nested. Sparse is better than dense. Readability counts. # Special cases aren't special enough to break the rules. Although practicality beats purity. Errors should never pass silently. Unless explicitly silenced. In the face of ambiguity, refuse the temptation to guess. There should be one-- and preferably only one --obvious way to do it. Although that way may not be obvious at first unless you're Dutch. Now is better than never. Although never is often better than *right* now. If the implementation is hard to explain, it's a bad idea. # If the implementation is easy to explain, it may be a good idea. Namespaces are one honking great idea -- let's do more of those!
I have put a comment sign on the right of the main focus points here. Comprehensions and generator expressions become hard to read, more implicit than explicit, complex, and they can be hard to explain. Sometimes you have to break them apart using the inside-out technique, to understand why they produce the result they produce.
To give you an example, let's talk a bit more about Pythagorean triples. Just to remind you, a Pythagorean triple is a tuple of positive integers (a, b, c) such that .
We saw earlier in this chapter how to calculate them, but we did it in a very inefficient way because we were scanning all pairs of numbers below a certain threshold, calculating the hypotenuse, and filtering out those that were not producing a triple.
A better way to get a list of Pythagorean triples is to directly generate them. There are many different formulas to do this and we'll use one of them: the Euclidean formula.
This formula says that any triple (a, b, c), where , b = 2mn, , with m and n positive integers such that m > n, is a Pythagorean triple. For example, when m = 2 and n = 1, we find the smallest triple: (3, 4, 5).
There is one catch though: consider the triple (6, 8, 10), that is just like (3, 4, 5) with all the numbers multiplied by 2. This triple is definitely Pythagorean, since , but we can derive it from (3, 4, 5) simply by multiplying each of its elements by 2. Same goes for (9, 12, 15), (12, 16, 20), and in general for all the triples that we can write as (3k, 4k, 5k), with k being a positive integer greater than 1.
A triple that cannot be obtained by multiplying the elements of another one by some factor k, is called primitive. Another way of stating this is: if the three elements of a triple are coprime, then the triple is primitive. Two numbers are coprime when they don't share any prime factor amongst their divisors, that is, their greatest common divisor (GCD) is 1. For example, 3 and 5 are coprime, while 3 and 6 are not, because they are both divisible by 3.
So, the Euclidean formula tells us that if m and n are coprime, and m – n is odd, the triple they generate is primitive. In the following example, we will write a generator expression to calculate all the primitive Pythagorean triples whose hypotenuse (c) is less than or equal to some integer N. This means we want all triples for which . When n is 1, the formula looks like this: , which means we can approximate the calculation with an upper bound of .
So, to recap: m must be greater than n, they must also be coprime, and their difference m - n must be odd. Moreover, in order to avoid useless calculations we'll put the upper bound for m at floor(sqrt(N)) + 1.
Note
The function floor
for a real number x gives the maximum integer n such that n < x, for example, floor(3.8) = 3, floor(13.1) = 13. Taking the floor(sqrt(N)) + 1 means taking the integer part of the square root of N and adding a minimal margin just to make sure we don't miss out any number.
Let's put all of this into code, step by step. Let's start by writing a simple gcd
function that uses Euclid's algorithm:
functions.py
def gcd(a, b): """Calculate the Greatest Common Divisor of (a, b). """ while b != 0: a, b = b, a % b return a
The explanation of Euclid's algorithm is available on the Web, so I won't spend any time here talking about it; we need to concentrate on the generator expression. The next step is to use the knowledge we gathered before to generate a list of primitive Pythagorean triples:
pythagorean.triple.generation.py
from functions import gcd N = 50 triples = sorted( # 1 ((a, b, c) for a, b, c in ( # 2 ((m**2 - n**2), (2 * m * n), (m**2 + n**2)) # 3 for m in range(1, int(N**.5) + 1) # 4 for n in range(1, m) # 5 if (m - n) % 2 and gcd(m, n) == 1 # 6 ) if c <= N), key=lambda *triple: sum(*triple) # 7 ) print(triples)
There you go. It's not easy to read, so let's go through it line by line. At #3
, we start a generator expression that is creating triples. You can see from #4
and #5
that we're looping on m
in [1, M] with M being the integer part of sqrt(N), plus 1. On the other hand, n
loops within [1, m), to respect the m > n rule. Worth noting how I calculated sqrt(N), that is, N**.5
, which is just another way to do it that I wanted to show you.
At #6
, you can see the filtering conditions to make the triples primitive: (m - n) % 2
evaluates to True
when (m - n)
is odd, and gcd(m, n) == 1
means m
and n
are coprime. With these in place, we know the triples will be primitive. This takes care of the innermost generator expression. The outermost one starts at #2
, and finishes at #7
. We take the triples (a, b, c) in (...innermost generator...) such that c <= N
. This is necessary because is the lowest upper bound that we can apply, but it doesn't guarantee that c will actually be less than or equal to N.
Finally, at #1
we apply sorting, to present the list in order. At #7
, after the outermost generator expression is closed, you can see that we specify the sorting key to be the sum a + b + c. This is just my personal preference, there is no mathematical reason behind it.
So, what do you think? Was it straightforward to read? I don't think so. And believe me, this is still a simple example; I have seen expressions way more complicated than this one.
Unfortunately some programmers think that writing code like this is cool, that it's some sort of demonstration of their superior intellectual powers, of their ability to quickly read and digest intricate code.
Within a professional environment though, I find myself having much more respect for those who write efficient, clean code, and manage to keep ego out the door. Conversely, those who don't, will produce lines at which you will stare for a long time while swearing in three languages (at least this is what I do).
Now, let's see if we can rewrite this code into something easier to read:
pythagorean.triple.generation.for.py
from functions import gcd def gen_triples(N): for m in range(1, int(N**.5) + 1): # 1 for n in range(1, m): # 2 if (m - n) % 2 and gcd(m, n) == 1: # 3 c = m**2 + n**2 # 4 if c <= N: # 5 a = m**2 - n**2 # 6 b = 2 * m * n # 7 yield (a, b, c) # 8 triples = sorted( gen_triples(50), key=lambda *triple: sum(*triple)) # 9 print(triples)
I feel so much better already. Let's go through this code as well, line by line. You'll see how easier it is to understand.
We start looping at #1
and #2
, in exactly the same way we were looping in the previous example. On line #3
, we have the filtering for primitive triples. On line #4
, we deviate a bit from what we were doing before: we calculate c
, and on line #5
, we filter on c
being less than or equal to N
. Only when c
satisfies that condition, we calculate a
and b
, and yield the resulting tuple. It's always good to delay all calculations for as much as possible so that we don't waste time, in case eventually we have to discard those results.
On the last line, before printing the result, we apply sorting with the same key we were using in the generator expression example.
I hope you agree, this example is easier to understand. And I promise you, if you have to modify the code one day, you'll find that modifying this one is easy, while to modify the other version will take much longer (and it will be more error prone).
Both examples, when run, print the following:
$ python pythagorean.triple.generation.py [(3, 4, 5), (5, 12, 13), (15, 8, 17), (7, 24, 25), (21, 20, 29), (35, 12, 37), (9, 40, 41)]
The moral of the story is, try and use comprehensions and generator expressions as much as you can, but if the code starts to be complicated to modify or to read, you may want to refactor into something more readable. There is nothing wrong with this.
Name localization
Now that we are familiar with all types of comprehensions and generator expression, let's talk about name localization within them. Python 3.* localizes loop variables in all four forms of comprehensions: list
, dict
, set
, and generator expressions. This behavior is therefore different from that of the for
loop. Let's see a simple example to show all the cases:
scopes.py
A = 100 ex1 = [A for A in range(5)] print(A) # prints: 100 ex2 = list(A for A in range(5)) print(A) # prints: 100 ex3 = dict((A, 2 * A) for A in range(5)) print(A) # prints: 100 ex4 = set(A for A in range(5)) print(A) # prints: 100 s = 0 for A in range(5): s += A print(A) # prints: 4
In the preceding code, we declare a global name A = 100
, and then we exercise the four comprehensions: list, generator expression, dictionary, and set. None of them alter the global name A
. Conversely, you can see at the end that the for
loop modifies it. The last print statement prints 4.
Let's see what happens if A
wasn't there:
scopes.noglobal.py
ex1 = [A for A in range(5)] print(A) # breaks: NameError: name 'A' is not defined
The preceding code would work the same with any of the four types of comprehensions. After we run the first line, A
is not defined in the global namespace.
Once again, the for
loop behaves differently:
scopes.for.py
s = 0 for A in range(5): s += A print(A) # prints: 4 print(globals())
The preceding code shows that after a for
loop, if the loop variable wasn't defined before it, we can find it in the global frame. To make sure of it, let's take a peek at it by calling the globals()
built-in function:
$ python scopes.for.py 4 {'__spec__': None, '__name__': '__main__', 's': 10, 'A': 4, '__doc__': None, '__cached__': None, '__package__': None, '__file__': 'scopes.for.py', '__loader__': <_frozen_importlib.SourceFileLoader object at 0x7f05a5a183c8>, '__builtins__': <module 'builtins' (built-in)>}
Together with a lot of other boilerplate stuff, we can spot 'A': 4
.
Generation behavior in built-ins
Amongst the built-in types, the generation behavior is now quite common. This is a major difference between Python 2 and Python 3. A lot of functions such as map
, zip
, and filter
have been transformed so that they return objects that behave like iterables. The idea behind this change is that if you need to make a list of those results you can always wrap the call in a list()
class, and you're done. On the other hand, if you just need to iterate and want to keep the impact on memory as light as possible, you can use those functions safely.
Another notable example is the range
function. In Python 2 it returns a list, and there is another function called xrange
that returns an object that you can iterate on, which generates the numbers on the fly. In Python 3 this function has gone, and range
now behaves like it.
But this concept in general is now quite widespread. You can find it in the open()
function, which is used to operate on file objects (we'll see it in one of the next chapters), but also in enumerate
, in the dictionary keys
, values
, and items
methods, and several other places.
It all makes sense: Python's aim is to try and reduce the memory footprint by avoiding wasting space wherever is possible, especially in those functions and methods that are used extensively in most situations.
Do you remember at the beginning of this chapter? I said that it makes more sense to optimize the performances of code that has to deal with a lot of objects, rather than shaving off a few milliseconds from a function that we call twice a day.
One last example
Before we part from this chapter, I'll show you a simple problem that I submitted to candidates for a Python developer role in a company I used to work for.
The problem is the following: given the sequence 0 1 1 2 3 5 8 13 21 ...
write a function that would return the terms of this sequence up to some limit N
.
If you haven't recognized it, that is the Fibonacci sequence, which is defined as F(0) = 0, F(1) = 1 and, for any n > 1, F(n) = F(n-1) + F(n-2). This sequence is excellent to test knowledge about recursion, memoization techniques and other technical details, but in this case it was a good opportunity to check whether the candidate knew about generators (and too many so called Python coders didn't, when I was interviewing them).
Let's start from a rudimentary version of a function, and then improve on it:
fibonacci.first.py
def fibonacci(N):
"""Return all fibonacci numbers up to N. """
result = [0]
next_n = 1
while next_n <= N:
result.append(next_n)
next_n = sum(result[-2:])
return result
print(fibonacci(0)) # [0]
print(fibonacci(1)) # [0, 1, 1]
print(fibonacci(50)) # [0, 1, 1, 2, 3, 5, 8, 13, 21, 34]
From the top: we set up the result
list to a starting value of [0]
. Then we start the iteration from the next element (next_n
), which is 1
. While the next element is not greater than N
, we keep appending it to the list and calculating the next. We calculate the next element by taking a slice of the last two elements in the result
list and passing it to the sum
function. Add some print
statements here and there if this is not clear to you, but by now I would expect it not to be an issue.
When the condition of the while
loop evaluates to False
, we exit the loop and return result
. You can see the result of those print
statements in the comments next to each of them.
At this point, I would ask the candidate the following question: "What if I just wanted to iterate over those numbers?" A good candidate would then change the code like the next listing (an excellent candidate would have started with it!):
fibonacci.second.py
def fibonacci(N): """Return all fibonacci numbers up to N. """ yield 0 if N == 0: return a = 0 b = 1 while b <= N: yield b a, b = b, a + b print(list(fibonacci(0))) # [0] print(list(fibonacci(1))) # [0, 1, 1] print(list(fibonacci(50))) # [0, 1, 1, 2, 3, 5, 8, 13, 21, 34]
This is actually one of the solutions I was given. I don't know why I kept it, but I'm glad I did so I can show it to you. Now, the fibonacci
function is a generator function. First we yield 0
, then if N
is 0
we return (this will cause a StopIteration
exception to be raised). If that's not the case, we start iterating, yielding b
at every loop cycle, and then updating a
and b
. All we need in order to be able to produce the next element of the sequence is the past two: a
and b
, respectively.
This code is much better, has a lighter memory footprint and all we have to do to get a list of Fibonacci numbers is to wrap the call with list()
, as usual.
But what about elegance? I cannot leave the code like that. It was decent for an interview, where the focus is more on functionality than elegance, but here I'd like to show you a nicer version:
fibonacci.elegant.py
def fibonacci(N): """Return all fibonacci numbers up to N. """ a, b = 0, 1 while a <= N: yield a a, b = b, a + b
Much better. The whole body of the function is four lines, five if you count the docstring. Notice how in this case using tuple assignment (a, b = 0, 1
and a, b = b, a + b
) helps in making the code shorter, and more readable. It's one of the features of Python I like a lot.
Summary
In this chapter, we explored the concept of iteration and generation a bit more deeply. We saw the map
, zip
and filter
functions quite in detail, and how to use them as an alternative to a regular for
loop approach.
Then we saw the concept of comprehensions, for lists, dictionaries, and sets. We saw their syntax and how to use them as an alternative to both the classic for
loop approach and also to the use of map
, zip
, and filter
functions.
Finally, we talked about the concept of generation, in two forms: generator functions and expressions. We learned how to save time and space by using generation techniques and saw how they can make possible what wouldn't normally be if we used a conventional approach based on lists.
We talked about performances, and saw that for
loops are last in terms of speed, but they provide the best readability and flexibility to change. On the other hand, functions such as map
and filter
can be much faster, and comprehensions may be even better.
The complexity of the code written using these techniques grows exponentially so, in order to favor readability and ease of maintainability, we still need to use the classic for
loop approach at times. Another difference is in the name localization, where the for
loop behaves differently from all other types of comprehensions.
The next chapter will be all about objects and classes. Structurally similar to this one, in that we won't explore many different subjects, rather, just a few of them, but we'll try to dive a little bit more deeply.
Make sure you understand well the concepts of this chapter before jumping to the next one. We're building a wall brick by brick, and if the foundation is not solid, we won't get very far.