楔子
在之前的文章中一直反复提到四个字:名字空间。一段代码执行的结果不光取决于代码中的符号,更多的是取决于代码中符号的语义,而这个运行时的语义正是由名字空间决定的。
名字空间是由虚拟机在运行时动态维护的,但有时我们希望将名字空间静态化。换句话说,我们希望有的代码不受名字空间变化带来的影响,始终保持一致的功能该怎么办呢?随便举个例子:
def login(user_name, password, user):
if not (user_name == "satori" and password == "123"):
return "用户名密码不正确"
else:
return f"欢迎: {user}"
print(login("satori", "123", "古明地觉")) # 欢迎: 古明地觉
print(login("satori", "123", "古明地恋")) # 欢迎: 古明地恋
我们注意到每次都需要输入 username 和 password,于是可以通过使用嵌套函数来设置一个基准值。
def deco(user_name, password):
def login(user):
if not (user_name == "satori" and password == "123"):
return "用户名密码不正确"
else:
return f"欢迎: {user}"
return login
login = deco("satori", "123")
print(login("古明地觉")) # 欢迎: 古明地觉
print(login("古明地恋")) # 欢迎: 古明地恋
尽管函数 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 字段中。
图片
我们看一段代码:
def foo():
name = "古明地觉"
age = 17
gender = "female"
def bar():
nonlocal name
nonlocal age
print(gender)
return bar
print(foo.__code__.co_cellvars) # ('age', 'gender', 'name')
print(foo().__code__.co_freevars) # ('age', 'gender', 'name')
print(foo.__code__.co_freevars) # ()
print(foo().__code__.co_cellvars) # ()
和闭包相关的两个字段是 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 而言不存在内层函数。
我们再看个例子:
def foo():
name = "古明地觉"
age = 17
def bar():
nonlocal name
nonlocal age
gender = "female"
def inner():
nonlocal gender
return inner
return bar
print(foo().__code__.co_cellvars) # ('gender',)
print(foo().__code__.co_freevars) # ('age', 'name')
对于函数 bar 而言,它是函数 inner 的外层函数,同时也是函数 foo 的内层函数。所以它在获取 co_cellvars 和 co_freevars 属性时,得到的元组都不为空。因为内层函数 inner 引用了函数 bar 里面的变量 gender,同时函数 bar 也作为内层函数引用了函数 foo 里的 name 和 age。
那么问题来了,闭包变量所需要的空间申请在哪个地方呢?没错,显然是 localsplus。
在以前的版本中,这个字段叫 f_localsplus,现在叫 localsplus。
localplus 是一个柔性数组,它被分成了四份,分别用于:局部变量、cell 变量、free 变量、运行时栈。
所以闭包变量同样是以静态的方式实现的。
闭包的实现过程
介绍完实现闭包的基石之后,我们可以开始追踪闭包的具体实现过程了,当然还是要先看一下闭包对应的字节码。
import dis
code_string = """
def some_func():
name = "satori"
age = 17
gender = "female"
def inner():
print(name, age)
return inner
func = some_func()
func()
"""
dis.dis(compile(code_string, "", "exec"))
字节码指令如下,为了阅读方便,我们省略了源代码行号。
# ********** 模块对应的字节码 **********
0 RESUME 0
# 加载函数 some_func 对应的 PyCodeObject,压入运行时栈
2 LOAD_CONST 0 ()
# 从栈顶弹出 PyCodeObject,构造 PyFunctionObject,并压入运行时栈
4 MAKE_FUNCTION 0
# 从栈顶弹出 PyFunctionObject,然后使用变量 some_func 保存
6 STORE_NAME 0 (some_func)
8 PUSH_NULL
# 加载全局变量 some_func
10 LOAD_NAME 0 (some_func)
# 调用
12 CALL 0
# 弹出栈顶的返回值,并使用变量 func 保存
20 STORE_NAME 1 (func)
22 PUSH_NULL
# 加载全局变量 func
24 LOAD_NAME 1 (func)
# 调用
26 CALL 0
# 从栈顶弹出返回值,丢弃
34 POP_TOP
# 隐式地 return None
36 RETURN_CONST 1 (None)
# ********** 外层函数 some_func 对应的字节码 **********
Disassembly of :
# 创建 cell 对象 PyCellObject,该指令一会儿说
0 MAKE_CELL 2 (age)
2 MAKE_CELL 3 (name)
4 RESUME 0
# 加载常量 "satori"
6 LOAD_CONST 1 ('satori')
# 注意这里不是 STORE_FAST,而是 STORE_DEREF
# 它的作用肯定是将符号 "name" 和字符串常量绑定起来
# STORE_NAME、STORE_FAST、STORE_DEREF 做的事情是一样的
# 都是将符号和值绑定起来,只是绑定的方式不一样
# 比如 STORE_NAME 是通过字典完成绑定,STORE_FAST 是通过数组完成绑定
# 那么 STORE_DEREF 是怎么绑定的呢?稍后分析
8 STORE_DEREF 3 (name)
# 加载常量 17
10 LOAD_CONST 2 (17)
# 使用变量 age 保存
12 STORE_DEREF 2 (age)
# name 和 age 被内层函数引用了,所以是 STORE_DEREF
# 但 gender 没有,所以它对应的是 STORE_FAST
14 LOAD_CONST 3 ('female')
16 STORE_FAST 0 (gender)
# 加载 cell 变量,压入运行时栈
18 LOAD_CLOSURE 2 (age)
20 LOAD_CLOSURE 3 (name)
# 弹出 cell 变量,构建元组
22 BUILD_TUPLE 2
# 加载函数 inner 对应的 PyCodeObject
24 LOAD_CONST 4 ()
# 构造函数
26 MAKE_FUNCTION 8 (closure)
# 将函数使用 inner 变量保存
28 STORE_FAST 1 (inner)
# return inner
30 LOAD_FAST 1 (inner)
32 RETURN_VALUE
# ********** 内层函数 inner 对应的字节码 **********
Disassembly of :
0 COPY_FREE_VARS 2
2 RESUME 0
# 加载内置变量 print
4 LOAD_GLOBAL 1 (NULL + print)
# 显然它和 LOAD_NAME、LOAD_FAST 的关系也是类似的
# 也是负责加载变量,然后压入运行时栈
14 LOAD_DEREF 1 (name)
16 LOAD_DEREF 0 (age)
# 调用 print 函数
18 CALL 2
# 从栈顶弹出返回值,丢弃
26 POP_TOP
28 RETURN_CONST 0 (None)
字节码的内容并不难,我们来分析一下,这里先分析外层函数 some_func 对应的字节码。
图片
函数 some_func 里面有三个局部变量,但只有 name 和 age 被内层函数引用了,所以开头有两个 MAKE_CELL 指令。参数为符号在符号表中的索引,对应的符号分别为 age 和 name。我们来看一下这个指令是做什么的。
TARGET(MAKE_CELL) {
#line 1394 "Python/bytecodes.c"
// 符号在符号表中的索引,和对应的值在 localplus 中的索引是一致的
// 所以这里会获取变量对应的值,对于当前来说就是 age 和 name 的值
// 但很明显此时 name 和 age 还没有完成赋值,所以结果为 NULL
PyObject *initial = GETLOCAL(oparg);
// 调用 PyCell_New 创建 Cell 对象
PyObject *cell = PyCell_New(initial);
if (cell == NULL) {
goto resume_with_error;
}
// 用 Cell 对象替换掉原来的值
SETLOCAL(oparg, cell);
#line 1909 "Python/generated_cases.c.h"
DISPATCH();
}
// Objects/cellobject.c
PyObject *
PyCell_New(PyObject *obj)
{
PyCellObject *op;
// 为 PyCellObject 申请内存
op = (PyCellObject *)PyObject_GC_New(PyCellObject, &PyCell_Type);
if (op == NULL)
return NULL;
// 增加 obj 指向对象的引用计数,并赋值给 op->ob_ref
op->ob_ref = Py_XNewRef(obj);
_PyObject_GC_TRACK(op);
return (PyObject *)op;
}
// Include/cpython/cellobject.h
typedef struct {
PyObject_HEAD
PyObject *ob_ref;
} PyCellObject;
所以 MAKE_CELL 指令的作用是创建 PyCellObject,对于当前来说,会创建两个 PyCellObejct,它们的 ob_ref 字段分别为 age 和 name。只不过由于 name 和 age 还尚未完成赋值,所以此时为 NULL。
图片
接下来就是变量赋值,这个显然没什么难度,我们只需要看一下 STORE_DEREF 指令。并且也容易得出结论,如果局部变量被内层函数所引用,那么指令将不再是 LOAD_FAST 和 STORE_FAST,而是 LOAD_DEREF 和 STORE_DEREF。
TARGET(STORE_DEREF) {
// 由于在 STORE_DEREF 之前调用了 LOAD_CONST
// 所以这里会获取上一步压入的常量,对于当前来说就是 17 和 "satori"
PyObject *v = stack_pointer[-1];
#line 1463 "Python/bytecodes.c"
// 这里的 oparg 和 MAKE_CELL 的 oparg 的含义是一样的
// 此时会拿到对应的 PyCellObject *
PyObject *cell = GETLOCAL(oparg);
// 获取 PyCellObject 内部的 ob_ref,并减少引用计数
PyObject *oldobj = PyCell_GET(cell);
// 将 PyCellObject 内部的 ob_ref 设置成 v
PyCell_SET(cell, v);
Py_XDECREF(oldobj);
#line 1993 "Python/generated_cases.c.h"
// 栈收缩
STACK_SHRINK(1);
DISPATCH();
}
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,可能有人觉得,这个索引好奇怪啊,我们实际测试一下。
def some_func():
name = "satori"
age = 17
gender = "female"
def inner():
print(name, age)
return inner
print(
some_func.__code__.co_varnames
) # ('gender', 'inner')
我们看到 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。
// 加载 PyCellObject *,即 cell 变量,然后压入运行时栈
TARGET(LOAD_CLOSURE) {
PyObject *value;
#line 179 "Python/bytecodes.c"
value = GETLOCAL(oparg);
if (value == NULL) goto unbound_local_error;
Py_INCREF(value);
#line 66 "Python/generated_cases.c.h"
STACK_GROW(1);
stack_pointer[-1] = value;
DISPATCH();
}
LOAD_CLOSURE 执行完毕后,接着执行 BUILD_TUPLE,将 cell 变量从栈中弹出,构建元组。然后继续执行 24 LOAD_CONST,将内层函数 inner 对应的 PyCodeObject 压入运行时栈。
接着执行 26 MAKE_FUNCTION,将栈中元素弹出,分别是 inner 对应的 PyCodeObject 和一个元组,元组里面包含了 inner 使用的外层函数的变量。当然这里的变量已经不再是普通的变量了,而是 cell 变量,它内部的 ob_ref 字段才是我们需要的。
等元素弹出之后,开始构建函数,我们看一下 MAKE_FUNCTION 指令,它的指令参数为 8。
TARGET(MAKE_FUNCTION) {
PyObject *codeobj = stack_pointer[-1];
PyObject *closure = (oparg & 0x08) ? stack_pointer[...] : NULL;
// ...
// 创建函数对象
PyFunctionObject *func_obj = (PyFunctionObject *)
PyFunction_New(codeobj, GLOBALS());
Py_DECREF(codeobj);
if (func_obj == NULL) {
goto error;
}
// 由于指令参数为 8,所以 oparg & 0x08 为真
if (oparg & 0x08) {
assert(PyTuple_CheckExact(closure));
func_obj->func_closure = closure;
}
if (oparg & 0x04) {
assert(PyTuple_CheckExact(annotations));
func_obj->func_annotations = annotations;
}
if (oparg & 0x02) {
assert(PyDict_CheckExact(kwdefaults));
func_obj->func_kwdefaults = kwdefaults;
}
if (oparg & 0x01) {
assert(PyTuple_CheckExact(defaults));
func_obj->func_defaults = defaults;
}
// ...
DISPATCH();
}
所以 PyFunctionObject 再一次承担了工具人的角色,创建内层函数 inner 时,会将包含 cell 变量的元组赋值给 func_closure 字段。此时便将内层函数需要使用的变量和内层函数绑定在了一起,而这个绑定的结果我们就称之为闭包。
但是从结构上来看,闭包仍是一个函数,所谓绑定,其实只是修改了它的 func_closure 字段。当函数创建完毕后,localplus 的结构变化如下。
图片
函数即变量,对于函数 some_func 而言,内层函数 inner 也是一个局部变量,由于符号 inner 位于符号表中索引为 1 的位置。因此当函数创建完毕时,会修改 localplus[1],让它保存函数的地址。不难发现,对于局部变量来说,如何访问内存在编译阶段就确定了。
函数内部的 func_closure 字段指向一个元组,元组里面的每个元素会指向 PyCellObject。
调用闭包
闭包的创建过程我们已经了解了,我们用 Python 代码再解释一下。
def some_func():
name = "satori"
age = 17
gender = "female"
def inner():
print(name, age)
return inner
func = some_func()
# some_func 调用之后会返回内层函数 inner
# 只不过 inner 的 func_closure 字段保存了 cell 变量
# 而 cell 变量指向的 PyCellObject 对外层作用域的局部变量进行了冻结
# 所以我们也会称呼 inner 函数为闭包,但要知道闭包仍然是个函数
print(func.__name__) # inner
print(func.__class__) #
print(
func.__closure__[0]
) #
print(
func.__closure__[1]
) #
print(func.__closure__[0].cell_contents) # 17
print(func.__closure__[1].cell_contents) # satori | |
调用 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,看一下它的逻辑。
// 将 func_closure 里面的 cell 变量拷贝到 free 区域
TARGET(COPY_FREE_VARS) {
#line 1470 "Python/bytecodes.c"
PyCodeObject *co = frame->f_code;
assert(PyFunction_Check(frame->f_funcobj));
// 获取 func_closure,它指向一个元组,里面保存了 PyCellObject *
PyObject *closure = ((PyFunctionObject *)frame->f_funcobj)->func_closure;
assert(oparg == co->co_nfreevars);
// co_nlocalsplus 等于局部变量、cell 变量、free 变量的个数之和
// 显然 offset 表示 free 变量对应的内存区域
int offset = co->co_nlocalsplus - oparg;
// 将 func_closure 里面的 PyCellObject * 拷贝到 free 区域
for (int i = 0; i < oparg; ++i) {
PyObject *o = PyTuple_GET_ITEM(closure, i);
frame->localsplus[offset + i] = Py_NewRef(o);
}
#line 2010 "Python/generated_cases.c.h"
DISPATCH();
}
处理完之后,localplus 的布局如下,注意:此时是内层函数对应的 localplus。
图片
在构建内层函数时,会将 cell 变量打包成一个元组,交给内层函数的 func_closure 字段。然后执行内层函数创建栈帧的时候,再将 func_closure 中的 cell 变量拷贝到 localsplus 的第三段内存中。当然对于内层函数而言,此时它应该叫做 free 变量。
而在调用内层函数 inner 的过程中,当引用外层作用域的符号时,一定是到 localsplus 里面的 free 区域(第三段内存)去获取对应的 PyCellObject *,然后通过内部的 ob_ref 进而获取符号对应的值。至于 name 和 age 分别对应哪一个 PyCellObject,这些都体现在字节码指令参数当中了。
然后我们再来看看 free 变量是如何加载的,它由 LOAD_DEREF 指令完成。
TARGET(LOAD_DEREF) {
PyObject *value;
#line 1453 "Python/bytecodes.c"
// 加载 PyCellObject *
PyObject *cell = GETLOCAL(oparg);
// 获取 PyCellObject 对象的 ob_ref 字段的值
value = PyCell_GET(cell);
if (value == NULL) {
format_exc_unbound(tstate, frame->f_code, oparg);
if (true) goto error;
}
Py_INCREF(value);
#line 1980 "Python/generated_cases.c.h"
STACK_GROW(1);
stack_pointer[-1] = value;
DISPATCH();
}
这里再补充一点,我们说 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 变量。但不管什么变量,虚拟机在编译时便知道应该如何访问指定的内存。