一、起念
最近在看一本关于 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'>
函数调用
如上,实现生成器的舞台——代码目标和栈帧——已经搭建好了。
下面是一个生成器函数:
>>>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
的生成器都指向相同代码。但每一个都保有自己的独立栈帧。这个栈帧并不在真实的栈上,而是待在堆内存中等候着下次被运用:
生成器内存布局
帧目标有一个叫做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函数的栈帧都保存在堆内存上,不必恪守“先进后出”规则。
四、参阅链接
一张来自于《操作系统导论》关于堆和栈的图
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…