You cannot select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.

4.4 KiB

title
闭包

闭包

C语言中函数名称就是函数的首地址。Go语言中函数名称跟C语言一样函数名指向函数的首地址即函数的入口地址。从前面《[基础篇-函数-一等公民]({{< relref "function/first-class" >}})》那一章节我们知道Go 语言中函数是一等公民,它可以绑定变量,作函数参数,做函数返回值,那么它底层是怎么实现的呢?

我们先来了解下 Function Value 这个概念。

Function Value

Go 语言中函数是一等公民函数可以绑定到变量也可以做参数传递以及做函数返回值。Golang把这样的参数、返回值、变量称为Function value

Go 语言中Function value本质上是一个指针,但是其并不直接指向函数的入口地址,而是指向的runtime.funcval(runtime/runtime2.go)这个结构体。该结构体中的fn字段存储的是函数的入口地址

type funcval struct {
	fn uintptr
	// variable-size, fn-specific data here
}

我们以下面这段代码为例来看下Function value是如何使用的:

func A(i int) {
	i++
	fmt.Println(i)
}

func B() {
	f1 := A
	f1(1)
}

func C() {
	f2 := A
	f2(2)
}

上面代码中函数A被赋值给变量f1和f2这种情况下编译器会做出优化让f1和f2共用一个funcval结构体该结构体是在编译阶段分配到数据段的只读区域(.rodata)。如下图所示那样f1和f2都指向了该结构体的地址addr2该结构体的fn字段存储了函数A的入口地址addr1

{{< figure src="https://static.cyub.vip/images/202105/fuc_var.png" width="400px" class="text-center">}}

为什么f1和f2需要通过了一个二级指针来获取到真正的函数入口地址而不是直接将f1f2指向函数入口地址addr1。关于这个原因就涉及到Golang中闭包设计与实现了。

闭包

闭包(Closure) 通俗点讲就是能够访问外部函数内部变量的函数。像这样能被访问的变量通常被称为捕获变量。

闭包函数指令在编译阶段生成,但因为每个闭包对象都要保存自己捕获的变量,所以要等到执行阶段才创建对应的闭包对象。我们来看下下面闭包的例子:

package main

func A() func() int {
    i := 3
    return func() int {
        return i
    }
}

func main() {
    f1 := A()
    f2 := A()
    
    print(f1())
    pirnt(f2())
}

上面代码中当执行main函数时会在其栈帧区间内为局部变量f1和f2分配栈空间当执行第一个A函数时候会在其栈帧空间分配栈空间来存放局部变量i然后在堆上分配一个funcval结构体其地址假定addr2)该结构体的fn字段存储的是A函数内那个闭包函数的入口地址其地址假定为addr1。A函数除了分配一个funcval结构体外还会挨着该结构体分配闭包函数的变量捕获列表该捕获列表里面只有一个变量i。由于捕获列表的存在所以说闭包函数是一个有状态函数

当A函数执行完毕后其返回值赋值给f1此时f1指向的就是地址addr2。同理下来f2指向地址addr3。f1和f2都能通过funcval取到了闭包函数入口地址但拥有不同的捕获列表。

当执行f1()时候Go 语言会将其对应funcval地址存储到特定寄存器比如amd64平台中使用rax寄存器这样在闭包函数中就可以通过该寄存器取出funcval地址然后通过偏移找到每一个捕获的变量。由此可以看出来Golang中闭包就是有捕获列表的Function value

根据上面描述,我们画出内存布局图:

{{< figure src="https://static.cyub.vip/images/202105/func_clo.png" width="400px" class="text-center">}}

若闭包捕获的变量会发生改变,编译器会智能的将该变量逃逸到堆上,这样外部函数和闭包引用的是同一个变量,此时不再是变量值的拷贝。这也是为什么下面代码总是打印循环的最后面一个值。

package main

func main() {
	fns := make([]func(), 0, 5)
	for i := 0; i < 5; i++ {
		fns = append(fns, func() {
			println(i)
		})
	}

	for _, fn := range fns { // 最后输出5个5而不是01234
		fn()
	}
}

感兴趣的可以仿造上图,画出上面代码的内存布局图。重点关注闭包函数捕获的不是值拷贝,而是引用一个堆变量。