Go语言中的闭包

golang closure function

Posted by alovn on February 6, 2021

funcval

Go语言中函数是头等对象,可以作为参数传递,可以做函数的返回值,也可以绑定到变量,Go语言称这样的参数为 function value。

函数的指令在编译期间生成,而 function value 本质上是一个指针,但是并不直接指向函数指令入口,而是指向一个 runtime.funcval 结构体:

1
2
3
4
5
//src/runtime/runtime2.go
type funcval struct {
    fn uintptr
    // variable-size, fn-specific data here
}

这个结构体利只有一个地址,就是这个函数指令的入口地址。

看个例子:

1
2
3
4
5
6
7
8
9
10
11
12
func A(i int) {
    i++
    fmt.Println(i)
}
func B() {
    f1 := A
    f1(1)
}
func C() {
    f2 := A
    f2(1)
}

A 被赋值给 f1 和 f2 两个变量,这种情况编译器会做出优化,让 f1 和 f2 共用一个 funcval 结构体,通过 fn 指针拿到 A 函数入口地址,然后跳转执行。

既然只要有函数地址就能调用,为什么要通过一个 funcval 结构体包装这个地址,然后使用一个二级指针来调用呢?这里主要是为了处理闭包的情况,

闭包函数

维基百科这样定义闭包:

闭包-wikipedia

在计算机科学中,闭包(英语:Closure),又称词法闭包(Lexical Closure)或函数闭包(function closures),是在支持头等函数的编程语言中实现词法绑定的一种技术。闭包在实现上是一个结构体,它存储了一个函数(通常是其入口地址)和一个关联的环境(相当于一个符号查找表)。环境里是若干对符号和值的对应关系,它既要包括约束变量(该函数内部绑定的符号),也要包括自由变量(在函数外部定义但在函数内被引用),有些函数也可能没有自由变量。闭包跟函数最大的不同在于,当捕捉闭包的时候,它的自由变量会在捕捉时被确定,这样即便脱离了捕捉时的上下文,它也能照常运行。捕捉时对于值的处理可以是值拷贝,也可以是名称引用,这通常由语言设计者决定,也可能由用户自行指定(如C++)。

这里有两个关键点:

  1. 其一:必须要有在函数外部定义,但在函数内部引用的『自由变量』。
  2. 其二:脱离了形成闭包的上下文,闭包也能照常使用这些自由变量。

就像下面这个例子:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
func create() func() int {
    c := 2
    return func() int {
        return c
    }
}
func main() {
    f1 := create()
    f2 := create()
    fmt.Println(f1())
    fmt.Println(f2())
}
//Output:
//2
//2

函数 create 的返回值是一个函数,但这个函数内部使用了外部定义的变量 c,即使 create 执行结束,通过 f1 和 f2 依然能够正常调用这个闭包函数,并使用在 create 函数中定义的局部变量 c。所以这里符合闭包的定义,通过称这个变量 c 为捕获变量。闭包函数的指令自然也在编译阶段生成,但因为每个闭包对象都要保存自己的捕获变量,所以要到执行阶段才创建对应的闭包对象

捕获列表

每执行 create 函数时,会在堆上分配一个 funcval 结构体,fn 指向闭包函数入口,除此之外还有一个捕获列表,这里只捕获一个变量 c。通过 f1 和 f2 调用闭包函数,就会找到对应的funcval结构体,拿到同一个函数入口。但是通过 f1 和 f2 调用时使用的捕获列表是不一样的,这就是称闭包为有状态函数的原因了。

那究竟闭包函数是如何找到对应的捕获列表呢?

Go语言通过一个 funcval 结构体调用函数时,会把对应 funcval 结构体地址存入特定寄存器(例如 arm64 平台使用的是DX寄存器),这样在闭包函数中就可以通过寄存器取出 funcval 结构体地址,然后加上相应的偏移来找到每一个被捕获的变量,所以Go语言中闭包就是有捕获列表的 funcval,而没有捕获列表的 funcval 直接忽略这个寄存器的值就好。

再来看看这个捕获列表,它可不是拷贝变量值这么简单。被闭包捕获的变量要在外层函数和闭包函数中表现一致,好像它们在使用同一个变量。为此Go语言编译器针对不同情况做了不同处理。

修改变量

最简单的情况就像上面 create 闭包函数这个例子,被捕获的变量除了初始化赋值外,在任何地方都没有被修改过,所以直接拷贝值到捕获列表中就可以了。

但是除了初始化赋值之外还被修改过呢,那就要再做细分了。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
func create2() (fs []func()) {
    for i := 0; i < 2; i++ {
        fs = append(fs, func() {
            fmt.Println(i)
        })
    }
    return
}
func main() {
    fs := create2()
    for _, f := range fs {
        f()
    }
}
//Output:
//2
//2

在这个例子中被捕获的是局部变量 i,由于被闭包捕获,局部变量 i 会改为堆分配,在栈上只存一个地址。第一次 for 循环:在堆上创建 funcval 结构体,捕获 i 的地址,这样闭包函数就和外层函数操作同一个变量了。i自增1。 第二次 for 循环:再次堆分配一个 funcval 结构,捕获 i 的地址。i再次自增1。 此时满足循环退出条件,create2 函数结束,把返回值拷贝到局部变量 fs。 然后遍历调用闭包函数时,找到各自的捕获列表,被捕获的地址都指向同一个,所以每次都会打印2。

修改参数

闭包导致的局部变量堆分配,也是变量逃逸的一种场景。如果修改并被捕获的是参数,涉及到函数原型,就不能像局部变量那样处理了。

参数依然是通过调用者栈帧传入,但是编译器会把栈上这个参数拷贝到堆上一份,然后外层函数和闭包函数都使用堆上分配的这一个。

修改返回值

如果被捕获的是返回值,处理方式就又有些不同。

调用者栈帧上依然会分配返回值空间,不过闭包的外部函数会在堆上也分配一个,外部函数和闭包函数都使用堆上的这一个。但是在外层函数返回前,需要把堆上的返回值拷贝到栈上的返回值空间。

处理方式虽然多样,但是目标只有一个:就是保持捕获变量在外层函数与闭包函数中的一致性。