文章目录
GoLang之Function Value、闭包系列一(书面版)
注:本文以Go SDK v1.17进行讲解
1.Function Value
函数,在GO语言中属于头等对象,可以被当作参数传递、也可以作为函数返回值、绑定到变量。Go语言称这样的参数、返回值和变量为“Function Value”。
Function Value本质上是一个指针,却不直接指向函数指令入口,而是指向runtime.funcval结构体。
//runtime/runtime2.go
type funcval struct {
fn uintptr
// variable-size, fn-specific data here
}
这个结构体从定义上看只有一个地址,这个地址才是函数的指令入口。所以,一个Function Value是以下图所示形式存在的。
2.闭包
维基百科:
在计算机科学中,闭包(英语:Closure),又称词法闭包(Lexical Closure)或函数闭包(function closures),是在支持头等函数的编程语言中实现词法绑定的一种技术。闭包在实现上是一个结构体,它存储了一个函数(通常是其入口地址)和一个关联的环境(相当于一个符号查找表)。环境里是若干对符号和值的对应关系,它既要包括约束变量(该函数内部绑定的符号),也要包括自由变量(在函数外部定义但在函数内被引用),有些函数也可能没有自由变量。闭包跟函数最大的不同在于,当捕捉闭包的时候,它的自由变量会在捕捉时被确定,这样即便脱离了捕捉时的上下文,它也能照常运行。
所以像下面这个例子:
func create() func(){
c := 2
return func(){
fmt.Println(c)
}
}
func main(){
f1 := create()
f2 := create()
f1()
f2()
}
create函数的返回值是一个函数,并且引用了其外层函数定义的局部变量c;而且,即便create函数结束,依然可以通过f1和f2正常执行这个函数并使用定义在create内部的变量c。所以这个返回值符合闭包的定义,而这个自由变量c,通常被称为“捕获变量”。
虽然create函数的返回值函数形成闭包,但是Go语言里并没有把闭包从Function Value中特别区分出来。在Go语言中闭包只是拥有一个或多个捕获变量的Function Value而已。这些捕获变量就是它的捕获列表,就放在对应的funcval结构体的后面。所以上例中,f1和f2的内存布局如下图所示。
每个闭包对象都是一个Function Value,但是各自持有自己的捕获列表,这也是称闭包为“有状态的函数”的原因。
3.调用
通过FunctionValue调用函数时,会把对应的funcval结构体地址存入特定寄存器,例如amd64平台使用的是DX寄存器。
继续使用闭包的示例,通过f1调用闭包函数时,会把f1存储的funcval结构体地址存入寄存器DX,这样在闭包函数的指令中就可以通过这个寄存器存储的地址加上8字节的偏移,就找到f1的捕获变量了。
同样的,通过f2调用闭包函数时,会把f2存储的funcval结构体地址存入寄存器,闭包函数执行时找到的就是f2的捕获变量了。
如果是没有捕获列表的Function Value,直接忽略这个寄存器即可。通过这样的方式,Go语言实现了对Function Value的统一调用。对Function Value有了大致了解,就可以关注一些细节了。
4.静态分配
对于没有捕获列表的Function Value,如果多个变量关联到同一个函数,编译器会做出优化,让它们共用一个funcval结构体。
func A(i int) {
i++
fmt.Println(i)
}
func B(){
f1 := A
f1(1)
}
func C(){
f2 := A
f2(1)
}
像上面这种情况,编译阶段会创建一个funcval结构体放到只读数据段,而执行阶段,f1和f2都会使用它.
5.捕获列表
5.1介绍
因为捕获列表需要由闭包对象各自持有,所以有捕获列表的Function Value要到执行阶段才会在堆上分配对应的funcval结构体以及捕获列表空间。但是,捕获列表里存什么?直接拷贝捕获变量值吗?才没有那么简单。
闭包捕获的变量要在闭包函数和外层函数中表现一致,如果单纯值拷贝,就无法保证这一点。所以编译器针对捕获变量的不同情况分别做出了不同的处理。
1.捕获变量除了初始化赋值外在任何地方都没有被修改过,那就可以直接拷贝值,因为它不会再变化。
2.捕获变量除了初始化赋值外,还被修改过,就要再细分了。
5.2case[01]捕获局部变量
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]()
}
}
这个例子中,被捕获的是局部变量i,并且除了初始化赋值外还被修改过,所以局部变量i改为堆分配,栈上只存一个地址。我们把闭包函数指令入口地址记为addrf。
main函数栈帧中,局部变量fs,以及create函数的返回值都是长度为2的Function Value型数组。在create函数栈帧中,局部变量i被闭包捕获,分配到了堆上,在栈上的局部变量空间只存储它在堆上的地址。在for循环执行以前,i等于0。
在堆上创建一个funcval结构体,指向闭包函数入口,并且在捕获列表中存储捕获变量i的地址,这样就可以和外层函数使用同一个变量了。返回值数组第一个元素存储这一次创建的funcval结构体地址,第一次for循环结束后,i自增1。
第二次for循环再次创建一个funcval结构体,存储捕获变量i的地址,而funcval结构体本身的地址作为返回值的第二个元素。第二次循环结束,i再次自增1。
在这个示例中,为了让被捕获的局部变量在闭包函数和外层函数中保持一致,本该在栈上分配的局部变量被分配到堆上,这其实也是“变量逃逸”的一种场景。
5.3case[02]捕获参数
如果是参数被捕获,那么调用者依然从栈上传递参数,但是被调用函数会把它拷贝到堆上一份,然后和闭包函数都使用堆上分配的那一个。
5.4case[03]捕获返回值
如果是返回值被捕获,那么处理方式就又有些不同了。返回值空间依然由调用者在栈上分配,但是被调用函数(闭包的外层函数)会在堆上也分配一个,并且与闭包函数都使用堆上这一个。但是,在外层函数返回前要把堆上的返回值拷贝到栈上那一个。
6.关键点
(1)Go语言里Function Value本质上是指向funcval结构体的指针;
(2)Go语言里闭包只是拥有捕获列表的Function Value;
(3)捕获变量在外层函数与闭包函数中要保持一致。