首页 > Python笔记 阅读数:18

Python生成器是什么(超级详细)

之前我们讨论了高效的推导式。通过推导式,我们可以直接创建一个列表、字典或集合。但是,由于受到内存的限制,这些可迭代对象(列表、字典或集合)的容量是有限的。

比如,创建一个包含 10 万个元素的列表,不仅要占用很大的存储空间,而且根据局部性原理,在一段时间内我们要访问的仅仅局限于相邻的若干个元素,即使把所有元素都加载到内存之中,它们被“临幸”的概率也非常小。因此,大部分的存储空间其实是被白白浪费了。

基于此,我们就会有这样的需求:这些元素能不能按照某种算法推算出来,然后在后续循环过程中,根据这些元素不断推算出其他被访问的元素呢?这样一来,就不必创建完整的列表、字典或集合了,从而节省了大量的空间。在 Python 语言中,这种一边循环一边计算的机制,称为生成器

Python生成器的定义

创建一个生成器并不复杂,方法也有很多。最简单的一种方法莫过于把一个列表推导式最外层的标记方括号[ ]改成圆括号( ),这样一个生成器就创建好了,示例代码如下。
In [1]: n = 10
In [2]: a = [x**2 for x in range (n) if x%2 == 0] #这是一个列表推导式
In [3]: print(a)   #可正常输出
[0, 4, 16, 36, 64]
In [4]: type(a)  #验明正身
Out[4]: list
In [5]: b = (x**2 for x in range(n) if x % 2==0) #这是一个生成器
In [6]: print(b)   #无法直接输出
<generator object <genexpr> at 0xl07c88d00〉
In [7]: type(b)   #验明正身
Out[7]: generator

上述代码的 In [2] 处是一个标准的列表生成式,一旦执行,就会把符合条件的列表元素全部加载到内存之中,此处生成的元素个数仅为 1 0个。但如果 n 为 100 万呢?列表 a 就会生成同样数量级别的元素,这无疑会浪费大量内存。

而在输入 In [5] 处,我们将 In [2] 处的最外层方括号[ ]替换为圆括号( ),这时它的类型就截然不同了。从 In [4] 和 In [7] 处输出的对象类型可以看出,前者 a 是一个列表,而后者 b 则是一个生成器。

在本质上,生成器就是一个生成元素的函数。现在你应该明白 In [5] 处最外层的那对圆括号( )的意义了吧,它不是“元组”生成式的标志,而更像是某个函数的标志(函数最核心的标志之一就是那对括号)。我们把这种表达式叫作生成器表达式(generator expression)

列表中的元素可以直接利用 print( ) 语句输出(如上述代码 In [6] 处),但同样的办法对生成器而言却是不可行的,解释器仅能给出生成器的地址信息。那么,该如何输出生成器中的每一个元素呢?这时,就需要借助全局内置函数 next( ),获得生成器的下一个返回值。

next( ) 函数好像拥有记忆一般,每使用一次 next( ) 函数就会顺序输出生成器的下一个元素,而不是从最开始的位置输出,直到输出最后一个元素,没有元素可输出时,就会抛出 StopIteration 异常。
In [8]: next(b)
Out[8]: 0
In [9]: next(b)
Out[9]: 4
In [10]: next(b)
Out[10]: 16
In [11]: b.__next__()   #或用对象a的内部函数 __next__()来访问下一个元素
Out[11]: 36
...

由于生成器也是一个特殊的迭代器,所以它也会有内置函数 __next__(),在输入 In [11] 处,我们调用了它的内置函数 __next__(),也实现了和全局函数 next( ) 相同的效果。当我们不断执行 next(a) 时,它会不断输出 a 的下一个元素,直到没有更多的元素输出时,它会抛出 StopIteration 异常。

通常,生成器的正确打开方式并不是“傻乎乎”地反复调用 next( ) 函数,而是和循环(如 for、while 等)配套使用,由于 Python 语法糖会为我们保驾护航,确保访问不会越界,因此不会发生 StopIteration 异常,代码如下所示:
In [12]: a = (x**2 for x in range (n) if x%2 == 0)   #此处 n = 10
In [13]: for num in a: 
    ...:     print(num)
    ...:
0
4
16
36
64

Python利用yield创建生成器

生成器的功能很强大。如果推算的算法比较复杂,难以利用列表推导式来生成,这时就可以使用含有 yield 关键字的函数。下面举例说明。

例如,在著名的斐波那契数列(Fibonacci)中,除第一个数和第二个数都为 1 之外,任意后面一个数都可由前两个数相加得到。

1,1, 2, 3, 5, 8, 13, 21, 34,...


分别以斐波那契数列中的元素为半径画出 1/4 圆,这些 1/4 圆连接起来的曲线称为斐波那契螺旋线,也称“黄金螺旋”,如图 1 所示。很神奇的是,在自然界中,很多生物(如向日葵、仙人掌、海螺等)中都存在斐波那契螺旋线的图案。

斐波那契螺旋线
图 1:斐波那契螺旋线

回到关于生成器的讨论上来。生成斐波那契数列的过程相对比较复杂,难以利用列表推导式简练地表达出来,但可以用一个多行的函数描述出来,参见例 1。

【例 1】生成斐波那契数列的函数(fibonacci.py)
def fibonacci(xterms):
    n, a, b = 0, 0, 1  #变量初始化
    while n < xterms:
        print(b, end = ' ')
        a, b = b, a + b   #变量更新
        n = n + 1
    return '输出完毕'

fibonacci(10)
程序执行结果为:

1 1 2 3 5 8 13 21 34 55

第 02 行和第 05 行代码体现了 Python 的特色—多变量赋值。

第 02 行代码的功能是对三个变量进行初始化赋值,它等价于如下代码:
n = 0
a = 0
b = 1

第 05 行代码的功能是循环更新变量值,它等价于如下代码:
a = b
b = a + b

由上面的代码可以看出,使用多变量赋值可以大大简化代码。但实际上,如前讨论,第 02 行和第 05 行实现的就是两个匿名元组之间的赋值。print( ) 默认的输出终结符是换行符,这里为了不占用打印空间,改成了空格(通过设置print()函数中的参数 end = ' '来实现),因此所有元素输出以空格隔开。

仔细观察可以看出,实际上,fibonacci( ) 函数中第 05 行代码已经清楚地定义了斐波那契数列的推算规则,我们可以从第一个元素开始,推算出后续任意元素。而这种推导逻辑已经非常接近生成器。也就是说,把上述函数稍加改造,就能把fibonacci()函数变成生成器:只需要把向屏幕输出的 print(b) 改为专用的 yield b 就大功告成了。参见例 2。

【例 2】生成斐波那契数列的生成器(fibonacci-gen.py)
def fibonacci(xterms):
    n, a, b = 0, 0, 1
    while n < xterms:
        yield b #表明这是一个生成器
        a, b = b, a + b
        n = n + 1
    return '输出完毕'

例 1 与例 2 的核心区别在于第 04 行,例 2 的第 04 行使用了关键字“yield”,这个关键字的本意就是“生产、产出”,如果某个函数定义中包含 yield 关键字,那么这个函数就不一般了,它不再是一个普通函数,而是一个生成器。

将上述函数加载到内存中以后,我们可以用如下代码来进行测试:
In [1]: func = fibonacci (10)
In [2] : func #并不直接输出
Out[2]: <generator object fibonacci_gen at 0xll04ccl38>

通过前面的讨论,我们知道,In [1] 处的代码并不会执行 fibonacci( ) 函数,而是返回一个可迭代对象!这个对象并不能直接输出(见 In [2] 处),那该如何正确输出我们想要的结果呢?

第一种方法就是前面提到的反复利用 next( ) 函数,代码如下:
In [3]: next(func)
Out[3]: 1
In [4]: next(func)
Out[4]: 1
In [5]: next(func)
Out[5]: 2
In [6]: next(func)
Out[6]: 3
...

通过 next( ) 不断返回数列的下一个数,内存占用始终为常数。这是与列表推导式的显著不同。显然,如果生成器中“蕴涵”的数据较大,每次手动输入一个 next(func),才输出一个数据,麻烦至极。

因此,第二种方法更为常见,那就是和循环结构配套使用。我们重新加载 In [1] 处的代码并再次运行如下代码:
ln [7]: for item in func:
            print(item, end = ' ')
程序执行结果为:

1 1 2 3 5 8 13 21 34 55


前面的几个生成器的案例其实并不实用,生成器的最佳应用场景在于:我们不想将所有计算出来的大量结果一块保存到内存之中。因为这样做会浪费大量不必要的内存资源。例如,将上面代码 In[1] 处的 10 改成 1000000,这时生成器的优势就体现出来了。因为生成器会“临时抱佛脚”,需要谁,就按照规则“临时”生成谁,它就好比是一个“经济适用房”,占用空间不大,但能解决实际问题。

Python生成器的执行流程

在这里,需要特别注意的是,生成器和函数的执行流程不一样。普通函数遇到 return 语句或者执行到最后一行函数语句时就会返回,结束整个函数的运行。

而变成生成器的函数,在每次调用 next( ) 的时候执行,遇到 yield 语句就“半途而废”,再次执行时,就会从上次返回的 yield 语句处接着往下执行。

下面列举一个简单的例子说明生成器的执行流程,见例 3。

【例 3】生成器的执行流程(my_gen.py)
def my_gen():
    print('我是第1次返回')
    yield(1)
    print ('我是第2次返回')
    yield(2)
    print('我是第3次返回')
    yield(3)

由于上述函数中含有 yield 语句,很显然,这是一个生成器。将上述函数加载到内存中之后, 我们来调用这个生成器。在调用生成器之前,首先要生成一个生成器对象,然后用 next( ) 函数不断获得下一个返回值,在 IPython 中的验证代码如下:
In [1]: gen = my_gen()  #创建生成器对象
In [2]: next(gen)   #输出生成器第一个元素,即第一个yield语句运行结果,并返回
我是第1次返回
Out[2]: 1
In [3]: next(gen)  #从例 1 的第 04 行开始执行
我是第2次返回
Out [3] : 2
In [4]: next(gen)  #从例 1 的第06行开始执行
我是第3次返回
Out[4]: 3
In [5]: next(gen)   #无匹配的yield语句运行结果,发生异常,报错!
-----------------------------------------------------------------------
Stopiteration Traceback (most recent call last)
<ipython-input-55-6e72e47198db> in <module>
-----> 1 next(gen)
Stopiteration:

总结一下,在本质上,生成器就是一种元素生成函数,它和普通函数的不同之处在于,它的返回值不是通过 return 返回的,而是通过 yield 返回的。

另外一个需要注意的地方是,含有 yield 语句的函数中如果还配有return语句,那么这个 return 语句并不是用于函数正常返回的,而是 StopIteration 的异常说明。也就是说,生成器没有办法使用 return 的返回值。如果想获得该返回值,需要捕获 StopIteration 异常,然后输出 StopIteration.value。

相关文章