加载中...

闭包


Function Value

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

function value本质是上一个指针,但是并不直接指向函数的指令入口。而是指向一个runtime.funcval结构体,这个结构体里只有一个地址,就是这个函数指令的入口地址

如果函数A被赋值给$f1$和$f2$两个变量,这种情况,编译器会做出优化,让$f1$和$f2$共用一个funcval结构体

假设函数A的入口地址为$addr1$(在代码段中),编译阶段,会在只读数据段分配一个funcval结构体,$fn$指向函数A指令入口。而它本身的起始地址$addr2$(在只读数据段中),会在执行阶段赋给$f1$和$f2$。通过$f1$来执行函数,就会通过它存储的地址找到对应的funcval结构体,拿到函数入口地址,然后调转执行。

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

闭包定义

  • 必须要有在函数外部定义,但在函数内部引用的“自由变量”
  • 脱离了形成闭包的上下文,闭包也能照常使用这些自由变量

闭包函数的指令自然也是在编译阶段生成,但因为每个闭包对象都要保存自己的捕获变量。所以要到执行阶段才创建对应的闭包对象

下面用一个例子展示:

函数create的返回值是一个函数,但这个函数内部使用了外部定义的变量$c$,即使create执行结束,通过$f1$和$f2$依然能正常调用这个闭包函数,并使用定义在create函数内部的局部变量$c$。

通常称这个变量$c$为捕获变量

执行阶段,main函数栈帧有两个局部变量,然后是返回值空间。到create函数栈帧这里,有一个局部变量c=2

create函数会在堆上分配一个funcval结构体,$fn$指向闭包函数入口,除此之外,还有一个捕获列表,这里只捕获一个变量$c$。然后这个结构体的起始地址就作为返回值写入返回值空间,即f1 = addr2。接着再次调用create函数,它就会再次创建一个funcval结构体,同样捕获变量$c$,然后这个起始地址addr3作为返回值写入,即f2 = addr3

通过$f1$和$f2$调用闭包函数,就会找到各自对应的funcval结构体,拿到同一个函数入口,但是通过$f1$调用时要使用$addr2$上面的捕获列表,$f2$调用时要使用$addr3$上面的捕获列表,这就是称闭包为有状态的函数的原因。

函数如何找到对应的捕获列表呢?

Go语言中通过一个function value调用函数时,会把对应的funcval结构体地址存入特定寄存器,例如amd64平台使用的是DX寄存器。这样在闭包函数中,就可以通过寄存器取出funcval结构体的地址,然后加上相应的偏移来找到每一个被捕获的变量。

所以Go语言中闭包就是有捕获列表的function value,而没有捕获列表的function value直接忽略这个寄存器的值就行

捕获列表

被闭包捕获的变量,要在外层函数与闭包函数中表现一致,好像它们在使用同一个变量,Go语言的编译器针对不同情况做了不同的处理

1、被捕获的变量没有被修改时

如上面的例子,被捕获的变量除了初始化赋值外,在任何地方都没有被修改过,所以直接拷贝值到捕获列表就行

2、被捕获的变量有被修改时

在这个例子中,被捕获的是局部变量$i$,而且除了初始化赋值外还被修改过

由于被闭包捕获并修改,局部变量$i$改为堆分配,在栈上只存一个地址

第一次for循环。在堆上创建funcval结构体,捕获 $i$ 的地址,这样闭包函数就和外层函数操作同一个变量了,返回值空间第一个元素存储$addr0$,第一次for循环结束,$i$自增1

第二次for循环,堆上再次分配一个funcval,捕获变量$i$的地址,返回值空间第二个元素存储$addr1$,第二次循环结束,$i$再次自增1,create函数结束,把返回值拷贝到main的局部变量$fs$

通过$fs[0]$调用函数时,会把$addr0$存入寄存器。闭包函数通过寄存器存储的地址加上偏移找到捕获变量$i$的地址。$fs[1]$同理,被捕获的地址都指向它,所以每次都会打印2

闭包导致的局部变量堆分配,也是变量逃逸的一种场景。

3、被捕获的是参数且有被修改时

此时涉及到函数原型,就不能再像局部变量那样处理

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

4、被捕获的是返回值且有被修改时

调用者栈帧上依然会分配返回值的空间,不过闭包的外层函数会在堆上也分配一个外层函数和闭包函数都使用堆上这个

与第三个例子中被捕获的是参数的情况不同的是,在外层函数返回前,需要把堆上的返回值拷贝到栈上的返回值空间

总的来说,虽然处理方式多样,但是目标只有一个,就是保存捕获变量在外层函数和闭包函数中的一致性

参考

幼麟实验室


文章作者: DestiNation
版权声明: 本博客所有文章除特別声明外,均采用 CC BY 4.0 许可协议。转载请注明来源 DestiNation !
  目录