在Python这门语言中,生成器毫无疑问是最有用的特性之一,与此同时,也是使用的最不广泛的Python特性之一。究其原因,主要是因为,在其他主流语言里面没有生成器的概念。正是由于生成器是一个“新”的东西,所以,它一方面没有引起广大工程师的重视,另一方面,也增加了工程师的学习成本,最终导致大家错过了Python中如此有用的一个特性。在这一节中,我们将深入浅出地介绍Python的生成器,以改变“如此有用的特性却使用极不广泛”的现象。
1 迭代器协议
生成器自动实现了迭代器协议,而迭代器协议对很多人来说,也是一个较为抽象的概念。所以,为了更好的理解生成器,需要简单的了解一下迭代器协议的概念。迭代器协议是指: 对象需要提供next方法,它要么返回迭代中的下一项,要么就引起一个StopIteration异常以终止迭代。可迭代对象就是实现了迭代器协议的对象。所谓协议,只是一种约定,可迭代对象实现迭代器协议,Python的内置工具(如for循环、sum、min、max函数等)使用迭代器协议访问对象。
例如,在所有语言中,我们都可以使用for循环来遍历数组。Python的list底层实现是一个保存对象引用的数组。因此,我们可以使用for循环来遍历list。如下所示:
In [1]: for n in [1, 2, 3, 4]:
...: print(n)
...:
1
2
3
4
但是,在Python语言中,我们不但可以用for循环来遍历list,也可以用来遍历文件对象。如下所示:
In [2]: with open('/etc/passwd') as f:
...: for line in f:
...: print(line)
为什么在Python中,文件还可以使用for循环进行遍历呢?这是因为,在Python语言中,文件对象实现了迭代器协议。for循环并不知道它遍历的是一个文件对象,它只管使用迭代器协议访问对象。正是由于Python的文件对象实现了迭代器协议,我们才得以使用如此方便的方式访问文件。如下所示:
In [3]: f = open('/etc/passwd')
In [4]: dir(f)
Out[4]:
['__class__',
'__delattr__',
'__doc__',
......
'next',
'readline',
'readlines']
我们通过Python内置的dir函数可以看到,文件对象具有一个名为next的方法。next方法就是迭代器协议的一部分,它要么返回迭代中的下一项,要么就抛出一个StopIteration异常。for循环会自动调用next方法获取文件中的内容,与此同时,for循环也会通过结束循环的方式,实现自动处理StopIteration异常。
2 生成器
理解了迭代器协议和可迭代对象以后,再来看生成器就会好理解很多。
Python使用生成器对延迟操作提供了支持。所谓延迟操作,是指在需要的时候才产生结果,而不是立即产生结果。Python有两种不同的方式提供生成器:
- 生成器函数:与普通函数定义类似,但是,使用yield语句而不是return语句返回结果。yield语句一次返回一个结果,在每个结果中间,挂起函数的状态,以便下次从它离开的地方继续执行;
- 生成器表达式:类似于列表推导,但是,生成器返回按需产生结果的一个对象,而不是一次构建一个结果列表。
下面是一个生成器的例子,使用生成器返回自然数的平方:
def gensquares(N):
for i in range(N):
yield i ** 2
for item in gensquares(5):
print(item)
相同的功能,使用普通函数实现:
def gensquares(N):
res = []
for i in range(N):
res.append(i*i)
return res
for item in gensquares(5):
print(item)
读者可以看到,使用生成器以后,实现相同功能,代码行数更少了。在我们的gensquares函数中,原来需要5行代码,现在只需要3行代码即可。
我们再来看一个例子,以加深读者对生成器的理解。假设现在有一个列表,列表中包含若干整数。接下来,我们需要对列表进行过滤。在过滤以后的结果中,只保留列表中偶数的数字。这个需求是如此的简单,相信读者能够快速的写出相应的函数。如下所示:
def get_even_num(l):
res = []
for item in l:
if item % 2 == 0:
res.append(item)
return res
def main():
l = range(5)
for item in get_even_num(l):
print(item)
if __name__ == '__main__':
main()
使用生成器以后,程序整体架构不变,但是代码更加清晰。如下所示:
def get_even_num(l):
for item in l:
if item % 2 == 0:
yield item
def main():
l = range(5)
for item in get_even_num(l):
print(item)
if __name__ == '__main__':
main()
接下来看一下生成器表达式的使用。生成器表达式与列表推导非常相似,使用列表推导,将会一次产生所有结果。使用生成器,不会一次产生所有的结果,它会返回按需产生结果的一个对象。如下所示:
In [5]: squares = [x**2 for x in range(5)]
In [6]: squares
Out[6]: [0, 1, 4, 9, 16]
将列表推导的中括号,替换成圆括号,就是一个生成器表达式:
In [7]: squares = (x**2 for x in range(5))
In [8]: squares
Out[8]: <generator object <genexpr> at 0x7fe8333bf280>
In [9]: next(squares)
Out[9]: 0
In [10]: next(squares)
Out[10]: 1
In [11]: next(squares)
Out[11]: 4
In [12]: list(squares)
Out[12]: [9, 16]
在Python语言中,不但使用迭代器协议让for循环变得更加通用,而且,大部分内置函数也可以使用迭代器协议访问对象。例如, sum函数是Python的内置函数,该函数使用迭代器协议访问对象,而生成器实现了迭代器协议,所以,我们可以直接这样计算一系列值的和:
In [13]: sum((x ** 2 for x in range(4)))
Out[13]: 14
为了简单起见,我们也可以省略生成器的圆括号。如下所示:
In [14]: sum(x ** 2 for x in range(4))
Out[14]: 14
使用sum函数计算一些列值的和时,可以直接使用生成器,而不用多此一举的先构造一个列表。例如,下面的程序就是一个典型的反面教材。在这段程序中,需要先构造一个列表,然后再使用sum函数计算列表中元素的和:
In [15]: sum([x ** 2 for x in range(4)])
Out[15]: 14
3 生成器与函数
通过前面的介绍,相信读者已经对Python的生成器有了感性的认识。接下来,以生成器函数为例,再来深入探讨一下Python的生成器。
Python的生成器语法上和函数类似。生成器函数和常规函数几乎是一样的,它们都是使用def语句进行定义。差别在于:
- 从语法上看,生成器使用yield语句返回一个值,而常规函数使用return语句返回一个值;
- 从使用上看,Python的生成器自动实现了迭代器协议。由于生成器实现了迭代器协议,所以,我们可以在迭代环境中(如for循环、sum函数、min函数等)使用生成器;
- 从Python实现上看,生成器的yield语句挂起生成器函数的状态,保留足够的信息,以便之后从它离开的地方继续执行。
使用生成器的主要原因是,生成器支持延迟计算。所谓延迟计算,就是一次返回一个结果,而不是一次返回所有结果。这对于大数据量处理,将会非常有用。读者可以在自己计算机上试试下面两个表达式,并且观察内存占用情况。对于前一个表达式,笔者在自己的电脑上进行测试,还没有看到最终结果电脑就已经卡死。对于后一个表达式,几乎没有什么内存占用。
In [16]: sum([i for i in xrange(1000000000)])
In [17]: sum(i for i in xrange(1000000000))
除了延迟计算,生成器还能有效提高代码可读性。例如,现在有一个需求,求一段文字中,每个单词出现的位置。如果我们不使用生成器,我们的程序大概是下面这样:
def index_words(text):
result = []
if text:
result.append(0)
for index, letter in enumerate(text, 1):
if letter == ' ':
result.append(index)
return result
text = """The Zen of Python, by Tim Peters"""
print(index_words(text))
使用生成器以后,程序变得更加清晰了:
def index_words(text):
if text:
yield 0
for index, letter in enumerate(text, 1):
if letter == ' ':
yield index
text = """The Zen of Python, by Tim Peters"""
print(list(index_words(text)))
至少有两个理由可以说明 ,使用生成器比不使用生成器代码更加清晰:
- 使用生成器以后,代码行数更少。读者需要牢记记住,如果想把代码写得简洁优美,需要在保证代码可读性的前提下,代码行数越少越好;
- 不使用生成器的时候,对于每次结果,我们首先看到的是一个列表的append操作(result.append(index)),其次,我们才看到的是index这个变量。也就是说,我们每次看到的是一个列表的append操作,只是append的参数是我们想要的结果。使用生成器以后,直接yield index,少了列表append操作的干扰,一眼就能够看出,代码是要返回index这个变量的值。
这个例子充分说明了,合理使用生成器,能够有效提高代码可读性。只要大家接受了生成器的概念,理解了yield语句和return语句一样,也是返回一个值。那么,就能够理解生成器的好处,也可以通过生成器提高程序效率和可读性。
4 总结
生成器本身是一个可选的特性,所有使用生成器的地方,都可以用一个不使用生成器的函数来代替。之所以需要使用生成器,一方面是为了对大数据量的计算提供支持,另一方面,是为了提高代码的可读性。在这篇文章中,我们反复强调,要想把代码写得简洁优美,需要在保证可读性的前提下,代码行数越少越好。生成器就是一个改善代码质量的例子,似乎可有可无,但是,很多问题使用生成器都可以解决得更加优美。
作者介绍
赖明星,架构师、作家。现就职于腾讯,参与并主导下一代金融级数据库平台研发。有多年的 Python 开发经验和一线互联网实战经验,擅长 C、Python、Java、MySQL、Linux 等主流技术。国内知名的 Python 技术专家和 Python 技术的积极推广者,著有《Python Linux 系统管理与自动化运维》一书。