17 函数闭包:函数式编程的重要支撑
你好,我是海纳。
我们通过前面那么多节课的努力,先后实现了函数调用、内建方法、函数传参等特性。在虚拟机中,函数机制的实现都离不开 FunctionObject 和 FrameObject 这两个对象。
有了 FunctionObject,一个函数就可以像普通的整数或者字符串那样,作为参数传递给另外一个函数,也可以作为返回值被传出函数。所以,在 Python 语言中,函数也和整数类型、字符串类型一样,是第一类公民(first class citizen)。
把函数作为第一类公民,使新的编程范式成为可能,但它也引入了一些必须要解决的问题,例如自由变量和闭包的问题。这节课我们就先来实现它。
函数闭包
在函数中查找变量的时候,遵循 LEBG 规则。其中 L 代表局部变量,E 代表闭包变量(Enclosing),G 代表全局变量,B 则代表虚拟机内建变量。
在第 11 课,我们提到了全局变量和局部变量,但当时没有解释闭包变量是什么,这节课我们专门研究闭包变量的功能和影响。我们先来看一个最简单的例子。
运行这个例子,最后一行会打印出 2。
首先,调用 func 的时候,得到返回值是在 func 函数内部定义的函数 say。所以变量 f(第 9 行)指向的是函数 say,调用它的时候就会打印 2(第 10 行)。
也就是说,当 say 函数在 func 函数的定义之外执行的时候,依然可以访问到 x 的值。这就好像在定义 say 函数的时候,把 x 和 say 打包在一起了,我们把这个包裹叫做闭包(closure)。
我们再把这段代码翻译成 pyc 文件,然后通过 show_file 工具查看它的内容。我给出了 func 函数的字节码,你可以看一下。
code
argcount 0
nlocals 1
stacksize 3
flags 0003
code ...
2 0 LOAD_CONST 1 (2)
2 STORE_DEREF 0 (x)
4 4 LOAD_CLOSURE 0 (x)
6 BUILD_TUPLE 1
8 LOAD_CONST 2 (<code object say>)
10 LOAD_CONST 3 ('func.<locals>.say')
12 MAKE_FUNCTION 8 (closure)
14 STORE_FAST 0 (say)
7 16 LOAD_FAST 0 (say)
18 RETURN_VALUE
.....
names ()
varnames ('say',)
freevars ()
cellvars ('x',)
这里出现了两个新的字节码:STORE_DEREF 和 LOAD_CLOSURE。还有 func 所对应的 CodeObject 的 cellvars 也不为空,这是我们第一次遇到这种情况。
cellvars 里的变量是在本函数中定义,在内部函数中被引用的。在这个例子中,只有一个 x,说明内部函数所引用的本地定义变量只有 x 一个。这种会被内部函数引用的变量就是 cell 变量。
接下来,我们从头开始完整地梳理一遍字节码。STORE_DEREF 是为 x 赋值(第 8 行),因为 x 是 cell 变量,Python 专门为 cell 变量引入了一类新的字节码。
然后虚拟机通过 LOAD_CLOSURE 又把 x 加载到操作数栈上(第 10 行),接下来 BUILD_TUPLE 参数为 1,代表了创建一个列表,列表中只有一个元素,那就是 x。
接着就是加载 CodeObject 到栈顶(第 12 行),然后加载一个字符串到栈顶(第 13 行),这个字符串代表了函数的全名,或者叫做限定名称。限定名会把函数所在的命名空间都带上,例如这个例子中的 say 函数就是定义在 func 函数中,作为它的局部变量。所以,它的全量限定名就是 “func.<locals>.say”
。
最后执行 MAKE_FUNCTION 创建一个 FunctionObject(第 14 行)。这个例子里的参数有点特殊,它的值是 0x8。
我们知道 MAKE_FUNCTION 指令的操作数代表了创建函数对象时提供的默认参数的个数,当操作数为 1 时,代表要创建的函数带有默认参数,而且默认参数的值以列表的形式提供。
Python 3.8 则采用 0x8 这个特殊值来代表当前创建的函数对象是一个闭包对象,而不是一个普通的函数对象。
MAKE_FUNCTION 指令会把刚才放在栈顶的那个 cell 变量列表也打包塞进 FunctionObject 中。这种把外部定义的变量一起打包的情况就是闭包。理解了这些字节码的具体动作以后,我们就能实现它们了。
闭包功能的总体设计
闭包的功能比较复杂,所以在动手实现之前,我们先来完成总体的架构设计。这里我举一个具体的例子,配合多张图片来说明闭包功能的执行步骤。
步骤一,在 func 函数中定义 x 变量,使用的是 STORE_DEREF。显然,这里需要为 func 函数的执行环境添加 cell 变量表,以便把 x 的值存放在这里。实际的设计中,我们在 FrameObject 里添加一个名为 _closure 的变量表,来存放 cell 变量。
步骤二,在定义 foo 函数之前,虚拟机需要使用 LOAD_CLOSURE 把 x 变量加载到栈顶,这一步不能把 x 所代表的整数对象直接加载到栈顶。而是应该把 x 所在的变量表和它的下标保存在 CellObject 里。
步骤三,使用 MAKE_FUNCTION 创建闭包函数。cell 变量会被打包进 foo 函数对应的 FunctionObject 中。从 func 函数的 FrameObject 到 foo 函数的 FrameObject,需要有一个“邮递员”来传送这些 cell 变量。而这正是 MAKE_FUNCTION 要做的事情。你可以看一下我给出的示意图。
步骤四,在 foo 函数的 FrameObject 里,通过 LOAD_DEREF 从 CellObject 中读取 x 的真实取值,并且把它加上 1,再使用 STORE_DEREF 修改变量 x 的值。假如这个时候,x 的值在 foo 函数外被修改了,由于 cell 变量里保存的是变量表和下标,所以这里的 LOAD_DEREF 也一样可以看到。
REF 是 reference 的缩写,是C++语言中“引用”的意思,DEREF 正是解引用的意思。而 cell 变量的作用确实和C++语言里的“引用”功能差不多,所以这个名字非常恰当。
搞明白了这些关键步骤,我们就可以动手实现闭包函数的功能了。
首先,我们来实现 CellObject,也就是引用的能力。
class CellObject : public HiObject {
friend class CellKlass;
private:
HiList* _table;
int _index;
public:
CellObject(HiList* l, int i);
HiObject* value();
void set_value(HiObject* o);
};
CellObject 类包含两个成员变量,_table 代表变量所存储的变量表,_index 记录了变量在变量表中的下标。
value 方法用来从变量表里取出变量,set_value 则用于更新变量表中的值。你可以看一下CellObject 的具体实现。
CellObject::CellObject(HiList* t, int i) :
_table(t), _index(i) {
set_klass(CellKlass::get_instance());
}
HiObject* CellObject::value() {
return _table->get(_index);
}
void CellObject::set_value(HiObject* o) {
return _table->set(_index, o);
}
接下来,在 FrameObject 中引入了 closure 这个域,来记录所有的 cell 变量。
FrameObject::FrameObject(CodeObject* codes) {
//....
_closure = nullptr;
HiList* cells = _codes->_cell_vars;
if (cells && cells->size() > 0) {
_closure = new HiList();
for (int i = 0; i < cells->size(); i++) {
_closure->append(nullptr);
}
}
if (func->closure() && func->closure()->size() > 0) {
if (_closure == nullptr)
_closure = func->closure();
else {
_closure = _closure->add(func->closure())->as<HiList>();
}
}
//....
}
如果有多级嵌套的情况,每一级都有可能出现 cell 变量,所以对于某一个内层的函数来说,它所能使用的 cell 变量可能来自于本层函数,也有可能来自于外层函数。
Python 虚拟机在执行内层函数的时候,会把它们叠加在一起,按照从外到内的顺序排放,STORE_DEREF 等指令也会按照这个顺序生成操作数。
所以,我们在创建 FrameObject 的时候,也要按照这样的顺序把变量摆放好。如果本层函数没有定义新的 cell 变量,而外层函数有定义,那就直接使用外层函数的变量表就可以了(第 16 行)。如果本层函数也有定义 cell 变量,那么本层函数的 cell 变量放在前面,外层的放在后面(第 18 行)。注意这里的 add 方法是列表相加,而不是添加一个元素。
第三步,实现 STORE_DEREF 指令。
void Interpreter::eval_frame() {
...
while (_frame->has_more_codes()) {
unsigned char op_code = _frame->get_op_code();
...
switch (op_code) {
...
case ByteCode::STORE_DEREF:
v = _frame->closure()->get(op_arg);
w = POP();
if (v == nullptr || v->klass() != CellKlass::get_instance()) {
_frame->closure()->set(op_arg, w);
}
else {
v->as<CellObject>()->set_value(w);
}
break;
...
}
}
}
STORE_DEREF 的操作数是变量在变量表中的序号。在修改 cell 变量之前,先从变量里取出它的值(第 9 行)。如果这个变量是一个空指针或者是普通变量,这就意味着这个 cell 变量是在当前函数中定义的(第 12 至 14 行),因为这个变量还没有通过 LOAD_CLOSURE 变成 CellObject 传递给内层函数。
如果这个变量是一个 CellObject,就说明变量是在外层函数中定义的,对这个变量的修改就不能在当前栈帧的 cell 变量表里进行。而是应该通过 set_value 方法修改定义它的地方(第 16 行)。
第四步,实现 LOAD_CLOSURE 指令,它需要一种新的数据结构,这种数据结构可以记录 cell 变量所在的表和它在表中的序号。通过这种方式,我们模拟了“引用”这一机制。
void Interpreter::eval_frame() {
...
while (_frame->has_more_codes()) {
unsigned char op_code = _frame->get_op_code();
...
switch (op_code) {
...
case ByteCode::LOAD_CLOSURE:
v = _frame->closure()->get(op_arg);
if (v == NULL) {
v = _frame->get_cell_from_parameter(op_arg);
_frame->closure()->set(op_arg, v);
}
if (v->klass() == CellKlass::get_instance()) {
PUSH(v);
}
else
PUSH(new CellObject(_frame->closure(), op_arg));
break;
...
}
}
}
HiObject* FrameObject::get_cell_from_parameter(int i) {
HiObject* cell_name = _codes->_cell_vars->get(i);
i = _codes->_var_names->index(cell_name);
return _fast_locals->get(i);
}
这段代码先从 closure 表里取出对应序号的对象(第 9 行)。如果取出来是空值,那就说明这个值不是局部变量,而是一个参数。我们来看一个例子。
这段代码中使用的 x 就出现在了入参中,而不是局部变量。这种情况下,我们先把这个 cell 变量从参数列表中取出来,再存入到 closure 表中。这样,LOAD_CLOSURE 指令就可以直接使用 closure 指针和序号值来构建 CellObject 了。get_cell_from_parameter 实现了这个功能。
如果从 closure 中找不到相应的变量,那就说明这个变量并不是由当前函数定义的,也就是说它并不来自于 STORE_CLOSURE 指令,而是来自外部函数的参数,所以我们在 closure 中找不到变量,就会转而去外部函数的参数列表里查找这个变量(LOAD_CLOSURE 的 第 11 行)。
最后一步,补全 MAKE_FUNCTION 的功能。当操作数为 0x8 的时候,代表需要向 FunctionObject 里传递 cell 变量表,这是为了把当前函数中定义的 cell 变量表传给被调用的那个函数。
void Interpreter::eval_frame() {
...
while (_frame->has_more_codes()) {
unsigned char op_code = _frame->get_op_code();
...
switch (op_code) {
...
case ByteCode::MAKE_FUNCTION:
w = POP(); // function name
v = POP();
fo = new FunctionObject(v);
fo->set_globals(_frame->globals());
if (op_arg & 0x8) {
fo->set_closure(POP()->as<HiList>());
}
op_arg &= 0x7;
if (op_arg == 1) {
HiList* t = POP()->as<HiList>();
args = new ArrayList<HiObject*>();
for (int i = 0; i < t->size(); i++) {
args->add(t->get(i));
}
}
fo->set_default(args);
PUSH(fo);
if (args != NULL) {
delete args;
args = NULL;
}
break;
...
}
}
}
在这里我们又一次请出了 FunctionObject 这个邮递员,在此之前,我们已经把全局变量(第 12 行)和默认参数(第 26 行)都打包进 FunctionObject 里,当真正执行这个函数的时候,才会由这个函数对象创建 FrameObject。
到这里为止,闭包的功能就实现完成了,在这个基础之上,我们再来研究一种 Python 中特有的语法:函数修饰器(decorator)。
函数修饰器
Python 的函数修饰器是一种特殊类型的函数,它接受另一个函数作为输入参数。这意味着你可以在修饰器内部访问和操作这个被传入的函数。
装饰器函数执行后,会返回一个新的函数对象。这个新函数通常会在执行原始函数之前或之后添加额外的操作,比如记录日志、性能测试、权限验证等,但最终还是会调用原始函数。
当使用 @decorator 语法应用装饰器到一个函数上的时候,Python 会用装饰器返回的新函数来替换原始函数。这样一来,每当尝试调用原始函数的时候,实际上是调用了装饰器返回的那个新函数。这里我给你一个具体的例子,你可以看一下。
def call_cnt(fn):
cnt = 0
def inner_func(*args):
nonlocal cnt
cnt += 1
print(cnt)
return fn(*args)
return inner_func
@call_cnt
def add(a, b = 2):
return a + b
print add(1, 2)
print add(2, 3)
在这个例子中,call_cnt 作为一个函数修饰器可以用来统计某一个方法被调用的次数。例如,使用 call_cnt 修饰了 add 方法以后,每次调用 add 方法,计数器都会加 1,并且计数的值会被打印出来。
实际上,上面这段代码和下面这行代码是完全等价的,修饰器不过是以下函数调用的一种语法上的简写。我们把这种用于简化代码的语法叫做语法糖。
也就是说,虚拟机执行 call_cnt 所得到的返回值,替换掉了原来的 add 方法。在这个新的方法里,可以进行计数器加一和打印计数器的值,并且调用老的 add 方法,以保持逻辑上的完全兼容。
可以看到,在这个例子中,最重要的就是 call_cnt 的返回值本质上是一个闭包,所以当我们实现了闭包的功能以后,函数的修饰器功能也就完成了。
要真正让这个例子成功运行,还需要补全两个字节码,分别是BUILD_TUPLE_UNPACK_WITH_CALL 和 CALL_FUNCTION_EX。
前者的作用是把扩展列表参数都整合到一个列表里。后者的作用是使用栈顶的列表作为参数去调用函数。你可以看我给出的这个例子。
def call_ex(a, b, c, d):
return a + b + c + d
args = [3, 4]
a = [1, ]
b = (2, )
print(call_ex(*a, *b, *args))
你可以自己使用 Python 3.8 执行这个例子,就会看到 BUILD_TUPLE_UNPACK_WITH_CALL 会把 a、b 和 args 三个列表整合成一个列表。
而 CALL_FUNCTION_EX 会把最后整合好的列表作为参数传递给 call_ex 函数。
所以这两个字节码可以这样实现:
void Interpreter::eval_frame() {
...
while (_frame->has_more_codes()) {
unsigned char op_code = _frame->get_op_code();
...
switch (op_code) {
...
case ByteCode::CALL_FUNCTION_EX:
assert(op_arg == 0);
args = POP()->as<HiList>();
fo = static_cast<FunctionObject*>(POP());
build_frame(fo, args, kwargs);
break;
.....
case ByteCode::BUILD_TUPLE_UNPACK_WITH_CALL:
v = POP();
args = new HiList();
op_arg--;
while (op_arg--) {
w = POP();
args->append(w);
args->append(v);
list_extend(args);
v = args->get(0);
args->clear();
}
PUSH(w);
break;
...
}
}
}
因为我们在设计 FrameObject 的时候,就选择了使用列表来传递所有的参数,所以 CALL_FUNCTION_EX 的实现反而变得更简单了,它只需要把列表直接传递给 build_frame 方法就可以了(第 12 行)。
BUILD_TUPLE_UNPACK_WITH_CALL 的操作数代表了要合并的列表的个数。例如 call_ex 接受了三个列表,那么这里的操作数就是 3。所以上述代码采用了循环语句来不断地把列表向前整合,最终成为一个列表(第 21 至 28 行),最后再把整合完的列表放到栈顶(第 29 行)。
完成了这些工作,前面的例子就可以正确执行了。
总结
这节课我们实现了函数闭包的功能。在函数式编程中函数闭包是一个非常常见的概念。闭包本质上是由函数及其相关的引用环境组合而成的实体。简而言之,就是闭包允许一个函数记住并访问它自身作用域以外的变量,即便它的外部作用域已经不再存在的时候也是这样。
闭包通常涉及至少两个函数层次,一个外部函数(封装环境)和一个内部函数(闭包本身)。内部函数可以访问外部函数的变量和参数。在 Python 字节码中,外部函数中定义并且被内部函数使用的变量被记录在 cell_vars 中,我们把它叫做 cell 变量。而对于内部函数,这些变量就是自由变量。自由变量是指在某个作用域内使用的变量,但该变量在这个作用域内并没有被定义或赋值,它的定义存在于当前作用域的外部。
这节课我们实现了 STORE_DEF、LOAD_CLOSURE、LOAD_DEREF 三个字节码,从而实现了完整的闭包功能。最后,我们这节课也通过补齐一些特殊的字节码,最终实现了函数修饰器的功能。到这里,函数的基本功能就已经全部完成了。从下节课开始,我们会把重点转向对象系统的实现。
思考题
你知道丘奇数(church number)吗?请你自己动手使用 Lambda 表达式来实现丘奇数,并在我们的虚拟机上做测试,欢迎你把你的实现分享到评论区,也欢迎你把这节课的内容分享给其他朋友,我们下节课再见!
- 骨汤鸡蛋面 👍(0) 💬(1)
所以可以认为当 一个函数由装饰器修饰时,函数调用对应的字节码由CALL_FUNCTION变为了CALL_FUNCTION_EX,这样就由解释器帮忙把函数调用改为了装饰器封装后的函数?
2024-06-15 - 冯某 👍(0) 💬(0)
记录一下
2024-12-07 - ifelse 👍(0) 💬(0)
学习打卡
2024-11-01 - Geek_3d35fd 👍(0) 💬(0)
文中提到的STORE_CLOSURE 在源码中并不存在,是不是STORE_DEREF?
2024-09-28 - Geek_66a783 👍(0) 💬(0)
gitee上LOAD_DEREF字节码的实现似乎有点问题,会导致以下代码无法运行: def foo(x): # x已经被提升为了cell variable,但是在函数栈帧的_closure上还没有记录在案, # 因此若虚拟机通过Load_Deref字节码访问将会导致出错! print(x) def bar(): print(x) return bar foo("Hello World") 这是我的修改版本: case ByteCode::LOAD_DEREF: { v = _frame->closure()->get(op_arg); if (v == nullptr) { v = _frame->get_cell_from_parameter(op_arg); _frame->closure()->set(op_arg, v); } if (v->klass() == CellKlass::get_instance()) { v = v->as<CellObject>()->value(); } PUSH(v); break; }
2024-09-21