闭包的实现
很多编程语言都提供了闭包这一特性,那什么是闭包呢?形象一点来描述就是“一个有状态的函数”。对于普通的函数来讲,如果只使用局部变量,那么函数是不会有状态的。局部变量分配在运行时栈上,即使是像string
、slice
这样的引用类型,指针也是存储在栈上的。当函数返回后,栈帧也随之销毁,所有局部变量不复存在。
闭包不仅仅是一种语法特性,在实现上也有其特殊之处。它“捕获”外层函数的局部变量,在外层函数返回之后,被捕获的变量不会销毁,而且也再不能被直接访问,只能通过闭包函数来使用,从而将一个函数和一组变量打包在一起,成为一个对象,也就是闭包对象。
Go语言实现了闭包,但是没有将其称为闭包,而是称为匿名函数。我们来看如下示例:
示例1
func getCounter() func(int) int {
c := 0
return func(i int) int {
c += i
return c
}
}
以上代码中,内层函数捕获了外层getCounter
中的变量c
,每次调用getCounter
都会分配一个新的变量,互不影响。那么Go语言到底是如何把一个变量和一个函数打包在一起,成为一个闭包对象的呢?下面我们来看一下运行时中和闭包相关的数据结构。
runtime.funcval
type funcval struct {
fn uintptr
// variable-size, fn-specific data here
}
通过结构名称可以看出,该结构就是用来实现“函数变量”的。其实Go语言中没有“函数指针”,而是将“闭包”和“函数指针”统一实现为“函数变量”,底层数据结构就是runtime.funcval
。
我们看一下上面的数据结构,其中的fn
字段存储的是闭包函数的地址,而根据下面的注释能够知道这个结构的大小是不确定的,实际上编译器会把闭包函数捕获外层函数的“捕获列表”追加到fn
字段之后,至于捕获列表中存储的是值还是地址,需要根据实际情况而定。
示例1中,闭包函数对变量c
进行了修改,所以捕获列表中实际存储的是c
的地址,而且c
也要相应的在堆上分配。示例1中代码实际上会被编译器转化为下面这个样子:
// 在funcval尾部追加捕获列表
type funcval1 struct {
runtime.funcval
c *int
}
// 闭包函数被编译为独立的函数,通过this指针访问捕获列表
func getCounter.func1(this *runtime.funcval, i int) int {
f1 := (*funcval1)(this)
*f1.c += i
return *f1.c
}
// 所有闭包和函数变量都是*runtime.funcval,只是捕获列表不同
func getCounter() *runtime.funcval {
c := new(int)
f := new(funcval1)
f.fn = &getCounter.func1
f.c = c
return &f.funcval
}
// 调用闭包函数时
f := getCounter()
f.fn(f, 10)
以上代码无法通过编译,只是用做展示。实际上,在Go语言中闭包函数的this
指针是通过指定的寄存器来传递的,不是像普通的函数参数那样通过栈传递。在目前最常见的amd64平台上,是通过寄存器RDX来传递的。我们对上面的代码稍作修改:
// 闭包函数被编译为独立的函数,RDX寄存器用作this指针
func getCounter.func1(i int) int {
f1 := (*funcval1)(RDX)
*f1.c += i
return *f1.c
}
// 调用闭包函数时
f := getCounter()
RDX = f
f.fn(10)
捕获值?捕获地址?
关于闭包捕获外层变量最容易让人疑惑的地方,就是捕获值还是捕获地址。下面我们用几个示例来演示何时捕获值,何时捕获地址,同时提出一种检验方法。
检验方法
// 助手函数
func helper(arg int) {
addr := uintptr(unsafe.Pointer(&arg))
fmt.Printf(“post: %#x\n”, addr)
}
// 为外层函数添加一个占位参数
func getCounter(dummy int) func(int) int {
c := 0
// 添加如下代码块
addr := uintptr(unsafe.Pointer(&dummy))
fmt.Printf(“pre: %#x\n”, addr)
addr = uintptr(unsafe.Pointer(&c))
fmt.Printf(“addr: %#x\n”, addr)
helper(0)
return func(i int) int {
c += i
return c
}
}
上述检验方法的思路是:1)运行时栈向下增长;2)函数的参数通过运行时栈传递;3)函数的局部变量在运行时栈上分配,且在参数之后。
可以对上述代码的输出结果做如下推论:1)如果c
在栈上分配,那么pre > addr > post;2)如果c
在堆上分配,那么addr > pre或者addr < post。
相应的:1)c
在栈上分配时,闭包捕获值;2)c
在堆上分配时,闭包捕获地址。反推:如果闭包需要捕获地址,那么会促使c
在堆上分配。
那么什么时候需要捕获地址呢?我们用一种直观的方法按步分析:1)被捕获的变量出现在两个或更多个函数的作用域中,包括外层函数和一个或多个闭包函数;2)如果在任何一个作用域中,被捕获变量都不曾被修改过(外层函数中赋初始值不算修改),那么所有闭包函数捕获值即可;3)如果被捕获变量在任何一个作用域内被修改,那么变量需要分配在堆上,所有闭包捕获地址。
示例1中的变量c
在闭包函数中被修改,所以需要捕获地址,相应的也要在堆上分配。下面再来举几个例子:
捕获值
// 变量不曾被修改,捕获值
func getf() (f1, f2 func() int) {
a := 10
f1 := func() int {
return a
}
f2 := func() int {
return a
}
return f1, f2
}
捕获地址
// 变量在外层函数被修改,堆分配,捕获地址
func getf() func() int {
a := 10
f := func() int {
return a
}
a = 20
return f
}
捕获循环变量地址
// 循环变量在外层函数被修改,堆分配,捕获地址
func getfs(c int) (fs []func() int) {
for i := 0; i < c; i++ {
f := func() int {
return i
}
fs = append(fs, f)
}
return
}
函数变量
受C语言中函数指针的影响,我们容易先入为主的认为Go语言中的函数变量就是函数指针,实则不然。Go语言中的函数变量并不是函数指针,其在实现上与上面所讲的闭包完全一致,使用相同的数据结构runtime.funcval
,调用方式也完全相同,通过RDX传递funcval
地址。实际上在Go语言中闭包并不是一种独特的类型,它属于函数变量的一种实现,就是有捕获列表的函数变量而已。有没有捕获列表,或者有什么样的捕获列表,不会影响函数变量的类型,函数变量的类型只跟函数原型有关,这点和普通具名函数一样。且看如下示例:
// 具名函数
func add(a, b int) int {
return a + b
}
f := add
// 函数变量
// f := new(runtime.funcval)
// f.fn = &add
f(10, 20)
// 调用操作
// RDX = f
// f.fn(10, 20)
因为add
函数没有捕获任何变量,所以实际上根本用不着通过RDX传递funcval
指针,直接传递add
函数地址更简洁高效。之所以这样做,为的是使调用者不必关心调用的到底是一个闭包对象还是一个普通函数。为了达成统一,函数指针迁就了闭包,一致采用闭包的方式来实现。
调用者在调用函数变量时,只需提前把RDX赋好值,然后从funcval
中取出函数地址直接调用即可,至于有没有捕获列表,那是被调用函数(闭包函数)应该关心的事。
打印地址
上面我们检验变量是在栈上还是堆上分配时,使用了打印地址并比较的方法。在打印地址前为什么要将其转换为uintptr
,而不是使用fmt.Printf(“%p”, &a)
直接打印呢?
因为Go语言的编译器有像逃逸分析这类的高级特性,在代码中传递指针类型会引发一些意想不到的副作用,尤其是fmt.Printf
这样重度依赖类型断言的函数,能带来更多的惊喜。
// 变量在闭包函数中被修改,捕获地址
func getf() func() int {
a := 0
return func() int {
a++
return a
}
}
f1 := getf()
f2 := getf()
fmt.Printf(“%p, %p\n”, f1, f2)
以上代码打印出的两个地址竟然是相等的,不可思议。按道理每次分配新的funcval
,地址是不可能相等的。其实fmt.Printf
识别出f1
和f2
是函数变量后,把funcval.fn
给打印出来了,打印的是函数的真实地址,而不是funcval
的地址。
如果我们要查看funcval
的地址,可以对代码稍作修改:
f1 := getf()
f2 := getf()
a1 := *(*uintptr)(unsafe.Pointer(&f1))
a2 := *(*uintptr)(unsafe.Pointer(&f2))
fmt.Printf(“%#x, %#x\n”, a1, a2)
相等性
Go语言不支持比较两个函数变量是否相等,只能判断是否为nil
,这是为什么?
怎么样才能算两个函数变量是相等的?首先,funcval.fn
也就是函数地址要相等;其次,如果没有捕获列表,那么可以认为它们是相等的,否则要求捕获列表也是相等的。捕获列表中可能包含引用类型,因为Go语言在设计上不支持引用类型的相等性比较,所以无法保证捕获列表是可比较的。
即使像struct
一样,只在不包含引用类型时可比较,又会引入另一个问题:如果在相等性比较时考虑捕获列表,就相当于说捕获列表属于函数变量类型的一部分,而不再是像之前提到的那样只考虑函数原型,进而有着不同捕获列表的函数变量属于不同类型不能通用,函数变量也就失去了存在的意义。
对于无捕获列表的函数变量,编译器会静态生成funcval
,运行时就不用动态分配了,如下所示:
// 没有捕获列表
func getf() func() int {
return func() int {
return 10
}
}
f1 := getf()
f2 := getf()
a1 := *(*uintptr)(unsafe.Pointer(&f1))
a2 := *(*uintptr)(unsafe.Pointer(&f2))
fmt.Printf(“%#x, %#x\n”, a1, a2)
因为编译器静态生成funcval
,getf()
每次返回相同的地址,所以上述代码打印的两个地址应该是相等的。
小结
本文分析了函数变量的底层结构,探讨了捕获列表何时捕获值、何时捕获地址。最后作为总结,让我们手动构造一个函数变量:
// 变量在闭包函数中被修改,捕获地址
func getf() func() int {
a := 0
return func() int {
a++
return a
}
}
type funcval struct {
fn uintptr
aa *int
}
// 利用逃逸分析实现堆分配
func newfv() *funcval {
return new(funcval)
}
func newi() *int {
return new(int)
}
// 通过f1取得函数实际地址
f1 := getf()
var f2 func() int
fv := newfv()
fv.aa = newi()
fv.fn = **(**uintptr)(unsafe.Pointer(&f1))
*(**funcval)(unsafe.Pointer(&f2)) = fv
fmt.Printf(“%d, %d\n”, f1(), f2())
上例中利用编译器的逃逸分析确保funcval
和int
在堆上分配,这样就可以安全的传递函数变量f2
而不用担心内存访问异常了。