Skip to content

Latest commit

 

History

History
1117 lines (800 loc) · 63.4 KB

File metadata and controls

1117 lines (800 loc) · 63.4 KB

五、节省时间和内存

"It's not the daily increase but daily decrease. Hack away at the unessential." – Bruce Lee

我喜欢李小龙的这句话。他真是个聪明人!特别是,第二部分,对不必要的进行了破解,这对我来说是什么让计算机程序变得优雅。毕竟,如果有更好的方式来做事,这样我们就不会浪费时间和记忆,为什么不呢?

有时,没有将代码提升到最大限度是有正当理由的:例如,有时为了实现微不足道的改进,我们不得不牺牲可读性或可维护性。当我们可以在 1.05 秒内用可读、干净的代码提供网页时,用不可读、复杂的代码在 1 秒内提供网页有意义吗?不,这毫无意义。

另一方面,有时尝试从一个函数中减去一毫秒是完全合理的,特别是当该函数要被调用数千次时。您在那里保存的每一毫秒意味着每数千个调用保存一秒钟,这对您的应用程序可能有意义。

鉴于这些考虑,本章的重点将不是提供工具,让您“无论如何”将代码推向性能和优化的绝对极限,而是让您能够编写高效、优雅、可读性好、运行速度快且不会明显浪费资源的代码。

在本章中,我们将介绍以下内容:

  • map、zip 和 filter 函数
  • 理解
  • 发电机

我将进行一些测量和比较,并谨慎地得出一些结论。请记住,在具有不同设置或不同操作系统的不同盒子上,结果可能会有所不同。请看下面的代码:

# squares.py
def square1(n):
    return n ** 2  # squaring through the power operator

def square2(n):
    return n * n  # squaring through multiplication

两个函数都返回n的平方,但哪个更快?从我在他们身上运行的一个简单的基准测试来看,第二个看起来稍微快一点。如果你仔细想想,这是有道理的:计算一个数字的幂涉及到乘法,因此,无论你使用什么算法来执行幂运算,它都不太可能打败简单的乘法,比如square2中的乘法。

我们关心这个结果吗?在大多数情况下,不会。如果你正在编写一个电子商务网站的代码,很可能你甚至不需要将一个数字提高到二次方,如果你这样做,很可能是一个零星的操作。您无需担心在调用几次的函数上节省几分之一微秒的时间。

那么,优化何时变得重要?一种非常常见的情况是,您必须处理大量数据。如果你在一百万个customer对象上应用相同的函数,那么你希望你的函数被调整到最佳状态。在一个名为一百万次的函数上获得 1/10 秒可以节省 100000 秒,也就是 27.7 小时。那不一样,对吧?所以,让我们关注集合,看看 Python 为您提供了哪些工具来高效、优雅地处理它们。

Many of the concepts we will see in this chapter are based on those of the 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 Chapter 6, OOP, Decorators, and Iterators.

由于我们将在本章中探讨的对象的性质,我经常被迫将代码包装在list构造函数中。这是因为将迭代器/生成器传递给list(...)会耗尽它,并将所有生成的项放入一个新创建的列表中,我可以轻松打印该列表以显示其内容。这种技术会影响可读性,所以让我为列表引入一个别名:

# alias.py
>>> range(7)
range(0, 7)
>>> list(range(7))  # put all elements in a list to view them
[0, 1, 2, 3, 4, 5, 6]
>>> _ = list  # create an "alias" to list
>>> _(range(7))  # same as list(range(7))
[0, 1, 2, 3, 4, 5, 6]

在我强调的三个部分中,第一个是我们需要执行的调用,以显示range(7)将生成什么,第二个是我创建别名列表的时刻(我选择了希望不显眼的下划线),第三个是等效调用,当我使用别名而不是列表时。

Hopefully readability will benefit from this, and please keep in mind that I will assume this alias to have been defined for all the code in this chapter.

map、zip 和 filter 函数

我们将首先回顾mapfilterzip,它们是处理集合时可以使用的主要内置函数,然后我们将学习如何使用两个非常重要的结构来实现相同的结果:理解生成器。系好安全带!

地图

根据官方 Python 文档:

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.

我们将在本章后面解释屈服的概念。现在,让我们将其转换为代码,我们将使用一个lambda函数,该函数接受数量可变的位置参数,并将它们作为元组返回:

# map.example.py
>>> map(lambda *a: a, range(3))  # 1 iterable
<map object at 0x10acf8f98>  # Not useful! Let's use alias
>>> _(map(lambda *a: a, range(3)))  # 1 iterable
[(0,), (1,), (2,)]
>>> _(map(lambda *a: a, range(3), 'abc'))  # 2 iterables
[(0, 'a'), (1, 'b'), (2, 'c')]
>>> _(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
>>> _(map(lambda *a: a, (), 'abc'))  # empty tuple is shortest
[]
>>> _(map(lambda *a: a, (1, 2), 'abc'))  # (1, 2) shortest
[(1, 'a'), (2, 'b')]
>>> _(map(lambda *a: a, (1, 2, 3, 4), 'abc'))  # 'abc' shortest
[(1, 'a'), (2, 'b'), (3, 'c')]

在前面的代码中,您可以看到为什么我们必须在list(...)中包装调用(在本例中,它的别名为_)。如果没有它,我会得到一个map对象的字符串表示,它在这个上下文中不是很有用,是吗?

您还可以注意到每个 iterable 的元素是如何应用于函数的;首先是每个 iterable 的第一个元素,然后是每个 iterable 的第二个元素,依此类推。还要注意,map在我们称之为它的最短的 iterables 耗尽时停止。这其实是一种很好的行为,;它不会强迫我们将所有的 iterables 调平到一个共同的长度,如果它们的长度不同,它也不会断裂。

当您必须对一个或多个对象集合应用相同的函数时,map非常有用。作为一个更有趣的例子,让我们看一下装饰排序不装饰习惯用法(也称为施瓦茨变换。当 Python 排序不提供键函数时,这是一种非常流行的技术,因此在今天很少使用,但这是一种很酷的技巧,偶尔也会派上用场。

让我们在下一个例子中看到它的一个变体:我们希望按照学生累积的学分总和降序排序,以便让最好的学生位于 0 位。我们编写一个函数来生成一个装饰过的对象,我们进行排序,然后取消装饰。每个学生都有三门(可能不同)科目的学分。在这种情况下,修饰一个对象意味着对其进行转换,或者向其添加额外的数据,或者将其放入另一个对象中,从而使我们能够按照我们想要的方式对原始对象进行排序。这项技术与 Python 装饰器无关,我们将在本书后面探讨。

排序后,我们还原装饰对象以从中获取原始对象。这被称为取消装饰:

# 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 = _(map(undecorate, students))

让我们从了解每个学生对象是什么开始。事实上,让我们打印第一个:

{'credits': {'history': 7, 'math': 9, 'physics': 6}, 'id': 0}

你可以看到这是一本有两个键的字典:idcreditscredits的值也是一个字典,其中有三个主题/年级键/值对。我相信您还记得我们在数据结构领域的访问,调用dict.values()返回一个类似于iterable的对象,只有值。因此,第一个学生的sum(student['credits'].values())相当于sum((9, 6, 7))

让我们打印调用第一个学生的结果:

>>> decorate(students[0])
(22, {'credits': {'history': 7, 'math': 9, 'physics': 6}, 'id': 0})

如果我们像这样装饰所有的学生,我们可以通过对元组列表进行排序来对他们的总学分进行排序。为了将装饰应用于学生的每个项目,我们称之为map(decorate, students)。然后我们对结果进行排序,然后以类似的方式取消装饰。如果您正确地阅读了前面的章节,那么理解这段代码应该不会太难。

运行整个代码后打印学生会产生:

$ 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}]

你可以看到,按照学生对象的顺序,他们确实是按照学分的总和排序的。

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.7/howto/sorting.html#the-old-way-using-decorate-sort-undecorate).

关于排序部分有一件事需要注意:如果两个或更多的学生共享相同的总数会怎样?排序算法将通过相互比较student对象对元组进行排序。这没有任何意义,在更复杂的情况下,可能会导致不可预测的结果,甚至错误。如果您想确保避免这个问题,一个简单的解决方案是创建一个三元组而不是两元组,第一个位置是积分之和,第二个位置是students列表中student对象的位置,第三个位置是student对象本身。这样,如果积分总和相同,元组将根据位置进行排序,位置总是不同的,因此足以解决任何一对元组之间的排序问题。

拉链

在前面的章节中我们已经介绍了zip,所以让我们正确地定义它,然后我想向您展示如何将其与map相结合。

根据 Python 文档:

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.

让我们看一个例子:

# zip.grades.py
>>> grades = [18, 23, 30, 27]
>>> avgs = [22, 21, 29, 24]
>>> _(zip(avgs, grades))
[(22, 18), (21, 23), (29, 30), (24, 27)]
>>> _(map(lambda *a: a, avgs, grades))  # equivalent to zip
[(22, 18), (21, 23), (29, 30), (24, 27)]

在前面的代码中,我们将每个学生上一次考试的平均成绩和分数压缩在一起。注意使用map(示例的最后两条指令)复制zip是多么容易。在这里,为了可视化结果,我们必须使用我们的_别名。

关于组合使用mapzip的一个简单示例可以是计算序列中元素最大值的一种方法,即每个序列的第一个元素的最大值,然后是第二个元素的最大值,依此类推:

# 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))
>>> _(maxs)
[6, 9, 2, 9, 7]

请注意,计算三个序列的最大值是多么容易。zip不是严格需要的,当然,我们可以使用map。有时候,当展示一个简单的例子时,很难理解为什么使用一种技术可能是好的或坏的。我们忘记了我们并不总是控制源代码,我们可能不得不使用第三方库,我们无法改变我们想要的方式。因此,使用不同的方法处理数据非常有用。

滤器

根据 Python 文档:

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 is None, the identity function is assumed, that is, all elements of iterable that are false are removed.

让我们看一个非常简单的例子:

# filter.py
>>> test = [2, 5, 8, 0, 0, 1, 0]
>>> _(filter(None, test))
[2, 5, 8, 1]
>>> _(filter(lambda x: x, test))  # equivalent to previous one
[2, 5, 8, 1]
>>> _(filter(lambda x: x > 4, test))  # keep only items > 4
[5, 8]

在前面的代码中,请注意对filter的第二次调用与第一次调用是如何等价的。如果我们传递一个接受一个参数并返回参数本身的函数,则只有那些为True的参数才会使函数返回True,因此此行为与传递None完全相同。模仿一些内置 Python 行为通常是一个很好的练习。当您成功时,可以说您完全理解 Python 在特定情况下的行为。

借助于mapzipfilter(以及 Python 标准库中的其他几个函数),我们可以非常有效地对序列进行按摩。但这些功能并不是实现这一目标的唯一途径。让我们看看 Python 最优秀的特性之一:理解。

理解

理解是一种简洁的表示法,既可以对元素集合执行某些操作,也可以选择满足某些条件的元素子集。它们是从函数式编程语言 Haskell(中借用的 https://www.haskell.org/ ),并与迭代器和生成器一起为 Python 提供功能性风格。

Python 为您提供了不同类型的理解:listdictset。现在我们将集中讨论第一个问题,然后很容易解释其他两个问题。

让我们从一个非常简单的例子开始。我想用前 10 个自然数的平方来计算一个列表。你会怎么做?有两种等效方法:

# squares.map.py
# If you code like this you are not a Python dev! ;)
>>> squares = []
>>> for n in range(10):
...     squares.append(n ** 2)
...
>>> 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))
>>> _(squares)
[0, 1, 4, 9, 16, 25, 36, 49, 64, 81]

前面的例子对您来说应该不是什么新鲜事。让我们看看如何通过list理解来达到同样的结果:

# squares.comprehension.py
>>> [n ** 2 for n in range(10)]
[0, 1, 4, 9, 16, 25, 36, 49, 64, 81]

就这么简单。这不是很优雅吗?基本上我们在方括号内放了一个for循环。现在让我们过滤掉奇数的方块。我将首先向您展示如何使用mapfilter,然后再次使用list理解:

# even.squares.py
# using map and filter
sq1 = list(
    map(lambda n: n ** 2, filter(lambda n: not 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

我认为现在可读性上的差异是显而易见的。list理解读起来好多了。这几乎是英语:如果n是偶数,请给我09之间的n的所有正方形(n ** 2)。

根据 Python 文档:

A list comprehension consists of brackets containing an expression followed by a for clause, then zero or more for or if clauses. The result will be a new list resulting from evaluating the expression in the context of the for and if clauses which follow it.

嵌套理解

让我们看一个嵌套循环的示例。在处理算法时,必须使用两个占位符在序列上迭代是非常常见的。第一个从左到右贯穿整个序列。第二个也是,但它从第一个开始,而不是 0。其概念是在不重复的情况下测试所有对。让我们看看经典的for循环等价物:

# pairs.for.loop.py
items = 'ABCD'
pairs = []

for a in range(len(items)):
    for b in range(a, len(items)):
        pairs.append((items[a], items[b]))

如果在末尾打印对,则会得到:

$ python pairs.for.loop.py
[('A', 'A'), ('A', 'B'), ('A', 'C'), ('A', 'D'), ('B', 'B'), ('B', 'C'), ('B', 'D'), ('C', 'C'), ('C', 'D'), ('D', 'D')]

所有具有相同字母的元组都是那些ba位于相同位置的元组。现在,让我们来看看如何在list理解中翻译这一点:

# pairs.list.comprehension.py
items = 'ABCD'
pairs = [(items[a], items[b])
    for a in range(len(items)) for b in range(a, len(items))]

这个版本只有两行长,达到了相同的效果。注意,在这个特殊的情况下,因为for循环b依赖于a,所以在理解中它必须遵循for循环a。如果你交换它们,你会得到一个名称错误。

过滤理解力

我们可以将过滤应用于理解。让我们先用filter来做。让我们找出所有短边小于 10 的毕达哥拉斯三元组。显然,我们不想对组合进行两次测试,因此我们将使用与上一个示例中类似的技巧:

# pythagorean.triple.py
from math import sqrt
# this will generate all possible pairs
mx = 10
triples = [(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
triples = list(
    filter(lambda triple: triple[2].is_integer(), triples))

print(triples)  # prints: [(3, 4, 5.0), (6, 8, 10.0)]

Pythagorean triple is a triple (abc) of integer numbers satisfying the equation a2 + b2 = c2.

在前面的代码中,我们生成了一个包含三元组triples的列表。每个元组包含两个整数(腿),以及毕达哥拉斯三角形的斜边,其腿是元组中的前两个数字。例如,当a3b4时,元组为(3, 4, 5.0),当a5b7时,元组为(5, 7, 8.602325267042627)

在完成所有的triples之后,我们需要过滤掉所有没有斜边的整数。为了做到这一点,我们根据float_number.is_integer()True进行过滤。这意味着在我之前展示的两个示例元组中,带有5.0斜边的元组将被保留,而带有8.602325267042627斜边的元组将被丢弃。

这很好,但我不喜欢三元组有两个整数和一个浮点数。它们应该都是整数,所以让我们使用map来解决这个问题:

# pythagorean.triple.int.py
from math import sqrt
mx = 10
triples = [(a, b, sqrt(a**2 + b**2))
    for a in range(1, mx) for b in range(a, mx)]
triples = filter(lambda triple: triple[2].is_integer(), triples)
# this will make the third number in the tuples integer
triples = list(
    map(lambda triple: triple[:2] + (int(triple[2]), ), triples))

print(triples)  # prints: [(3, 4, 5), (6, 8, 10)]

注意我们添加的步骤。我们取triples中的每个元素,然后将其切片,只取其中的前两个元素。然后,我们用一个元组连接切片,在元组中我们放置了我们不喜欢的浮点数的整数版本。好像要做很多工作,对吧?的确如此。让我们看看如何通过list理解来完成这一切:

# pythagorean.triple.comprehension.py
from math import sqrt
# this step is the same as before
mx = 10
triples = [(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
triples = [(a, b, int(c)) for a, b, c in triples if c.is_integer()]
print(triples)  # prints: [(3, 4, 5), (6, 8, 10)]

我知道。好多了,不是吗?它干净、可读、简短。换句话说,它很优雅。

I'm going quite fast here, as anticipated in the Summary of Chapter 4Functions, the Building Blocks of Code. 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?

听写理解

字典和set理解的工作原理与列表中的完全相同,只是语法上有一点不同。以下示例足以解释您需要了解的所有内容:

# dictionary.comprehensions.py
from string import ascii_lowercase
lettermap = dict((c, k) for k, c in enumerate(ascii_lowercase, 1))

如果您打印lettermap,您将看到以下内容(我省略了中间的结果,您得到了要点):

$ python dictionary.comprehensions.py
{'a': 1,
 'b': 2,
 ...
 'y': 25,
 'z': 26}

在前面的代码中发生的是,我们正在向dict构造函数提供理解(从技术上讲,是一个生成器表达式,我们稍后会看到)。我们告诉dict构造函数从理解中的每个元组中制作/对。我们使用enumerate枚举所有小写 ASCII 字母的序列,从1开始。小菜一碟。还有另一种方法可以做同样的事情,更接近于其他字典语法:

lettermap = {c: k for k, c in enumerate(ascii_lowercase, 1)} 

它做了完全相同的事情,使用了稍微不同的语法,突出了更多的键:值部分。

字典不允许在键中重复,如以下示例所示:

# dictionary.comprehensions.duplicates.py
word = 'Hello'
swaps = {c: c.swapcase() for c in word}
print(swaps)  # prints: {'H': 'h', 'e': 'E', 'l': 'L', 'o': 'O'}

我们创建一个字典,其中包含键、'Hello'字符串中的字母以及相同字母的值,但大小写交换。请注意,只有一对'l': 'L'。构造函数不会抱怨,它只是将副本重新分配到最新的值。让我们用另一个例子来说明这一点;让我们为每个键指定其在字符串中的位置:

# dictionary.comprehensions.positions.py
word = 'Hello'
positions = {c: k for k, c in enumerate(word)}
print(positions)  # prints: {'H': 0, 'e': 1, 'l': 3, 'o': 4}

请注意与字母'l': 3关联的值。'l': 2对不在那里;已被'l': 3覆盖。

集合理解

set理解与列表和字典的理解非常相似。Python 既允许使用set()构造函数,也允许使用显式{}语法。让我们看一个简单的例子:

# set.comprehensions.py
word = 'Hello'
letters1 = set(c for c in word)
letters2 = {c for c in word}
print(letters1)  # prints: {'H', 'o', 'e', 'l'}
print(letters1 == letters2)  # prints: True

请注意,对于set理解,与字典一样,不允许重复,因此结果集只有四个字母。另外,请注意,指定给letters1letters2的表达式会生成等价的集合。

用于创建letters2的语法与我们可以用于创建字典的语法非常相似。只有字典需要用列分隔的键和值,而集合不需要,您才能发现差异。

发电机

生成器是 Python 赠送给我们的非常强大的工具。正如我们前面所说,它们基于迭代的概念,并且允许将优雅与效率相结合的编码模式。

发电机有两种类型:

  • 生成器函数:这些函数与常规函数非常相似,但它们不是通过 return 语句返回结果,而是使用 yield,这允许它们在每次调用之间暂停并恢复状态
  • 生成器表达式:这些与我们在本章中看到的list理解非常相似,但它们不是返回一个列表,而是返回一个对象,该对象会逐个生成结果

生成函数

生成器函数在所有方面的行为都与正则函数类似,只是有一个区别。它们不是收集结果并立即返回,而是自动转换为迭代器,当您调用next时,每次生成一个结果。Python 会自动将生成器函数转换为自己的迭代器。

这都是非常理论化的,让我们弄清楚为什么这样一个机制如此强大,然后让我们看一个例子。

比如说我让你从 1 数到 1000000。你开始,在某个时刻我要求你停止。过了一段时间,我请你继续。此时,要正确恢复,您需要的最低信息是什么?嗯,你需要记住你最后打过的电话号码。如果我在 31415 之后阻止你,你只会继续 31416,以此类推。

关键是,你不需要记住你在 31415 之前说过的所有数字,也不需要把它们写在什么地方。嗯,你可能不知道,但你已经表现得像个发电机了!

请仔细查看以下代码:

# 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)))

两个print语句的结果将相同:[0, 1, 4, 9, 16, 25, 36, 49, 64, 81]。但这两种功能之间存在巨大差异。get_squares是一个经典函数,它收集列表中【0,n中的所有数字平方并返回。另一方面,get_squares_gen是一个生成器,其行为非常不同。每次解释器到达yield行时,其执行都会暂停。唯一的原因是print语句返回相同的结果是因为我们将get_squares_gen馈送给list构造函数,构造函数通过询问下一个元素直到StopIteration被激发,从而完全耗尽生成器。让我们详细了解一下:

# 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 0x10dd...>
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))

在前面的代码中,每次调用 generator 对象上的next时,我们要么启动它(首先是next),要么让它从最后一个暂停点(任何其他next)恢复。

第一次调用next时,我们得到0,它是0的平方,然后是1,然后是4,然后是9,由于for回路在此之后停止(n4,发电机自然结束。经典函数此时只返回None,但为了遵守迭代协议,生成器将引发StopIteration异常。

这解释了for循环是如何工作的。当你调用for k in range(n)时,for循环从range(n)中得到一个迭代器,并开始调用next,直到StopIteration被触发,这告诉for循环迭代已经结束。

将这种行为构建到 Python 的每个迭代方面会使生成器更加强大,因为一旦我们编写它们,我们就能够将它们插入到我们想要的任何迭代机制中。

此时,您可能会问自己为什么要使用生成器而不是常规函数。好吧,这一章的标题应该能给出答案。稍后我将讨论性能,所以现在让我们专注于另一个方面:有时生成器允许您执行一些简单列表无法实现的操作。例如,假设要分析序列的所有排列。如果序列的长度为N,则其排列的数量为N!。这意味着,如果序列长度为 10 个元素,则排列数为 3628800。但是一个由 20 个元素组成的序列将有 2432902008176640000 个排列。它们成倍增长。

现在假设您有一个经典函数,它试图计算所有排列,将它们放入列表中,然后返回给您。对于 10 个元素,可能需要几十秒,但是对于 20 个元素,根本不可能完成。

另一方面,生成函数将能够开始计算并返回第一个置换,然后是第二个置换,依此类推。当然你不会有时间去解析所有的代码,因为它们太多了,但至少你可以处理其中的一些代码。

还记得我们在for循环中讨论break语句的时候吗?当我们发现一个数字除以一个候选素数时,我们打破了循环,没有必要继续。

有时情况完全一样,只是需要迭代的数据量太大,以至于无法将所有数据都保存在列表的内存中。在这种情况下,发电机是无价的:它们使原本不可能的事情成为可能。

因此,为了节省内存(和时间),尽可能使用生成器功能。

还值得注意的是,您可以在生成器函数中使用 return 语句。它将产生一个要引发的StopIteration异常,从而有效地结束迭代。这是非常重要的。如果一个return语句实际上要使函数返回一些东西,它将破坏迭代协议。Python 的一致性防止了这种情况,并使我们在编码时非常容易。让我们看一个简单的例子:

# 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)

前面的代码生成几何级数的所有项,aaqaq2aq3。。。。当级数产生大于100000的项时,生成器停止(使用return语句)。运行代码会产生以下结果:

$ python gen.yield.return.py
2
10
50
250
1250
6250
31250

下一个学期应该是156250,太大了。

Speaking about StopIteration, as of Python 3.5, the way that exceptions are handled in generators has changed. To understand the implications of the change is probably asking too much of you at this point, so just know that you can read all about it in PEP 479 (https://legacy.python.org/dev/peps/pep-0479/).

超越下一个

在本章的开头,我告诉过您生成器对象是基于迭代协议的。我们将在第 6 章OOP、装饰器和迭代器中看到如何编写自定义迭代器/iterable 对象的完整示例。现在,我只想让你们了解next()是如何工作的。

当您调用next(generator)时,会发生的情况是您正在调用generator.__next__()方法。记住,方法只是一个属于对象的函数,Python 中的对象可以有特殊的方法。__next__()只是其中之一,它的目的是返回迭代的下一个元素,或者在迭代结束且没有更多元素可返回时引发StopIteration

If you recall, in Python, an object's special methods are also called magic methods, or dunder (from "double underscore") methods.

当我们编写生成器函数时,Python 会自动将其转换为与迭代器非常相似的对象,当我们调用next(generator)时,该调用会在generator.__next__()中转换。让我们重温上一个关于生成正方形的示例:

# 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

结果与前面的示例完全相同,只是这次没有使用next(squares)代理调用,而是直接调用squares.__next__()

生成器对象还有三种其他方法,允许我们控制它们的行为:sendthrowclosesend允许我们将值传回生成器对象,而throwclose分别允许我们在生成器内引发异常并将其关闭。它们的使用非常先进,我不会在这里详细介绍它们,但我想在send上花几句话,举一个简单的例子:

# 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

前面的迭代器创建一个将永远运行的生成器对象。你可以一直呼唤它,它永远不会停止。或者,你可以把它放在一个for循环中,例如for n in counter(): ...,它也将永远持续下去。但是如果你想在某个时候阻止它呢?一种解决方案是使用一个变量来控制while循环。像这样的事情:

# 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

这就行了。我们从stop = False开始,直到我们将其更改为True,发电机将继续运行,就像以前一样。但当我们将 stop 更改为True时,while循环将退出,下一次调用将引发StopIteration异常。这个把戏管用,但我不喜欢。我们依赖于一个外部变量,这可能会导致问题:如果另一个函数改变了stop,该怎么办?此外,代码是分散的。简而言之,这还不够好。

我们可以使用generator.send()使其更好。当我们调用generator.send()时,我们提供给send的值将传递给生成器,执行将继续,我们可以通过yield表达式获取它。当用文字解释时,这一切都非常复杂,所以让我们看一个例子:

# 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

执行上述代码会产生以下结果:

$ 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

我认为有必要逐行检查这段代码,就像我们在执行它一样,看看我们是否能理解到底发生了什么。

我们通过调用next#C来启动生成器执行。在发电机内,n设置为与start相同的值。进入while循环,执行停止(#A),并将n0返回给调用方。0打印在控制台上。

然后调用send#D),继续执行,将result设置为'Wow!'(仍然是#A),然后在控制台上打印其类型和值(#Bresult不是'Q',因此n增加1,执行返回while条件,即True,计算结果为True(这不难猜测,对吧?)。另一个循环循环开始,执行再次停止(#A),并且n1)返回给调用方。1打印在控制台上。

此时,我们调用next#E),再次恢复执行(#A),因为我们没有显式地向生成器发送任何内容,Python 的行为与不使用return语句的函数完全相同;yield n表达式(#A返回None。因此将result设置为None,其类型和值再次打印在控制台上(#B。执行继续,result不是'Q',所以n增加了1,我们再次开始另一个循环。再次停止执行(#A),并将n2)退回给调用方。2打印在控制台上。

现在是大结局:我们再次调用send#F,但这次我们传入'Q',因此当执行恢复时,result被设置为'Q'#A。在控制台(#B上打印其类型和值,if子句最后计算为True,并且while循环被break语句停止。生成器自然终止,这意味着引发StopIteration异常。您可以在控制台上打印的最后几行上看到它的回溯打印。

这一点一开始并不容易理解,所以如果你不清楚,不要气馁。您可以继续阅读,过一段时间后再回到这个示例。

使用send可以产生有趣的模式,值得注意的是send也可以用于启动生成器的执行(前提是您使用None来调用它)。

表达的结果

另一个有趣的结构是yield from表达式。此表达式允许您从子迭代器生成值。它的使用允许非常高级的模式,所以让我们来看一个非常快速的例子:

# 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)

前面的代码在控制台上打印数字4916(在单独的行上)。现在,我希望您能够自己理解它,但让我们快速回顾一下发生了什么。函数外部的for循环从print_squares(2, 5)获取一个迭代器,并对其调用next,直到迭代结束。每次调用生成器时,yield n ** 2上的执行都会暂停(随后恢复),返回当前n的平方。让我们看看如何从yield from表达式中转换此代码:

# 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)

这段代码产生相同的结果,但正如您所看到的,yield from实际上正在运行一个子迭代器(n ** 2 ...)yield from表达式将子迭代器生成的每个值返回给调用方。它更短,而且读起来更好。

生成器表达式

现在让我们讨论一下一次生成一个值的其他技术。

语法与list理解完全相同,只是不是用方括号括起来,而是用圆括号括起来。这被称为生成器表达式

一般来说,生成器表达式的行为类似于等价的list理解,但有一件非常重要的事情需要记住:生成器只允许一次迭代,然后它们将被耗尽。让我们看一个例子:

# 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 0x103fb5a98>
>>> type(cubes_gen)
<class 'generator'>
>>> _(cubes_gen)  # this will exhaust the generator
[0, 1, 8, 27, 64, 125, 216, 343, 512, 729]
>>> _(cubes_gen)  # nothing more to give
[]

查看创建生成器表达式并指定名称cubes_gen的行。您可以看到它是一个生成器对象。为了查看它的元素,我们可以使用一个for循环,一组对next的手动调用,或者简单地将它提供给list构造函数,这就是我所做的(记住我使用_作为别名)。

请注意,一旦生成器耗尽,就无法再次从中恢复相同的元素。如果我们想重新使用它,我们需要重新创建它。

在接下来的几个示例中,让我们看看如何使用生成器表达式重现mapfilter

# gen.map.py
def adder(*n):
    return sum(n)
s1 = sum(map(lambda *n: adder(*n), range(100), range(1, 101)))
s2 = sum(adder(*n) for n in zip(range(100), range(1, 101)))

在前面的示例中,s1s2完全相同:它们是adder(0, 1), adder(1, 2), adder(2, 3)的和,依此类推,翻译成sum(1, 3, 5, ...)。语法不同,但我发现生成器表达式更具可读性:

# 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)

在前面的示例中,odd_cubes1odd_cubes2是相同的:它们生成一系列奇数立方体。同样,我更喜欢生成器语法。当事情变得更复杂时,这一点应该是显而易见的:

# 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)

前面的代码创建了两个生成器,cubes1cubes2。它们完全相同,当n35的倍数时,返回两个元组(n,n3

如果您打印列表(cubes1,您将得到:[(0, 0), (3, 27), (5, 125), (6, 216), (9, 729), (10, 1000), (12, 1728), (15, 3375), (18, 5832)]

查看生成器表达式的读取效果有多好?当事情非常简单时,这可能是有争议的,但只要您开始嵌套函数一点,就像我们在本例中所做的那样,生成器语法的优越性就显而易见了。它更短、更简单、更优雅。

现在,让我问你一个问题,以下代码行之间的区别是什么:

# 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))

严格地说,它们产生的总数是一样的。因为s2中的括号是多余的,所以得到s2s3的表达式是完全相同的。它们都是sum函数中的生成器表达式。然而,得到s1的表达式是不同的。在sum中,我们找到了list理解。这意味着为了计算s1sum函数必须对列表调用next一百万次。

你看到我们失去时间和记忆的地方了吗?在sum可以开始调用该列表上的next之前,该列表需要已经创建,这是浪费时间和空间的。sum调用next一个简单的生成器表达式要好得多。不需要将range(10**6)中的所有数字存储在列表中。

所以,在编写表达式时要注意额外的括号:有时很容易跳过这些细节,这使得我们的代码非常不同。如果您不相信我,请查看以下代码:

# 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)  # prints: 333333328333333350000000

尝试运行前面的示例。如果我使用 8 GB RAM 在旧 Linux 设备上运行第一行,我会得到以下结果:

$ python sum.example.2.py
Killed  

另一方面,如果我注释掉第一行,并取消注释第二行,则结果如下:

$ python sum.example.2.py
333333328333333350000000  

甜生成器表达式。这两行之间的区别在于,在第一行中,必须先列出前一亿个数字的平方,然后才能将它们相加。这个列表是巨大的,我们的内存用完了(至少,我的盒子用完了,如果你不尝试更大的数字的话),因此 Python 为我们扼杀了这个进程。悲伤的脸。

但是当我们去掉方括号时,我们就没有了列表。sum函数接收0149等,直到最后一个,并对其进行汇总。没问题,笑脸。

一些性能注意事项

因此,我们已经看到,我们有许多不同的方法来实现相同的结果。我们可以使用mapzipfilter的任意组合,或者选择理解,或者选择使用生成器,函数或表达式。我们甚至可能决定使用for循环;当应用于每个运行参数的逻辑不简单时,它们可能是最佳选择。

除了可读性问题之外,让我们谈谈性能。说到性能,通常有两个因素起主要作用:空间时间

空间是指数据结构将占用的内存大小。最好的选择方法是问问自己是否真的需要一个列表(或元组),或者一个简单的生成器函数是否也可以工作。如果答案是肯定的,那就用发电机吧,它会节省很多空间。功能也是如此;如果您实际上不需要它们返回列表或元组,那么您也可以将它们转换为生成器函数。

有时,您必须使用列表(或元组),例如,有一些算法使用多个指针扫描序列,或者可能会在序列上运行多次。生成器函数(或表达式)只能迭代一次,然后就会耗尽,因此在这些情况下,它不是正确的选择。

时间比空间难一点,因为它依赖于更多的变量,因此不可能在所有情况下都绝对肯定地说X 比 Y快。然而,根据今天在 Python 上运行的测试,我们可以说,平均而言,map表现出类似于list理解和生成器表达式的性能,而for循环始终较慢。

为了充分理解这些语句背后的推理,我们需要了解 Python 是如何工作的,这有点超出了本书的范围,因为它在细节上过于技术化。让我们假设maplist理解在解释器中以 C 语言速度运行,而 Pythonfor循环在 Python 虚拟机中作为 Python 字节码运行,这通常要慢得多。

There are several different implementations of Python. The original one, and still the most common one, is CPython (https://github.com/python/cpython), which is written in C. C is one of the most powerful and popular programming languages still used today.

我们做个小练习,看看我的说法是否准确,怎么样?我将编写一小段代码,收集某一组整数对的divmod(a, b)结果(a, b)。我将使用time模块中的time功能来计算我将执行的操作的运行时间:

# performances.py
from time import time
mx = 5000

t = time()  # start time for the for loop
floop = []
for a in range(1, mx):
    for b in range(a, mx):
        floop.append(divmod(a, b))
print('for loop: {:.4f} s'.format(time() - t))  # elapsed time

t = time()  # start time for the list comprehension
compr = [
    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
gener = list(
    divmod(a, b) for a in range(1, mx) for b in range(a, mx))
print('generator expression: {:.4f} s'.format(time() - t))

如您所见,我们正在创建三个列表:floopcomprgener。运行代码会产生以下结果:

$ python performances.py
for loop: 4.4814 s
list comprehension: 3.0210 s
generator expression: 3.4334 s

list理解占for循环所用时间的 67%。真令人印象深刻。生成器表达式与此非常接近,具有良好的~77%。生成器表达式较慢的原因是我们需要将其提供给list()构造函数,与纯粹的list理解相比,这有一点开销。如果我不必保留这些计算结果,那么生成器可能是更合适的选择。

一个有趣的结果是注意到,在for循环体中,我们将数据附加到列表中。这意味着 Python 会在幕后不时地调整其大小,为要追加的项分配空间。我猜想创建一个零列表,并简单地用结果填充它,可能会加快for循环,但我错了。自己检查一下,只需要预先分配mx * (mx - 1) // 2元素。

让我们看一个比较for循环和map调用的类似示例:

# 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))

此代码在概念上与前面的示例非常相似。唯一改变的是我们正在应用abs函数而不是divmod函数,并且我们只有一个循环而不是两个嵌套的循环。执行会产生以下结果:

$ python performances.map.py
for loop: 3.8948 s
list comprehension: 1.8594 s
map: 1.1548 s

map赢得比赛:list理解力的 62%和for循环力的 30%。对这些结果有点怀疑,因为根据不同的因素,比如操作系统和 Python 版本,情况可能会有所不同。但总的来说,我认为可以肯定地说,当涉及到性能编码时,这些结果已经足够好了。

除了个别情况的细微差异外,for循环选项显然是最慢的选项,所以让我们看看为什么我们仍然要使用它。

不要过度理解和理解

我们已经看到了list理解和生成器表达式是多么强大。它们是,别误会,但我在处理它们时的感觉是它们的复杂性呈指数级增长。在单个理解或生成器表达式中,你尝试做得越多,它就越难阅读、理解,因此也就越难维护或更改。

如果您再次查看 Python 的 Zen,我认为在处理优化代码时,有几行代码值得记住:

>>> import this
...
Explicit is better than implicit.
Simple is better than complex.
...
Readability counts.
...
If the implementation is hard to explain, it's a bad idea.
...

理解和生成表达式比显式更含蓄,很难阅读和理解,也很难解释。有时,你必须使用由内而外的技术将它们分开,以了解发生了什么。

举个例子,让我们再多谈一点勾股三元组。提醒一下,毕达哥拉斯三元组是一组正整数(abc),这样a2+b2=c2

我们在过滤理解部分中看到了如何计算它们,但我们的计算效率非常低,因为我们扫描了低于某个阈值的所有数字对,计算斜边,并过滤掉那些没有产生三元组的数字。

获取毕达哥拉斯三元组列表的更好方法是直接生成它们。有很多不同的公式可以用来做这件事,我们将使用欧几里德公式

这个公式表示任何三元组(abc),其中a=m2-n2b=2mnc=m2+n2,与和一起正整数,m>n是勾股三元组。例如,当m=2n=1时,我们找到最小的三元组:(345)。

但有一个陷阱:考虑三重(6),(8),8,3,7,7,7,4,9,11,11,11)。这个三元组绝对是毕达哥拉斯的,因为62+82=102,但我们可以通过将其每个元素乘以2简单地从(345中推导出它。同样的情况也适用于(91215、(121620),一般来说,我们可以写为(3k4k5k)的所有三元组,k 为大于1的正整数。

不能通过将另一个元素的元素乘以某个因子k来获得的三元组称为基元。另一种说法是:如果三元组的三个元素是互质,那么三元组就是本原。当两个数的除数之间不共享任何素数因子时,它们是互质的,即它们的最大公约数GCD1。例如,3 和 5 是互质,而 3 和 6 不是互质,因为它们都可以被 3 整除。

因此,欧几里德公式告诉我们,如果mn是互质,并且m-n是奇数,那么它们生成的三元组就是本原。在下面的示例中,我们将编写一个生成器表达式来计算斜边(c)小于或等于某个整数N的所有原始毕达哥拉斯三元组。这意味着我们需要所有的三元组,m2+n2≤ N。当n1时,公式如下:m2≤ N-1,这意味着我们可以用m 的上限来近似计算≤ N1/2

所以,概括一下:m必须大于n,它们也必须是互质,它们的差m-n必须是奇数。此外,为了避免无用的计算,我们将m的上限放在层(sqrt(N))+1

The floor function 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 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 any numbers.

让我们一步一步地把这些都写进代码中。让我们先编写一个使用欧几里德算法的简单gcd函数:

# functions.py
def gcd(a, b):
    """Calculate the Greatest Common Divisor of (a, b). """
    while b != 0:
        a, b = b, a % b
    return a

关于欧几里德算法的解释可以在网上找到,所以我不会在这里花时间讨论它;我们需要关注生成器表达式。下一步是使用我们之前收集的知识生成原始毕达哥拉斯三元组列表:

# 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
)

好了。这不容易读,所以让我们一行一行地看一遍。在#3处,我们启动一个正在创建三元组的生成器表达式。你可以从#4#5中看到,我们在*【1,M】中循环m,其中Msqrt(N)的整数部分,加上1*。另一方面,【1,m】中的n循环,以尊重m>n规则。值得注意的是,我是如何计算*sqrt(n)*的,即N**.5,这只是我想展示给大家的另一种方法。

#6可以看到使三元组原语的过滤条件:(m - n)为奇数时(m - n) % 2计算为True,而gcd(m, n) == 1表示mn为互质。有了这些,我们知道三元组将是原始的。这将处理最内部的生成器表达式。最外面的一个从#2开始,在#7结束。我们将三元组(abc)放入(……最里面的生成器…)中,以便c <= N

最后,在#1我们应用排序,以按顺序呈现列表。在#7处,当最外层的生成器表达式关闭后,您可以看到我们将排序键指定为a+b+c之和。这只是我个人的喜好,没有数学上的原因。

那么,你认为呢?读起来容易吗?我不这么认为。相信我,这仍然是一个简单的例子;在我的职业生涯中,我经历了更糟糕的事情。这种代码很难理解、调试和修改。它不应该在专业环境中找到一席之地。

那么,让我们看看是否可以将此代码重写为可读性更强的代码:

# 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

这样好多了。让我们一行一行地看一遍。你会发现这是多么容易理解。

我们从#1#2开始循环,与上一个示例中的循环方式完全相同。在第#3行,我们对原始三元组进行了过滤。在#4线上,我们与之前做的有些不同:我们计算c,在#5线上,我们过滤c小于或等于N。只有当c满足该条件时,我们才计算ab,并生成结果元组。尽可能多地延迟所有计算总是好的,这样我们就不会浪费时间和 CPU。在最后一行中,我们使用生成器表达式示例中使用的相同键应用排序。

我希望你同意,这个例子更容易理解。我向你保证,如果有一天你不得不修改代码,你会发现修改这个代码很容易,而修改另一个版本则需要更长的时间(而且更容易出错)。

如果打印两个示例的结果(它们相同),您将得到以下结果:

[(3, 4, 5), (5, 12, 13), (15, 8, 17), (7, 24, 25), (21, 20, 29), (35, 12, 37), (9, 40, 41)]  

这个故事的寓意是,尽可能多地尝试使用理解和生成器表达式,但如果代码开始变得复杂而难以修改或阅读,您可能希望将其重构为更具可读性的代码。你的同事会感谢你的。

名称本地化

现在我们已经熟悉了所有类型的理解和生成器表达式,让我们来讨论其中的名称本地化。Python 3.*在所有四种理解形式中本地化循环变量:listdictset和生成器表达式。因此,该行为与for循环不同。让我们看一个简单的例子来说明所有情况:

# 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

在前面的代码中,我们声明了一个全局名称A = 100,然后我们练习了四种理解:list、生成器表达式、字典和set。它们都没有更改全局名称A。相反,您可以在结尾看到for循环对其进行了修改。最后一个打印语句打印4

让我们看看如果A不在那里会发生什么:

# scopes.noglobal.py
ex1 = [A for A in range(5)]
print(A)  # breaks: NameError: name 'A' is not defined

上述代码对四种理解中的任何一种都适用。在我们运行第一行之后,A没有在全局名称空间中定义。同样,for循环的行为不同:

# scopes.for.py
s = 0
for A in range(5):
    s += A
print(A) # prints: 4
print(globals())

前面的代码显示在for循环之后,如果循环变量之前没有定义,我们可以在全局框架中找到它。为了确保这一点,让我们通过调用 OutT1 内置函数来窥视它:

$ python scopes.for.py
4
{'__name__': '__main__', '__doc__': None, ..., 's': 10, 'A': 4}

再加上我遗漏的许多其他样板资料,我们可以发现'A': 4

内置的生成行为

在内置类型中,生成行为现在非常常见。这是 Python2 和 Python3 之间的主要区别。许多函数,例如mapzipfilter都经过了转换,因此它们返回的对象的行为类似于 iterables。这一变化背后的想法是,如果您需要列出这些结果,您可以将调用打包到list()类中,这样您就完成了。另一方面,如果您只需要迭代并希望尽可能减少对内存的影响,那么您可以安全地使用这些函数。

另一个值得注意的例子是range函数。在 Python2 中,它返回一个列表,还有另一个名为xrange的函数,它返回一个可以迭代的对象,该对象会动态生成数字。在 Python3 中,这个函数已经消失了,range现在的行为与之类似。

但总体而言,这一概念现在相当普遍。您可以在open()函数中找到它,该函数用于对文件对象进行操作(我们将在第 7 章文件和数据持久化中看到),也可以在enumerate、字典keysvaluesitems方法以及其他几个地方找到它。

这一切都是有道理的:Python 的目标是尽可能避免浪费空间,特别是在大多数情况下广泛使用的函数和方法中,从而尽量减少内存占用。

你还记得这一章的开头吗?我说过,优化处理大量对象的代码的性能比从我们每天调用两次的函数中节省几毫秒更有意义。

最后一个例子

在我们完成本章之前,我将向您展示一个简单的问题,我曾向一家公司的 Python 开发人员职位候选人提交过这个问题。

问题如下:给定序列0 1 1 2 3 5 8 13 21 ...,编写一个函数,该函数将返回该序列的项,直到达到某个极限N

如果你还没有认识到,那就是斐波那契序列,它被定义为F(0)=0F(1)=1以及,对于任何n>1F(n)=F(n-1)+F(n-2)。这个序列非常适合测试关于递归、记忆技术和其他技术细节的知识,但在本例中,这是一个很好的机会来检查应试者是否知道生成器。

让我们从函数的基本版本开始,然后对其进行改进:

# 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]

从顶部开始:我们将result列表设置为起始值[0]。然后我们从下一个元素(next_n)开始迭代,即1。虽然下一个元素不大于N,但我们会将其添加到列表中并计算下一个元素。我们通过从result列表中的最后两个元素中取一个切片并将其传递给sum函数来计算下一个元素。如果你不清楚的话,在这里或那里添加一些print声明,但现在我认为这不是一个问题。

while循环的条件评估为False时,我们退出循环并返回result。您可以在每个print语句旁边的注释中看到这些语句的结果。

在这一点上,我会问应聘者以下问题:*如果我只想迭代这些数字会怎么样?*一个好的候选者会将代码更改为您在这里可以找到的代码(一个优秀的候选者会从它开始!):

# 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]

这实际上是我得到的解决方案之一。我不知道为什么我保留了它,但我很高兴我保留了它,这样我就可以给你看了。现在,fibonacci函数是生成器函数。首先我们产生0,然后如果N0,我们返回(这将导致StopIteration异常被引发)。如果不是这样,我们开始迭代,在每个循环周期产生b,然后更新ab。为了能够产生序列的下一个元素,我们所需要的只是过去的两个元素:ab

这段代码要好得多,内存占用更少,我们要得到一个斐波那契数列表所需要做的就是像往常一样用list()来结束调用。但是优雅呢?我不能就这样离开,对吗?让我们尝试以下方法:

# 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

好多了。函数的整个主体是四行,如果计算 docstring,则为五行。注意,在本例中,使用元组赋值(a, b = 0, 1a, b = b, a + b有助于使代码更短、更可读。

总结

在本章中,我们更深入地探讨了迭代和生成的概念。我们详细研究了mapzipfilter函数,并学习了如何将它们用作常规for循环方法的替代方法。

然后我们讨论了理解的概念,如列表、词典和集合。我们探讨了它们的语法,以及如何使用它们来替代经典的for循环方法以及mapzipfilter函数。

最后,我们讨论了生成的概念,有两种形式:生成函数和表达式。我们学习了如何通过使用生成技术来节省时间和空间,并了解了如果我们使用基于列表的传统方法,它们如何能够实现通常不会实现的功能。

我们讨论了性能,发现for循环在速度上是最后一个循环,但它们提供了最好的可读性和更改灵活性。另一方面,诸如mapfilter以及list理解等功能可以更快。

使用这些技术编写的代码的复杂性呈指数级增长,因此为了提高可读性和易维护性,我们有时仍然需要使用经典的for循环方法。另一个区别在于名称本地化,for循环的行为与所有其他类型的理解不同。

下一章将全部介绍对象和类。它在结构上与这个类似,因为我们不会探讨很多不同的主题,只是其中的一些,但我们会尝试更深入地探讨它们。

在继续下一章之前,请确保您理解了本章的概念。我们正在建造一堵砖砌的墙,如果地基不牢固,我们不会走得很远。