Golang知识点六、闭包
从本篇文章开始,记录闭包相关的知识点。闭包相关的内容包括两个部分,分别是funcval结构体和闭包结构两部分。
1. funcval结构体
Go语言中,函数是头等对象,可以作为参数传递,也可以作为函数返回值,也可以绑定到变量,Go语言称这样的参数、返回值或变量为function value
函数指令在编译期间生成,而function value
本质上是一个指针,但是并不直接指向函数指令入口,而是指向一个runtime.funcval
结构体,这个结构体里只有一个地址,即函数指令的入口地址。
type funcval struct {
fn uintptr
}
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
结构体,假设编译后A的代码段入口地址为addr1,编译阶段会在只读数据段addr2处分配一个funcval
结构体,其中fn
指向函数A入口地址。函数函数指向阶段,addr2会被赋值给f1和f2,通过f1执行函数,就会通过它存储的地址找到addr2处的funcval
结构体,拿到函数入口地址addr1
并跳转执行。
为什么要通过funcval
结构体来包装这个地址,然后用一个二级指针调用呢? 答案是,为了处理闭包的情况。
2. 闭包结构
闭包的形成,有两个关键点分别是,(1)自由变量:在函数外部定义,在函数内被引用。(2)脱离了形成闭包的上下文,闭包也能照常使用这些自由变量。
2.1. 闭包函数
func create() func() int {
c := 2
return func() int {
return c
}
}
func main() {
f1 := create()
f2 := create()
fmt.Println(f1())
fmt.Println(f2())
}
函数create
的返回值是一个函数,在返回函数的内部,使用了外部定义的变量c。即使create
执行结束,通过f1和f2依然可以正常调用这个闭包函数,并使用定义在create
内的局部变量c
。通常称变量c
为捕获变量。
分析执行过程,执行到f1 := create()
。由于每个闭包对象都要保存自己的捕获变量,所以要到执行阶段才创建对应的闭包对象。 create
函数会在地址为addr2的堆上分配一个funcval
结构体,指向闭包函数入口;另外,堆上还有一个捕获列表(这里只捕获一个变量c
),addr2就作为返回值写入main函数栈帧返回值空间。
执行到f2 := create()
。create
函数就会在堆上addr3处再次创建一个funcval
结构体和一个捕获变量,之后将addr3作为返回值,写入main函数栈帧返回值空间。
f1被赋值为addr2,f2被赋值为addr3。通过f1和f2调用闭包函数,就会找到各自对应的funcval
结构体,拿到同一个函数入口。通过f1调用时要使用addr2处的捕获列表,通过f2调用时,要使用addr3处的捕获列表。正因为如此,也称闭包为有状态的函数。
闭包函数如何找到对应的捕获列表呢?Golang中通过一个funcval
调用函数时,会把对应funcval
结构体地址存入特定寄存器(例如ARM64平台使用的DX寄存器),这样就可以通过寄存器取出funcval
结构体地址,然后加上偏移找到每个捕获的变量。简单来说,闭包就是有捕获列表的funcval
,没有捕获列表的funcval
,直接忽略这个寄存器的值就行。
2.2. 捕获列表
捕获列表不是拷贝变量值这么简单,被闭包捕获的变量,要在外层函数和闭包函数中表现一致。为此,Go语言编译器针对不同情况,做了不同处理。
最简单的例子就是上面的示例,除了初始化赋值,再没有改变过,所以直接拷贝值到捕获列表中就行了。如果捕获变量除了初始化赋值还被修改过,就不能直接拷贝值了。闭包所导致的局部变量堆分配,也是变量逃逸的一种场景。
func create()(fs [2]func()) {
for i := 0; i < 2; i++ {
fs[i] = func() {
fmt.Println(i)
}
}
return
}
func main() {
fs := create()
for i := 0; i < len(fs); i++ {
fs[i]()
}
}
变量逃逸: 局部变量被闭包捕获,会改为堆分配,栈上只存堆上的地址,捕获列表也存堆上地址,这样就能保证捕获变量在外层函数和闭包函数中的一致性。
参数堆分配: 如果修改并被捕获的是参数,涉及到函数原型,就不能像局部变量那样处理了。参数依然通过调用者栈帧传入,但是编译器会把栈上这个参数拷贝到堆上一份,然后外层函数和闭包函数都使用堆上分配的这一个。
返回值: 如果被捕获的是返回值,调用者栈帧上依然会分配返回值空间。闭包的外层函数会再堆上也分配一个,外层函数和闭包函数都使用堆上这一个。但是,在外层函数返回前,需要把堆上返回值拷贝到栈上的返回值空间。
处理方式虽然不同,但是目标是一致的,即保持捕获变量在外层函数和在闭包函数中的一致性。