文章目录
GoLang之Function Value、闭包系列二(视频版)
注:本文以Go SDK v1.17进行讲解
1.FuncVal对象介绍
闭包复制的是原对象指针,这就很容易解释延迟引用现象
在汇编层 ,test 实际返回的是 FuncVal 对象,其中包含了匿名函数地址、闭包对象指针。当调 匿名函数时,只需以某个寄存器传递该对象即可
FuncVal { func_address, closure_var_pointer … }
package main
import "fmt"
func test() func() {
x := 100
fmt.Printf("x (%p) = %d\n", &x, x)
return func() {
fmt.Printf("x (%p) = %d\n", &x, x)
}
}
func main() {
f := test()
f()
/*
x (0xc42007c008) = 100
x (0xc42007c008) = 100
*/
}
go中函数是头等对象,可以作为参数传递,可以作函数返回值,也可以绑定到变量。go语言中称这样的参数、返回值或者变量为function value
函数指令在编译期间生成,而function value本质是上一个指针,但是并不直接指向函数的指令入口。而是指向一个runtime.funcval结构体,这个结构体里只有一个地址,就是这个函数指令的入口地址
2.FuncVal对象在函数调用栈中变化
func A(i int) {
i++
fmt.Println(i)
}
func B() {
f1 := A
f1(1)
}
func C() {
f2 := A
f2(1)
}
func main() {
B()//输出2
C()//输出2
}
下面看一个完整的例子,函数A被赋值给f1和f2两个变量,这种情况,编译器会做出优化,让f1和f2共用一个funcval结构体
如果函数A的指令在这addr1那里
编译阶段会在只读数据段分配一个funcval结构体
fn指向函数A指令入口
而funcval结构体它本身的起始地址addr2,会在执行阶段赋给f1和f2
通过f1来执行函数,就会通过它存储的地址找到对应的funcval结构体,拿到函数入口地址,然后调转执行,参数为1,即i++i自增1,下一步输出2;
f2的调用完全相同;
3.闭包对象在函数调用栈中变化(捕获局部变量只初始化赋值)
既然以以上的例子中函数入口地址就能调用 ,为什么要通过funcval结构体包装这个地址,然后使用一个二级指针来调用呢?这里主要是为了处理闭包的情况闭包的两个定义:
第一:必须要有在函数外部定义,但在函数内部引用的“自由变量”
第二:脱离了形成闭包的上下文,闭包也能照常使用这些自由变量
函数create的返回值是一个函数,但这个函数内部使用了外部定义的变量c,即使create函数执行结束,但通过d与f2依然可以正常调用这个闭包函数,并使用定义在create函数内部的局部变量c,所以这里符合闭包的定义,通常这个变量c为捕捉变量;
闭包函数的指令自然也在编译阶段生成,但因为每个闭包对象都要保存自己的捕获变量,所以要到执行阶段才创建对应的闭包对象;
到执行阶段,main函数栈帧有两个局部变量
然后是返回值空间
到create函数帧这里,有一个局部变量c=2
create函数会在堆上分配一个funcval结构体(addr1),fn指向闭包函数入口
除此之外,还有一个捕获列表,这里只捕获了一个变量c
然后这个结构体的起始地址就作为返回值写入返回值空间
所以f1被赋为addr2
下面再次调用create函数,他就会再次创建一个funcval结构体,同样接下来捕获变量c
然后这个起始地址addr3作为返回值写入,最终f2被赋值为addr3
通过f1和f2调用闭包函数,结汇找到各自对应的funcval结构体,拿到同一个函数入口,但是通过f1调用时要使用“ addr2 fn=addr1上面的c=2”这个捕获列表,通过f2调用时要使用“addr3 fn=addr1上面的c=2”这个捕获列表,这就是称闭包有状态的函数的原因
那闭包函数究竟如何找到对应的捕获列表呢,go语言中通过一个function value结构体调用函数时,会把对应的funcval结构体地址存入特定寄存器,例如amd64平台使用的是DX寄存器。这样在闭包函数中,就可以通过寄存器取出funcval结构体的地址,然后加上相应的偏移来找到每一个被捕获的变量。所以go语言中闭包就是有捕获列表的function value,而没有捕获列表的function value直接忽略这个寄存器的值就好了
最后来看看这个捕获列表,他可不是拷贝变量值这么简单;
被闭包捕获变量要在外层函数与闭包函数中表现一致,好像他们在使用同一个变量,为此Go语言编译器针对不同情况做了不同的处理
最简单的情况就像上面那个例子,被捕获的变量除了初始化赋值外(c:=2),在任何地方都没有被修改过,所以直接拷贝值到捕获列表就ok了
4.闭包对象在函数调用栈中变化(捕获局部变量被初始化赋值且修改)
但是如果除了初始化赋值外还被修改过,那就要再做细分了,在下面这个例子中,被捕获的是局部变量i,而且除了初始化赋值(i:=0)外还被修改(i++)过
//返回的是一个func()函数类型的数组
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]()
}
/*输出:
2
2
*/
}
闭包函数指令入口addrf(红色圈圈的那个),main函数栈帧中,局部变量fs是一个长度为2的function value类型数组,返回值为零值
到create函数栈帧,由于被闭包捕获并修改,局部变量i改为堆分配,在栈上只存一个地址
第一次for循环,在堆上创建funcval结构体,捕获i的地址,这样闭包函数就和外层函数操作同一个变量了
返回值第一个元素存储addr0,第一次for循环结束,i自增1
第二次for循环开始,再次堆分配一个funcval,捕获变量i的地址,第二个元素存储addr1,第二次循环结束,i再次自增1
此时满足循环退出条件,reate函数结束,把返回值拷贝到局部变量fs
通过fs[0]调用函数时,把addr0存入寄存器,就可以找到addr0那个位置。闭包函数通过寄存器存储的地址加上偏移找到捕获变量i的地址
fs[1]同理,通过f[s1]调用时,把add1存入寄存器,就可以找到addr1那个位置。闭包函数通过寄存器存储的地址加上偏移找到捕获变量i的地址.被捕获的地址都指向它,所以那里每次都会打印2
闭包导致的局部变量堆分配,也是变量逃逸的一种场景
5.闭包对象在函数调用栈中变化(捕获变量为参数)
如果有修改并被捕获的是参数,涉及到函数原型,就不能像局部变量那样处理了;
参数依然通过调用者栈帧传入,但是编译器会把栈上这个参数拷贝到堆上一份。然后外层函数和闭包函数都使用堆上分配的这一个
6.闭包对象在函数调用栈中变化(捕获变量为返回值)
如果被捕获的是返回值,处理方式又有些不同;
调用者栈帧上依然会分配返回值的空间,不过闭包的外层函数会在堆上也分配一个,外层函数和闭包函数都使用堆上这个,但是在外层函数返回前,需要把堆上的返回值拷贝到栈上的返回值空间,处理方式虽然比较多样,但是目标只有一个,就是保持捕获变量在外层函数与闭包函数中的一致性
7.Debug
7.1Debug(Function Value)
package main
import "fmt"
func A(i int) {
i++
fmt.Println(i)
}
func main() {
f1 := A
f1(1)
f2 := A
f2(1)
}
7.2Debug(捕获局部变量只初始化赋值)
7.3Debug(捕获局部变量被初始化赋值且修改)