编程语言如何实现闭包

闭包是什么

这里偷懒抄一下维基百科的定义:

计算机科学中,闭包(英语:Closure),又称词法闭包(Lexical Closure)或函数闭包(function closures),是在支持头等函数的编程语言中实现词法绑定的一种技术。闭包在实现上是一个结构体,它存储了一个函数(通常是其入口地址)和一个关联的环境(相当于一个符号查找表)。

简单地说,闭包是一个在函数内定义的函数,并且闭包能够捕获定义处函数中所拥有的变量(包括全局变量和局部变量)。

如果一个语法只能在函数内定义函数,却无法使用上层函数的局部变量,那么它就不是闭包语法。这种方式只不过是缩减了函数名的命名空间,仅仅能减少命名冲突罢了。例如下面的rust语句就不是闭包(rust通过其他语法实现闭包)。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
fn func1() {
let n = 100;
fn add(a: i32, b: i32) -> i32 {
// println!("n = {}", n) // 这行语句无法被编译,因为add只是一个普通函数,不能捕获环境中的局部变量n
a + b
}
add(4, 6);
}
fn func2() {
let n = 100;
let add = |a: i32, b: i32| -> i32 {
println!("n = {}", n); // 这里可以被编译,这是个闭包语法,能捕获环境中的局部变量n
a + b
};
add(4, 6);
}

那么,闭包应该如何实现呢,我们可以通过C++、Rust、Golang、Python这几个语言中的行为或实现来观察其究竟使用什么方式实现闭包。

解释型语言中的编译模块

通常所说的解释型语言逐行解释执行,不进行编译是不正确的。目前主流解释型语言的官方实现,除了bash部分等shell脚本语言外,都是会先编译为字节码,再由解释器执行的。所以当本文提及编译器时,请注意也包含了Python和Lua,只不过它们的编译器内置在解释器内。

闭包的组成

计算机并不区分程序和数据,但人类设计的系统和程序会区分,并且将程序和数据分开存储,闭包也是这样。

闭包有两个核心组成部分:闭包函数的代码和其捕获的数据。其中代码段由编译器在编译期生成,而数据则在运行时捕获。

闭包如何捕获变量

熟悉C++的读者会知道,闭包捕获变量有两种捕获方式:值捕获和引用捕获。不过在一些语言中,并没有值捕获这种方式,例如Python、Lua。

值得注意的是Go,作为一个保留了指针的语言,Go也不提供值捕获能力,如果需要值捕获需要将变量作为闭包的参数传入。

值捕获

我们来看下面的C++代码。该代码使用C++11编译。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
#include <iostream>
#include <functional>

std::function<int()> make_closure(int x) {
auto inner = [x]()mutable {
return ++x;
};
x++;
return inner;
}

int main() {
auto closure1 = make_closure(100);
auto closure2 = make_closure(200);

int c1r1 = closure1();
int c2r1 = closure2();

int c1r2 = closure1();
int c2r2 = closure2();

std::cout << "closure1() = " << c1r1 << std::endl;
std::cout << "closure2() = " << c2r1 << std::endl;

std::cout << "closure1() = " << c1r2 << std::endl;
std::cout << "closure2() = " << c2r2 << std::endl;
return 0;
}

上面这段代码为值捕获,其输出如下。

1
2
3
4
closure1() = 101
closure2() = 201
closure1() = 102
closure2() = 202

可以看到闭包closure1调用后返回了x自增后的值,第二次调用后x继续自增,而closure2的调用则是与closure1的调用结果相互独立,并不会互相影响。这里我们可以看出,实现上,闭包会将捕获的局部变量复制到自身内部成为其组成部分,从而能让闭包保存自身所捕获变量的状态。

另外我们还发现,make_closure最后的x++并没有对闭包捕获的x造成影响,从这里我们可以看到,对于值捕获的闭包而言,其捕获变量的时机就是在其所定义的位置。

我们可以用C语言模拟这种闭包行为。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
#include <stdio.h>
#include <malloc.h>
struct ClosureEnv
{
int env1;
};

struct Closure
{
int (*function)(struct ClosureEnv *env);
struct ClosureEnv env;
};

int closure_function(struct ClosureEnv *env)
{
return ++env->env1;
}

struct Closure *make_closure(int x)
{
struct Closure *inner = malloc(sizeof(struct Closure));

inner->function = closure_function,
inner->env.env1 = x;

x++;
return inner;
}

int main()
{
struct Closure *closure1 = make_closure(100);
struct Closure *closure2 = make_closure(200);

printf("closure1() = %d\n", closure1->function(&closure1->env));
printf("closure1() = %d\n", closure1->function(&closure1->env));

printf("closure2() = %d\n", closure2->function(&closure2->env));
printf("closure2() = %d\n", closure2->function(&closure2->env));
return 0;
}

为了实现这个闭包,我们需要手动构造闭包的环境,并且在外部写一个闭包的函数体。而支持闭包的语言可以通过模板或者泛型的方式帮助我们自动完成这些枯燥的过程,并且省去传入的参数。

多个闭包共享同个变量

从上面的代码可以看到,对于值捕获的闭包情况,每个闭包都会有各自捕获的值的的拷贝,但是实际编程中很有可能遇到同一个函数内使用两个或更多闭包,如果使用值捕获的方式,那么捕获的变量就无法共享。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
#include <iostream>
#include <functional>

std::pair<std::function<int()>, std::function<int()>> make_closure(int x) {
auto inner = [x]()mutable {
return ++x;
};
auto inner2 = [x]()mutable {
return ++x;
};
x++;
return std::make_pair(inner, inner2);
}

int main() {
auto closures = make_closure(0);
auto closure1a = closures.first;
auto closure1b = closures.second;

std::cout << "closure1a() = " << closure1a() << std::endl;
std::cout << "closure1b() = " << closure1b() << std::endl;
return 0;
}

大家可以自己参考上面的C代码,想想这里的C代码模拟实现是什么样子的。而这里的C++代码,输出结果为

1
2
closure1a() = 1
closure1b() = 1

但是大多数时候我们会需要不同闭包间共享同一个局部变量,并且还能把闭包内对局部变量的修改反映到闭包外。事实上,这是大多数语言,如Go、Python、JS的默认行为。这些被共享的变量,通常被称为freevars,即自由变量。下面举一个Python的例子。

1
2
3
4
5
6
7
8
9
10
def outer(n):
def getter():
return n
def setter(m):
nonlocal n
n = m
return getter, setter
g, s = outer(100)
s(200)
print('g() = {}'.format(g())) # g() = 200

引用捕获

C++中的引用捕获

为了让多个闭包能够共享同一变量,C++还提供了引用捕获。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
#include <iostream>


void test_closure(int x) {
auto inner = [&x]()mutable {
return ++x;
};
auto inner2 = [&x]()mutable {
return ++x;
};
std::cout << inner() << std::endl;
std::cout << inner2() << std::endl;
}

int main() {
test_closure(100);
return 0;
}

当使用引用捕获时,闭包可以捕获局部变量的引用,这样就可以在多个闭包间共享同一个局部变量了,但这种方式也有缺陷,当对变量的引用超出变量的作用范围时会发生UB。

显然,为了解决这个问题,我们可以将这个局部变量拷贝到由程序员手动管理的内存中,并在闭包不再使用后删除。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
#include <iostream>
#include <functional>
#include <memory>

std::pair<std::function<int()>, std::function<int()>> make_closure(int x) {
std::shared_ptr<int> heap_x = std::make_shared<int>(x);

auto inner = [heap_x]()mutable {
return ++(*heap_x);
};
auto inner2 = [heap_x]()mutable {
return ++(*heap_x);
};
return std::make_pair(inner, inner2);
}

int main() {
auto closures = make_closure(100);
auto closure1a = closures.first;
auto closure1b = closures.second;

std::cout << "closure1a() = " << closure1a() << std::endl;
std::cout << "closure1b() = " << closure1b() << std::endl;
return 0;
}

Rust中的引用捕获

Rust与C++不一样,生命周期和所有权机制并不允许出现像上面C++那样的写法。生命周期禁止将捕获了引用的闭包传到局部变量的生命周期外,而所有权机制则禁止出现多个可变引用,所以为了实现上面的写法,必须要借助RefCell才可以。

Go中的引用捕获

同为编译型语言,Go语言默认提供了引用捕获的方式,让我们看示例代码。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
package main

import "fmt"

type FnG func() int
type FnS func(int)

func testClosure(n int) (FnG, FnS) {
getter := func() int {
return n
}
setter := func(m int) {
n = m
}
return getter, setter
}
func main() {
get, set := testClosure(100)
fmt.Printf("get() = %d\n", get())
set(5400)
fmt.Printf("get() = %d\n", get())
}

打印出来结果为

1
2
get() = 100
get() = 5400

我们并没有在Go代码中看到任何形式指针,这是因为对自由变量的分析被内置在编译器中,编译器的逃逸分析模块会自动查找自由变量,并且根据实际情况选择捕获其引用还是使用值捕获,让原变量保存在栈中。

Go当前的编译器的判断标准是:如果一个变量从未被修改,并且小于128字节,那么就使用值捕获。显然变量从未被修改是一个必须条件,而小于128字节是Go团队认为的合适的大小,如果超过这个大小,可能值捕获提高的效率就不多了。

1
2
3
4
5
6
7
8
9
//file:src/cmd/compile/internal/escape/escape.go@batch.flowClosure
// Capture by value for variables <= 128 bytes that are never reassigned.
n.SetByval(!loc.addrtaken && !loc.reassigned && n.Type().Size() <= 128)
if !n.Byval() {
n.SetAddrtaken(true)
if n.Sym().Name == typecheck.LocalDictName {
base.FatalfAt(n.Pos(), "dictionary variable not captured by value")
}
}

Python中的引用捕获

有些熟悉Python的朋友可能会问,Python万物皆对象,那么所有的变量都是引用,为什么还要单独提引用捕获呢?

通常,Python中的局部变量以PyObject *指针的方式存在于Python函数的栈帧中。而Python是一种使用解释器的语言,其函数调用的方式不会像编译目标为机器码的语言一样受很大程度的平台指令集限制。这意味着想要捕获上层函数的环境,除了像Go一样使用逃逸分析的为其额外分配以外,还可以将整个函数的栈保留下来。不过Python也使用了额外分配的方式。

与Go编译器类似的,Python也使用了类似逃逸分析的方式去分析函数中的变量是否使用了上层函数的变量,不过由于Python的特性,其实现思路与Go差距很大。

首先,Python会检查对变量的赋值操作。当在处理函数时,如果一个变量在函数的任意位置被赋值,则认为该变量在函数中做了声明,会进入函数时会预先为其引用分配栈空间,以加速对象存取。如果这个变量完全没有被赋值,只是被使用了(obj.attr = 1也是被使用而非被赋值),那么Python就认为这是一个外部变量,会递归向上查找定义该变量的位置,如果直到找到全局名称空间仍未找到,则直接将其认定为一个全局变量。而如果在上层函数中找到,则认为其是一个闭包变量。

如果使用globalnonlocal声明了变量,那么Python不会通过是否存在赋值判断其是否应该在栈上创建对象引用,而是直接在全局或上层函数作用域中查找。值得注意的是,global变量不存在的时候,Python会直接创建该变量,且不会报错;但nonlocal变量不存在的时候,Python则会在编译时直接报错,不会执行任何一条代码,因为python需要在编译时处理上层的变量。

当识别出闭包使用的变量后,Python的行为就与其他语言类似了:无论是外部的函数还是闭包,都不在栈中直接访问对象,而是通过对象的引用访问对象。而进入闭包的第一个参数也是通过COPY_FREE_VARS将外部变量的引用传入到内部栈中,然后在栈中使用{LOAD,STORE}_DEREF操作这些变量。

Python的在处理自由变量时是在analyze_block中处理的。这里展开可能比较长,不再细述。

  • 版权声明: 本博客所有文章除特别声明外,著作权归作者所有。转载请注明出处!
  • Copyrights © 2019-2025 Ytyan

请我喝杯咖啡吧~