这次聊一下Python闭包的实现。
首先说下什么是闭包。以下为维基百科的定义。
在计算机科学中,闭包(英语:Closure),又称词法闭包(Lexical Closure)或函数闭包(function closures),是引用了自由变量的函数。这个被引用的自由变量将和这个函数一同存在,即使已经离开了创造它的环境也不例外。所以,有另一种说法认为闭包是由函数和与其相关的引用环境组合而成的实体。闭包在运行时可以有多个实例,不同的引用环境和相同的函数组合可以产生不同的实例。
|
|
上面func(1)返回后,构成了一个闭包 closure。这时虽然func函数已经结束了,但是仍能在inner_func里使用本来属于func 的局部变量x。
本文及其以后所讲的Python均为cython。
我们知道python的底层是c语言写的,而标准的c语言是并不支持闭包的。
假设C支持嵌套定义 (标准的C不支持嵌套函数定义, gcc支持 ), 也依然不可能。因为在func 函数执行结束后, func 作用域的局部变量x 会随着func 结束而消亡。 因此无法在closure(2)时再去引用到x。
这里如果有同学对于堆heap 和 栈stack遗忘了的话,简单帮大家回忆一下。
1、栈区(stack)— 由编译器自动分配释放 ,存放函数的参数值,局部变量的值等。
2、堆区(heap) — 一般由程序员分配释放, 若程序员不释放,程序结束时可能由OS回收
一个简单的c例子
|
|
系统对于函数的调用也是通过栈实现的,例如下面例子
|
|
在栈上的演示为下图
在main函数调用func_A的时候,首先在自己的栈帧中压入函数返回地址,然后为func_A创建新栈帧并压入系统栈
在func_A调用func_B的时候,同样先在自己的栈帧中压入函数返回地址,然后为func_B创建新栈帧并压入系统栈
在func_B返回时,func_B的栈帧被弹出系统栈,func_A栈帧中的返回地址被“露”在栈顶,此时处理器按照这个返回地址重新跳到func_A代码区中执行
在func_A返回时,func_A的栈帧被弹出系统栈,main函数栈帧中的返回地址被“露”在栈顶,此时处理器按照这个返回地址跳到main函数代码区中执行
注意:在实际运行中,main函数并不是第一个被调用的函数,程序被装入内存前还有一些其他操作,上图只是栈在函数调用过程中所起作用的示意图
这样大家就明白了,为什么说如果我们func 执行完了,那么属于它的局部变量也就消亡了。
OK,既然标准的C是无法实现的,那么下面就看看Python的底层是如何把局部变量(自由变量)传递给嵌套函数的了。
先介绍两个对象,一个PyCodeObject,一个是PyFunctionObject。
PyCodeObject 是一段Python源代码的静态表示。源代码编译后,一个Code Block会产生一个且只有一个PyCodeObject。这个PyCodeObject对象中包含了这个CodeBlock的一些静态的信息,所谓静态的信息是指可以从源代码中看到的信息。比如CodeBlock中有a=1这样的表达式,那么符号a和1以及他们之间的联系就是一种静态信息,这些信息会分别存储在PyCodeObject的常量表co_consts和符号表co_names以及字节码序列co_code中,这些信息是编译时就可以得到的。
Python虽然是解释型语言但是其实是有编译过程的,具体的可以看看这篇文章
这里再简单介绍下CodeBlock。
Python编译器在对Python源码进行编译的时候,对代码中的一个Code Block,会创建一个PyCodeObject对象与这段代码对应。
如何确定多少代码算一个Code Block?
Python中确定Code Block的规则:当进入一个新的名字空间或作用域时,就算进入了一个新的Code Block了。
即:一个名字空间对应一个Code Block,它会对应一个PyCodeObject。
在Python中,类、函数和module都对应着一个独立的名字空间,因此都会对应一个PyCodeObject对象。
PyFunctionObject 会在每一次调用函数时生成每一个PyFunction的 func_code 域都会关联到PyCode对象。
也就是说,如果函数被调用多次,会产生多个PyFunction共同引用同一个func_code
看下这两个对象的具体构成
源码
|
|
|
|
其中PyCodeObject 有两个属性
co_freevars 自由变量,这个CodeBlock 如果是被嵌套的那么这个自由变量就会存储着上一层嵌套他的CodeBlock的局部变量
co_cellvars ,这个会存储着会被嵌套的CodeBlock 用到的变量
具体看下
|
|
输出
|
|
inner_func 关联的 PyCodeObject 在编译时已经可以知道 func 的一个局部变量x,但是这里只是存储了一个变量名,接着具体看下是如何把值存进来的。
Python代码是先被编译为Python字节码后,再由Python虚拟机来执行Python字节码(pyc文件主要就是用于存储字节码指令 的)。一般来说一个Python语句会对应若干字节码指令,Python的字节码是一种类似汇编指令的中间语言,但是一个字节码指令并不是对应一个机器指 令(二进制指令),而是对应一段C代码。
可以用dis 这个module,来查看python代码对应的字节码指令
python -m dis deep_into_closure.py
|
|
执行func时,执行CALL_FUNCTION命令,去ceval.c看一下 CALL_FUNCTION
对应的c代码
|
|
call_function
调用 fast_function
|
|
|
|
在 PyEval_EvalCodeEx
中主要完成以下功能
- 创建一个PyFrameObject 对象
|
|
其中 PyFrame_New(tstate, co, globals, locals);
其中会开辟一块存储空间用来存储 co_stacksize , co_cellvars, co_freevars, co_nlocals
并用 f_localsplus
指向这块区域
- ….
- ….
OK,我们构造出了这个这个Python栈帧retval = PyEval_EvalFrameEx(f,0);
接着执行这个这个栈
|
|
用dis 查看下栈有关的字节码命令
|
|
输出
|
|
看下LOAD_CLOSURE的c命令
|
|
入栈, 此时得到一个PyCellObject, 指向2, name=’x’
LOAD_CLOSURE 在编译时会根据嵌套函数中 co_freevars, 决定了取得参数位置和个数
然后, BUILD_TUPLE, 将cell对象打包成tuple, 得到(‘x’, )
然后, 开始, 载入嵌套函数do_add, 入栈
调用MAKE_CLOSURE
|
|
来关注一下 PyFunction_SetClosure
|
|
即do_add的 PyFunctionObject的func_closure指向一个tuple
然后, 在嵌套函数被调用的时候
call_function->fast_function,
|
|
看下PyFunction_GET_CLOSURE
|
|
然后, 进入 PyEval_EvalCodeEx, 注意这里的closure参数即上一步取出来的func_closure, 即外层函数传进来的tuple
|
|
ok,如果你看上面看懵了的话,那么我简单总结一下
最初编译时生成了PyCodeObject其中co_cellvars 存了闭包要用的变量名x,调函数func 时, 生成PyFunctionObject, 接着生成了PyFrameObject, PyFrameObject.f_localsplus 存储了co_cellvars变量名指向的数值 1,接着运行这个栈,把f_localsplus 里的 闭包内容cell,打包成tuple,读取 inner_func 的 PyCodeObject,进而修改inner_func 的 PyFunctionObject, 使得inner_func 的 PyFunctionObject 创建时会读取到 闭包的tuple。 这样每次call 闭包函数时都会获取到 上一层的 闭包变量!