楔子
在之前的文章中一直反复提到四个字:名字空间。一段代码执行的结果不光取决于代码中的符号,更多的是取决于代码中符号的语义,而这个运行时的语义正是由名字空间决定的。
名字空间是由虚拟机在运行时动态维护的,但有时我们希望将名字空间静态化。换句话说,我们希望有的代码不受名字空间变化带来的影响,始终保持一致的功能该怎么办呢?随便举个例子:
我们注意到每次都需要输入 username 和 password,于是可以通过使用嵌套函数来设置一个基准值。
尽管函数 login 里面没有 user_name 和 password 这两个局部变量,但是不妨碍我们使用它,因为外层函数 deco 里面有。
也就是说,函数 login 作为函数 deco 的返回值被返回的时候,有一个名字空间就已经和 login 紧紧地绑定在一起了。执行内层函数 login 的时候,对于自身 local 空间中不存在的变量,会从和自己绑定的 local 空间里面去找,这就是一种将名字空间静态化的方法。这个名字空间和内层函数捆绑之后的结果我们称之为闭包(closure)。
为了描述方便,上面说的是 local 空间,但我们知道,局部变量不是从那里查找的,而是从 localsplus 里面。只是我们可以按照 LEGB 的规则去理解,这一点心理清楚就行。
也就是说:闭包=外部作用域+内层函数。并且在介绍函数的时候提到,PyFunctionObject 是虚拟机专门为字节码指令的传输而准备的大包袱,global 名字空间、默认参数都和字节码指令捆绑在一起,同样的,也包括闭包。
实现闭包的基石
闭包的创建通常是利用嵌套函数来完成的,我们说过局部变量是通过数组静态存储的,而闭包也是如此。这里再来回顾一下 PyCodeObject 里面的几个关键字段:
- co_localsplusnames:包含所有局部变量、cell 变量、free 变量的名称
- co_nlocalsplus:co_localsplusnames 的长度,或者说这些变量的个数之和
- co_varnames:包含所有局部变量的名称
- co_nlocals:局部变量的个数
- co_cellvars:包含所有 cell 变量的名称
- co_ncellvars:cell 变量的个数
- co_freevars:包含所有 free 变量的名称
- co_nfreevars:free 变量的个数
因此不难得出它们之间的关系:
- co_localsplusnames = co_varnames + co_cellvars + co_freevars
- co_nlocalsplus = co_nlocals + co_ncellvars + co_nfreevars
那么这些变量的值都存在什么地方呢?没错就是栈帧的 localsplus 字段中。
图片
我们看一段代码:
和闭包相关的两个字段是 co_cellvars 和 co_freevars。co_cellvars 保存了外层作用域中被内层作用域引用的变量的名字,co_freevars 保存了内层作用域中引用的外层作用域的变量的名字。
所以对于外层函数来说,应该使用 co_cellvars,对于内层函数来说,应该使用 co_freevars。当然无论是外层函数还是内层函数都有 co_cellvars 和 co_freevars,这是肯定的,因为都是函数。
只不过外层函数需要使用 co_cellvars 获取,因为它包含的是外层函数中被内层函数引用的变量的名称;内层函数需要使用 co_freevars 获取,它包含的是内层函数中引用的外层函数的变量的名称。
如果使用外层函数 foo 获取 co_freevars 的话,那么得到的结果显然就是个空元组了,除非 foo 也作为某个函数的内层函数,并且内部引用了外层函数的变量。同理内层函数 bar 也是一样的道理,它获取 co_cellvars 得到的也是空元组,因为对于 bar 而言不存在内层函数。
我们再看个例子:
对于函数 bar 而言,它是函数 inner 的外层函数,同时也是函数 foo 的内层函数。所以它在获取 co_cellvars 和 co_freevars 属性时,得到的元组都不为空。因为内层函数 inner 引用了函数 bar 里面的变量 gender,同时函数 bar 也作为内层函数引用了函数 foo 里的 name 和 age。
那么问题来了,闭包变量所需要的空间申请在哪个地方呢?没错,显然是 localsplus。
在以前的版本中,这个字段叫 f_localsplus,现在叫 localsplus。
localplus 是一个柔性数组,它被分成了四份,分别用于:局部变量、cell 变量、free 变量、运行时栈。
所以闭包变量同样是以静态的方式实现的。
闭包的实现过程
介绍完实现闭包的基石之后,我们可以开始追踪闭包的具体实现过程了,当然还是要先看一下闭包对应的字节码。
字节码指令如下,为了阅读方便,我们省略了源代码行号。
字节码的内容并不难,我们来分析一下,这里先分析外层函数 some_func 对应的字节码。
图片
函数 some_func 里面有三个局部变量,但只有 name 和 age 被内层函数引用了,所以开头有两个 MAKE_CELL 指令。参数为符号在符号表中的索引,对应的符号分别为 age 和 name。我们来看一下这个指令是做什么的。
所以 MAKE_CELL 指令的作用是创建 PyCellObject,对于当前来说,会创建两个 PyCellObejct,它们的 ob_ref 字段分别为 age 和 name。只不过由于 name 和 age 还尚未完成赋值,所以此时为 NULL。
图片
接下来就是变量赋值,这个显然没什么难度,我们只需要看一下 STORE_DEREF 指令。并且也容易得出结论,如果局部变量被内层函数所引用,那么指令将不再是 LOAD_FAST 和 STORE_FAST,而是 LOAD_DEREF 和 STORE_DEREF。
localplus 保存了局部变量的值,而符号在符号表中的索引,和对应的值在 localplus 中的索引是一致的。所以正常情况下,局部变量赋值就是 localsplus[oparg] = v。
但在执行 MAKE_CELL 指令之后,局部变量赋值就变成了 localsplus[oparg]->ob_ref = v,因为此时 localplus 保存的是 PyCellObject 的地址。
因此在两个 STORE_DEREF 执行完之后,localplus 会变成下面这样。
相信你明白 STORE_FAST 和 STORE_DEREF 之间的区别了,如果是 STORE_FAST,那么中间就没有 PyCellObject 这一层,localsplus 保存的 PyObject * 指向的就是具体的对象。
然后是 gender = "female",它就很简单了,由于符号 "gender" 在符号表中的索引为 0,那么直接让 localplus[0] 指向字符串 "female" 即可。
到此变量 name、age、gender 均已赋值完毕,此时 localsplus 结构如下。
图片
localsplus[0]、localsplus[2]、localsplus[3] 分别对应变量 gender、age、name,可能有人觉得,这个索引好奇怪啊,我们实际测试一下。
我们看到 some_func 的符号表里面只有 gender 和 inner,因此 localplus[0] 表示变量 gender。至于 localplus[1] 则表示变量 inner,只不过此时它指向的对象还没有创建,所以暂时为 NULL。
那么问题来了,变量 name 和 age 呢?毫无疑问,由于它们被内层函数引用了,所以它们变成了 cell 变量,并且位置是 co->co_nlocals + i。因为在 localsplus 中,cell 变量的位置是在局部变量之后的,这也完全符合我们之前说的 localsplus 的内存布局。
图片
并且我们看到无论是局部变量还是 cell 变量,都是通过数组索引访问的,并且索引在编译时就确定了,以指令参数的形式保存在字节码指令集中。
接下来执行偏移量为 18 和 20 的两条指令,它们都是 LOAD_CLOSURE。
LOAD_CLOSURE 执行完毕后,接着执行 BUILD_TUPLE,将 cell 变量从栈中弹出,构建元组。然后继续执行 24 LOAD_CONST,将内层函数 inner 对应的 PyCodeObject 压入运行时栈。
接着执行 26 MAKE_FUNCTION,将栈中元素弹出,分别是 inner 对应的 PyCodeObject 和一个元组,元组里面包含了 inner 使用的外层函数的变量。当然这里的变量已经不再是普通的变量了,而是 cell 变量,它内部的 ob_ref 字段才是我们需要的。
等元素弹出之后,开始构建函数,我们看一下 MAKE_FUNCTION 指令,它的指令参数为 8。
所以 PyFunctionObject 再一次承担了工具人的角色,创建内层函数 inner 时,会将包含 cell 变量的元组赋值给 func_closure 字段。此时便将内层函数需要使用的变量和内层函数绑定在了一起,而这个绑定的结果我们就称之为闭包。
但是从结构上来看,闭包仍是一个函数,所谓绑定,其实只是修改了它的 func_closure 字段。当函数创建完毕后,localplus 的结构变化如下。
图片
函数即变量,对于函数 some_func 而言,内层函数 inner 也是一个局部变量,由于符号 inner 位于符号表中索引为 1 的位置。因此当函数创建完毕时,会修改 localplus[1],让它保存函数的地址。不难发现,对于局部变量来说,如何访问内存在编译阶段就确定了。
函数内部的 func_closure 字段指向一个元组,元组里面的每个元素会指向 PyCellObject。
调用闭包
闭包的创建过程我们已经了解了,我们用 Python 代码再解释一下。
调用 inner 函数时,外层函数 some_func 已经执行结束,但它的局部变量 name 和 age 仍可被内层函数 inner 访问,背后的原因我们算是彻底明白了。
因为 name 和 age 被内层函数引用了,所以虚拟机将它们封装成了 PyCellObject *,即 cell 变量,而 cell 变量指向的 cell 对象内部的 ob_ref 字段对应原来的变量。当创建内层函数时,将引用的 cell 变量组成元组,保存在内层函数的 func_closure 字段中。
所以当内层函数在访问 name 和 age 时,访问的其实是 PyCellObject 的 ob_ref 字段。至于变量 name 和 age 对应哪一个 PyCellObject,这些在编译阶段便确定了,我们看一下内层函数 inner 的字节码指令。
图片
函数在执行时会创建栈帧,我们上面看到的 localsplus 是外层函数 some_func 对应的栈帧的 localsplus。而内层函数 inner 执行时,也会创建栈帧,然后在栈帧中执行字节码指令。
首先第一个指令是 COPY_FREE_VARS,看一下它的逻辑。
处理完之后,localplus 的布局如下,注意:此时是内层函数对应的 localplus。
图片
在构建内层函数时,会将 cell 变量打包成一个元组,交给内层函数的 func_closure 字段。然后执行内层函数创建栈帧的时候,再将 func_closure 中的 cell 变量拷贝到 localsplus 的第三段内存中。当然对于内层函数而言,此时它应该叫做 free 变量。
而在调用内层函数 inner 的过程中,当引用外层作用域的符号时,一定是到 localsplus 里面的 free 区域(第三段内存)去获取对应的 PyCellObject *,然后通过内部的 ob_ref 进而获取符号对应的值。至于 name 和 age 分别对应哪一个 PyCellObject,这些都体现在字节码指令参数当中了。
然后我们再来看看 free 变量是如何加载的,它由 LOAD_DEREF 指令完成。
这里再补充一点,我们说 localplus 是一个连续的数组,只是按照用途被划分成了四个区域:保存局部变量的内存空间、保存 cell 变量的内存空间、保存 free 变量的内存空间、运行时栈。
但对于当前的内层函数 inner 来说,它是没有局部变量和 cell 变量的,所以 localsplus 开始的位置便是 free 区域。
当然不管是局部变量、cell 变量,还是 free 变量,它们都按照顺序保存在 localplus 中,并且在编译阶段便知道它们在 localsplus 中的位置。比如我们将内层函数 inner 的逻辑修改一下。
图片
在 inner 里面创建了三个局部变量,那么它的字节码会变成什么样子呢?这里我们直接看 print 函数执行时的字节码即可。
图片
因为 inner 里面没有函数了,所以它不存在 cell 变量,里面只有局部变量和 free 变量。
图片
所以虽然我们说 localplus 被分成了四份,但是 cell 区域和 free 区域很少会同时存在。对于外层函数 some_func 来说,它没有 free 变量,所以 free 区域长度为 0。而对于内层函数 inner 来说,它没有 cell 变量,所以 cell 区域长度为 0。
只有函数的里面存在内层函数,并且外面存在外层函数,那么它才有可能同时包含 cell 变量和 free 变量。
但为了方便描述,我们仍然认为 localplus 被分成了四个区域,只不过对于外层函数 some_func 而言,它的 free 区域长度为 0;对于 inner 函数而言,它的 cell 区域长度为 0。
当然这些都是概念上的东西,大家理解就好。但不管在概念上 localplus 怎么划分,它本质上就是一个 C 数组,是一段连续的内存,用于存储局部变量、cell 变量、free 变量(这三种变量不一定同时存在),以及作为运行时栈。
最重要的是,这三种变量都是基于数组实现的静态访问,并且怎么访问在编译阶段就已经确定,因为访问数组的索引会作为指令参数存储在字节码指令集中。
- 比如访问变量 a,底层会访问 localplus[0];
- 比如访问变量 age,底层会访问 localplus[3]->ob_ref;
这便是静态访问。
小结
本篇文章我们就介绍了闭包,比想象中的要更加简单。因为闭包仍是一个函数,只是将外层作用域的局部变量变成了 cell 变量,然后保存在内部的 func_closure 字段中。
然后执行内层函数的时候,再将 func_closure 里的 PyCellObject * 拷贝到 localplus 的 free 区域,此时我们叫它 free 变量。但不管什么变量,虚拟机在编译时便知道应该如何访问指定的内存。