一、起念

最近在看一本关于 Python 基础知识的书,书名叫 Effective Python (2nd Edition)。正看到第32小节:Consider Generator Expressions for Large List Comprehensions(当列表推导式很大时,考虑运用生成器)。

这一节中说到:

Generator expressions don’t materialize the whole output sequence when they’re run. Instead, generator expressions evaluate to an iterator that yields one item at a time from the expression. (生成器表达式在履行时不会一次性生成全部的回来成果,它会产生一个迭代器,一次只输出一个成果。)

当程序运用大列表时,内存可能会爆,作者引荐:尽量运用生成器

我想知道为什么生成器会有省内存的效用?

于是去寻找答案,我找到答案,答案是一篇博客。

现将其译为中文,共享给我们。

二、译文

了解 Python 生成器之前,需求先弄清楚一般 Python 函数是怎么工作的。一般来说,当一个 Python 函数调用子函数,子函数在回来(或抛出异常)之前都将对流程保有控制权。回来(或抛出异常)之后,再将控制权还给上层调用者。

>>>deffoo():
...bar()
...
>>>defbar():
...pass

标准 Python 解说器是用 C 实现的。履行 Python 函数的 C 函数叫做PyEval_EvalFrameEx。它持有 Python 的栈帧目标,并在当时上下文中履行 Python 的字节码。foo函数的字节码如下(各个方位的意义请看之前的整理):

>>>importdis
>>>dis.dis(foo)
20LOAD_GLOBAL0(bar)
2CALL_FUNCTION0
4POP_TOP
6LOAD_CONST0(None)
8RETURN_VALUE

foo函数将bar函数加载到它的栈中并调用它,然后将它的回来值从栈中弹出来,把None载入栈中,然后再回来None

PyEval_EvalFrameEx遇见CALL_FUNCTION时,它创建一个新的 Python 栈帧并递归调用:即PyEval_EvalFrameEx带着新的包括bar函数一切信息的新的栈帧递归调用它自己。

理解 Python 的栈帧是在堆内存中是很重要的! Python 解说器只是一个一般的 C 程序,所以它的栈帧就是一般的栈帧。可是 Python 的栈帧是被这个 C 程序在堆上操作的。除了一些特殊情况外,这意味着 Python 的栈帧可以在函数调用之外存活。为了直观地说明这个问题,让我们来看看bar函数履行时的栈帧:

>>>importinspect
>>>frame=None
>>>deffoo():
...bar()
...
>>>defbar():
...globalframe
...frame=inspect.currentframe()
...
>>>foo()
>>>#当时帧正在履行'bar'的代码
>>>frame.f_code.co_name
'bar'
>>>#当时帧的父节点,指向了'foo'
>>>caller_frame=frame.f_back
>>>caller_frame.f_code.co_name
'foo'
>>>#栈帧的类型
>>>type(frame)
<class'frame'>
>>>type(frame.f_back)
<class'frame'>

月更 | 一篇技术文的翻译:Python生成器的实现

函数调用

如上,实现生成器的舞台——代码目标和栈帧——已经搭建好了。

下面是一个生成器函数:

>>>defgen_fn():
...result=yield1
...print(f'resultofyield:{result}')
...result2=yield2
...print(f'resultof2ndyield:{result2}')
...return'done'
...

当 Python 将gen_fn编译为字节码时,编译器看到yield要害字后知道gen_fn是一个生成器函数,它会在函数身上设置一个标记:

>>>#generator的标志位在第5位
>>># Python源码中定义:#define CO_GENERATOR 0x0020
>>>generator_bit=1<<5
>>>bin(generator_bit)
'0b100000'

>>>bool(gen_fn.__code__.co_flags&generator_bit)
True
>>>bool(bar.__code__.co_flags&generator_bit)
False

一个生成器函数被调用时,Python 会看到生成器标识,此刻并不会真的履行这个函数,而是创建一个生成器:

>>>gen=gen_fn()
>>>type(gen)
<class'generator'>

Python 生成器将栈帧以及对一些代码的引证封装在一起。gen_fn的内容为:

>>>gen.gi_code.co_name
'gen_fn'

一切来自于gen_fn的生成器都指向相同代码。但每一个都保有自己的独立栈帧。这个栈帧并不在真实的栈上,而是待在堆内存中等候着下次被运用:

月更 | 一篇技术文的翻译:Python生成器的实现

生成器内存布局

帧目标有一个叫做last instruction指针,指向最近履行的指令方位(译注:此处就是代码中yield所在当地)。最开始last instruction的值为 -1,代表这个生成器还未开始履行:

>>>gen.gi_frame.f_lasti
-1

当我们调用send,生成器到达它的第一个yield,然后停下来。send的回来值为 1,来自于gen传递给yield表达式的值:

>>>gen.send(None)
1

现在生成器的last instruction指向编译后总长度为46的字节码的第4位(此处我的测试与原文有些差异,原文总长度为56):

>>>gen.gi_frame.f_lasti
4
>>>len(gen.gi_code.co_code)
46

生成器可以被任何函数在任何时刻重新唤醒,由于它的栈帧在堆内存中,并非真的在栈空间中。它的调用阶层并非是固定的,它不必恪守一般函数的先进后出规则。

生成器,像云一样自由~

我们可以再将 “hello” 传递给生成器,它会成为yield句子的回来值,然后生成器继续履行,直到将2回来:

>>>ret=gen.send('hello')
resultofyield:hello
>>>ret
2

现在栈帧中包括局部变量result:

>>>gen.gi_frame.f_locals
{'result':'hello'}

其它被gen_fn创建的生成器都会有它们自己的栈帧和局部变量。

当我们再一次调用send,生成器从它的第二个yield句子继续下去,以它特有的StopIteration异常结束。

>>>ret=gen.send('bye')
resultof2ndyield:bye
Traceback(mostrecentcalllast):
File"<stdin>",line1,in<module>
StopIteration:done

这个异常包括了一个值,这个值来自于生成器的回来值:字符串done

三、结论

为什么生成器能够节省内存呢?

待我将上面翻译部分来回读两三遍后,才发现答案其实就在书中:生成器一次只处理一个值、只生成一个成果,处理完毕并不会将成果存着;既然如此,不需求请求许多内存来保存一切的成果,那肯定省内存嘛。

至于生成器是怎么做到一次只处理一个值的呢?

由于生成器函数履行时,只需碰到yield就停下来,等候下一个next的触发。一碰到yield就停下来,等候next信号继续。

函数调用怎么能够停下来?

是由于大部分Python函数的栈帧都保存在堆内存上,不必恪守“先进后出”规则。

四、参阅链接

月更 | 一篇技术文的翻译:Python生成器的实现

一张来自于《操作系统导论》关于堆和栈的图

1、原文链接:aosabook.org/en/500L/a-w… (How Python Generators Work小节)

2、Frame Object的介绍:nanguage.gitbook.io/inside-pyth…

3、yield要害词的解说,这里面有许多的大神清楚解说:stackoverflow.com/questions/2…