将闭包逮个正着

本文的内容仍然是Go语言相关研究。长久以来我都在寻找一种方法,为了能够直接看见闭包。就是看到闭包的对象模型,或者说内存布局,就用诸如此类的名词来描述吧。

我之前曾经有过相关的论述,拿Go语言的闭包和C++中的lambda表达式进行类比,都是由编译器构造出来的匿名类型,都有捕获列表,也都有一个函数与之关联。当然也有些不同,比如C++中的函数指针和lambda表达式是完全不同的东西,而Go里面没有传统的函数指针,闭包这个概念也没有被太多提及,却统一为Function Value。

因为一直没有找到一个很方便的方法来“逮住”闭包,因此不那么容易把事情讲清楚,太多枯燥的描述就更加显得苍白无力,最后极有可能落得一个“无图无真相”的评价。不过还好,我最近似乎找到了一种还算简便方法,接下来就一步一步走近闭包。

🌟 函数

Go经常被称为21世纪的C,说明两者还是有些相似的,比如都是编译为机器指令,生成操作系统原生的可执行文件。在函数的实现上,两者也没有太大差异。

函数,更早时期被称为“过程”,可以说是代码、逻辑复用的最小单元,如今的程序员基本上已经无法想象没有函数的年代。这个特性出现在我们日常使用的各种编程语言里,常见到没有哪个编程语言会以“支持函数”来为自己宣传,也很少有人停下来去思考一下它到底是一个什么样的存在。

如果说,函数就是可执行文件代码段中的一段代码,代码段加载到内存中后是可执行但不可修改的。这显然没有说到函数的精髓,要真正理解函数,就要弄清楚“运行时栈”与“函数栈帧”这两个概念。运行时栈是与线程、协程相关的,在这里指的就是Go语言的协程栈。

数据结构相关书籍基本都会讲到栈这种后进先出的结构,实际上在我们的代码背后就隐藏着一个栈,虽然我们并不常感受到它的存在。函数调用的参数、返回值都是通过它来传递,函数的局部变量也是在它上面分配的,这就是刚刚提到的运行时栈。为什么不是其他结构,而是一个栈呢?想一下层层函数调用与返回,不就是一个入栈出栈的过程吗。

下面就结合实际的代码看一下函数是如何使用运行时栈的。为了更好的说明Go函数支持多返回值的特性,假设有个函数,有两个int类型参数,还有两个int类型返回值:

1
2
3
func somefunc(i, j int) (a, b int) {
// ...
}

以amd64平台为例,对应的函数的栈帧结构示意图如下所示:

函数栈帧

按照代码执行的顺序,梳理栈空间的分配、使用和释放:

  1. 在函数被调用前,运行时栈指针SP的位置在左上方箭头处,返回值和参数都是通过运行时栈来传递的,空间是提前就预留出来的,位于caller的args to callee区间内;
  2. 调用函数时caller会将需要传递的参数拷贝到栈上,入栈顺序和C一样从右到左。然后执行CALL指令完成跳转和返回地址压栈;
  3. 图中caller’s BP是一个指针,用来保存caller的栈帧基址,从而可以把调用路径上所有的栈帧连成一个链表。因为某些编译选项的作用,这个指针有时会不存在;
  4. 第二个箭头表示刚刚跳转到somefunc时栈指针SP的位置,函数内部的代码会通过SP以相对地址的方式寻址,来读写栈上的参数和返回值;
  5. 函数somefunc如果需要分配局部变量,或者因为内部调用其他函数需要分配args to callee区间,就会向下移动SP,到达第三个箭头的位置。分配的空间大小都是编译器计算好的,生成了相应指令来完成的;
  6. 函数代码使用局部变量也是通过栈指针的相对地址。函数执行完毕后会先将栈指针还原到第二个箭头的位置以释放局部空间,然后通过RET指令返回到caller,此时的SP也恢复到第一个箭头处。

这一系列动作都是函数的代码自动完成的,编译器在编译的时候就是本着运行时栈的模型来生成机器指令序列的。不只是Go、C/C++,像JVM、.NET CLR也是基于运行时栈来设计的,PHP的Zend Engine也有一个模拟的栈结构。不依赖运行时栈的系统、语言也许有,只是我在实际工作中还没怎么接触过。

理解了运行时栈的概念和函数的栈帧结构之后,再来看后面的内容,应该就会非常顺利了。

💥 方法

方法即method,Go语言支持为自定义类型实现方法,method在具体实现上与普通的函数并无不同,不过是通过运行时栈多传递了一个隐含的参数,这个隐含的参数就是所谓的接收者。下面通过代码来进行说明:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
type A struct {
name string
}

func (a A) Name() string {
return a.name
}

func (a *A) SetName(name string) {
a.name = name
}

func main() {
var a A
// 1)编译器的语法糖,提供面向对象的语法
a.SetName("Jackson")
println(a.Name())
// 2)更贴近真实实现的写法,和普通函数调用几乎没什么不同
(*A).SetName(&a, "Jackson")
println(A.Name(a))
}

以上代码展示了两种不同的写法,都能顺利通过编译并正常运行,实际上这两种写法会生成同样的机器码。

第一种是我们平常习惯使用的、很方便的写法,其实编译器会帮我们把它转换为第二种的形式,因为a是addressable的,所以在调用SetName时编译器自动完成了取地址操作。如果我们使用一个指针去调用一个值类型接收者方法,编译器也会自动完成值拷贝,这些为我们提供了语法上的便利。

第二种写法则更底层也更严谨,要求所有的类型必须严格对应,否则是无法通过编译的。

深入理解这两种写法的等价性是非常重要的,下面再用代码进一步证明:

1
2
3
4
5
6
7
8
9
10
11
12
// 复用上面的自定义类型A

func SetNameForA(a *A, name string) {
a.name = name
}

func main() {
t1 := reflect.TypeOf((*A).SetName)
t2 := reflect.TypeOf(SetNameForA)
// 会输出true,通过反射来验证,两者的类型是相同的
println(t1 == t2)
}

因为Go语言函数的类型只跟参数和返回值有关,所以以上代码很好的说明了:方法本质上就是普通的函数,而接收者就是隐含的第一个参数。

💫 Function Value

不像C/C++,Go语言里不再有函数指针,却多了一个Function Value,之所以我不称它为“函数值”,是为了避免和“函数返回值”混淆。函数在Go语言里属于first class value,可以作为参数传递,也可以保存在变量中。而这个用来保存函数的变量,就是Function Value,示例代码如下:

1
2
3
// pl就是Function Value
pl := println
pl("function value")

这个Function Value像极了函数指针,但又不是函数指针,它实际上也是个指针,不过指向的是一个struct,这个struct就是runtime.funcval:

1
2
3
type funcval struct {
fn uintptr
}

这个funcval的fn字段保存的是实际的函数地址,就像C/C++里的函数指针。所以这实际上就是个二级指针结构,比如上述例子中调用的时候,需要先由pl这个指针找到对应的funcval结构,再从funcval中取出函数的真实地址fn,然后就可以调用到println了。

这个Function Value费半天劲搞一个二级指针结构带来了什么好处呢?想弄清楚就需要继续往下看,那就是接下来要研究的闭包。

✨ 闭包

从本质上来讲,闭包应该是个函数对象,它将一组变量和一个函数捆绑在一起,看起来就像个有状态的函数。我习惯将这组变量称为闭包的“捕获列表”,而将这个函数称为闭包的“入口函数”。我们一般会认为闭包就是个函数,也就是容易把闭包对象跟它的入口函数混淆,实际研究的时候要注意区分。

与其用太多的名词术语枯燥的去解释闭包的概念,接下来我们就实际的“看见”Go语言中的闭包。既然闭包应该是个函数对象,那么运行时创建一个对象肯定涉及到堆分配,因此就以runtime.newobject函数作为切入点。runtime.newobject函数是Go语言中new的实现,原型如下:

1
func newobject(typ *_type) unsafe.Pointer

该函数接受一个参数typ,是一个指针,指向要创建的对象的所属类型。如果能够拿到这个类型参数,我们就能够知道到底创建了一个什么样的对象。完整的思路就是:用一个自己实现的函数替换runtime.newobject,在函数内部打印出typ指针指向的类型信息,然后再调用真实的runtime.newobject函数完成对象创建。

要完成这个替换函数的操作,可以使用hookingo这个mod,最新的v0.1.1版本已经可以钩住runtime.newobject和runtime.mallocgc,应该还适用于钩住其他很多runtime函数,有很大的发挥空间。以下就是通过钩住runtime.newobject来打印闭包类型信息的代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
package main

import (
"github.com/fengyoulin/hookingo"
"reflect"
"strconv"
"strings"
"unsafe"
)

var hno hookingo.Hook

//go:linkname newobject runtime.newobject
func newobject(typ unsafe.Pointer) unsafe.Pointer

// 自己实现的fake newobject,切记这里不要new,否则陷入无休止递归然后崩掉
func fno(typ unsafe.Pointer) unsafe.Pointer {
t := reflect.TypeOf(0)
(*(*[2]unsafe.Pointer)(unsafe.Pointer(&t)))[1] = typ // 相当于反射了闭包类型
println(t.String())
if fn, ok := hno.Origin().(func(typ unsafe.Pointer) unsafe.Pointer); ok {
return fn(typ) // 调用原始runtime.newobject
}
return nil
}

// 创建一个闭包,make closure
func mc(start int, name string) func () string {
return func() string {
r := strings.Join([]string{name, strconv.FormatInt(int64(start), 10)}, ":")
start++
return r
}
}

func main() {
var err error
hno, err = hookingo.Apply(newobject, fno) // 应用钩子,替换函数
if err != nil {
panic(err)
}
f := mc(10, "counter")
println(f())
}

编译运行以上代码的输出如下:

1
2
3
4
$ ./closure_type
int
struct { F uintptr; name string; start *int }
counter:10

一目了然,编译器为闭包构造一个struct类型:

1
2
3
4
5
struct {
F uintptr // 入口函数地址
name string // 捕获值
start *int // 捕获地址,所以之前new了一个int
}

回过头去看runtime.funcval结构,就会发现两者的共同之处,第一个字段都是函数指针,所以调用方式是一致的,闭包对象也是一个Function Value,Go语言用这种方式统一了函数指针和闭包。

不过上面有一点没有说明,我们知道函数的参数都是通过运行时栈传递的,那么闭包的入口函数是如何找到捕获列表的呢?难道和method一样有个隐含参数?那样的话就改变了入口函数的类型,也可以说是函数原型,因为参数列表变了。实际上Go语言没有改变函数原型,因为那样的话就等于为了兼容闭包,要为所有的普通函数都前置一个无用参数,既浪费又不优雅。Go使用了特定寄存器来传递闭包对象地址,例如在amd64平台上是通过DX寄存器。

因为用Function Value统一了函数指针和闭包,所以Function Value的类型只考虑入口函数,编译器在进行调用时也无法区分拿到的只是一个指针还是闭包对象,所以只能都给DX赋值。这就是为什么你在看反编译后的汇编代码时,经常看到Function Value调用前会为DX寄存器赋值,那是闭包需要的,虽然当前并不一定是个闭包。

本文就到这里,如果发现错误请及时反馈指正。我争取在后续的文章中梳理一下Go语言的类型系统和反射。