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 结构体包装这个地址,然后使用一个二级指针来调用呢?这里主要是为了处理闭包的情况,
闭包函数
维基百科这样定义闭包:
在计算机科学中,闭包(英语:Closure),又称词法闭包(Lexical Closure)或函数闭包(function closures),是在支持头等函数的编程语言中实现词法绑定的一种技术。闭包在实现上是一个结构体,它存储了一个函数(通常是其入口地址)和一个关联的环境(相当于一个符号查找表)。环境里是若干对符号和值的对应关系,它既要包括约束变量(该函数内部绑定的符号),也要包括自由变量(在函数外部定义但在函数内被引用),有些函数也可能没有自由变量。闭包跟函数最大的不同在于,当捕捉闭包的时候,它的自由变量会在捕捉时被确定,这样即便脱离了捕捉时的上下文,它也能照常运行。捕捉时对于值的处理可以是值拷贝,也可以是名称引用,这通常由语言设计者决定,也可能由用户自行指定(如C++)。
这里有两个关键点:
- 其一:必须要有在函数外部定义,但在函数内部引用的『自由变量』。
- 其二:脱离了形成闭包的上下文,闭包也能照常使用这些自由变量。
就像下面这个例子:
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。
修改参数
闭包导致的局部变量堆分配,也是变量逃逸的一种场景。如果修改并被捕获的是参数,涉及到函数原型,就不能像局部变量那样处理了。
参数依然是通过调用者栈帧传入,但是编译器会把栈上这个参数拷贝到堆上一份,然后外层函数和闭包函数都使用堆上分配的这一个。
修改返回值
如果被捕获的是返回值,处理方式就又有些不同。
调用者栈帧上依然会分配返回值空间,不过闭包的外部函数会在堆上也分配一个,外部函数和闭包函数都使用堆上的这一个。但是在外层函数返回前,需要把堆上的返回值拷贝到栈上的返回值空间。
处理方式虽然多样,但是目标只有一个:就是保持捕获变量在外层函数与闭包函数中的一致性。