这样理解 Go 闭包,就不会出错了
面试官:我们来看一个简单的问题吧,看看下面这个程序输出的是什么?
1func work() func() int {
2 days := 0
3 return func() int {
4 days++
5 return days
6 }
7}
8
9func main() {
10 closure := work()
11 fmt.Println(closure())
12 fmt.Println(closure())
13}
考点:闭包
超超:work()返回的匿名函数引用了局部变量days,这种将引用环境和函数结合的使用方法不就是闭包嘛!根据闭包的特性俩次closure的调用都是对函数work中的局部变量days执行自增,因此这段代码输出的结果为
11
22
面试官:你说根据闭包的特性,可以详细给我说说这个特性是指什么吗?
考点:闭包底层
超超:可以啊,这个就要从闭包的底层说起了,遇事不决先上汇编(:哈哈
执行命令go build -gcflags = "-S" main.go
1"".work STEXT size=145 args=0x8 locals=0x20
2 0x0000 00000 (main.go:5) TEXT "".work(SB), ABIInternal, $32-8
3 0x0000 00000 (main.go:5) MOVQ (TLS), CX
4 0x0009 00009 (main.go:5) CMPQ SP, 16(CX)
5 0x000d 00013 (main.go:5) PCDATA $0, $-2
6 0x000d 00013 (main.go:5) JLS 135
7 0x000f 00015 (main.go:5) PCDATA $0, $-1
8 0x000f 00015 (main.go:5) SUBQ $32, SP
9 0x0013 00019 (main.go:5) MOVQ BP, 24(SP)
10 0x0018 00024 (main.go:5) LEAQ 24(SP), BP //申请函数栈空间
...
13 0x001d 00029 (main.go:6) LEAQ type.int(SB), AX
14 0x0024 00036 (main.go:6) MOVQ AX, (SP)
15 0x0028 00040 (main.go:6) PCDATA $1, $0
16 0x0028 00040 (main.go:6) CALL runtime.newobject(SB) //给变量days申请空间
17 0x002d 00045 (main.go:6) MOVQ 8(SP), AX
18 0x0032 00050 (main.go:6) MOVQ AX, "".&days+16(SP)
19 0x0037 00055 (main.go:7) LEAQ type.noalg.struct { F uintptr; "".days *int }(SB), CX //定义闭包结构体
20 0x003e 00062 (main.go:7) MOVQ CX, (SP)
21 0x0042 00066 (main.go:7) PCDATA $1, $1
22 0x0042 00066 (main.go:7) CALL runtime.newobject(SB) //为闭包结构体申请空间
...
24 0x0076 00118 (main.go:7) RET
通过第16行可以看到变量days是通过runtime.newobject(SB)申请空间,而runtime.newobject(SB)正是关键字new的底层函数,他会在在堆上申请一块内存并返回相应的指针。
再往下看到第19行type.noalg.struct { F uintptr; "".days *int }(SB),这句话定义了闭包的结构体,结构如下
1type closure struct {
2 F uintptr // 函数指针,代表内部匿名函数
3 days *int // 变量days指针,代表对外部环境的引用
4}
第22行在定义了闭包结构体后,同样也调用了runtime.newobject(SB)为闭包结构体申请空间。
因此闭包的整个调用流程为

面试官:那是不是所有的闭包调用都会在闭包结构体中生成一个外部变量的指针?
考点:闭包场景
超超:不是所有场景下都会生成外部变量指针的,比如将上面的代码稍作修改
1func work() func() int {
2 days := 0
3 return func() int {
4 days
5 return days
6 }
7}
8
9func main() {
10 closure := work()
11 fmt.Println(closure())
12 fmt.Println(closure())
13}
执行命令go build -gcflags = "-S" main.go
1LEAQ type.noalg.struct { F uintptr; "".days int }(SB), AX
可以看到汇编中生成的闭包结构体中days为一个int类型的变量,而不是一个指针。这就是编译器做的比较好的地方,当他发现函数对引用的变量只有读操作而没有写操作时,做的是值拷贝避免内存逃逸的发生,当函数对引用的变量有写操作时,闭包结构体中才会用指针指向该变量。
面试官:之前也问过你关于defer的问题,你看下下面这段代码执行结果是什么?
1func func1() (i int) {
2 i = 100
3 defer func() {
4 i += 1
5 }()
6 return 5
7}
8
9func func2() int {
10 var i int
11 defer func() {
12 i += 1
13
14 }()
15 i += 100
16
17 return i
18}
19
20func main() {
21 fmt.Println(func1())
22 fmt.Println(func2())
23}
考点:闭包和defer组合使用
超超:执行结果为
16
2100
func1:首先变量i声明在返回值中,根据go的caller-save模式,变量i会被存储在调用者(main)的栈空间中。其次defer执行的是一个闭包,闭包中的匿名函数有对外部变量i的写操作,所以闭包结构体中存的是变量i的指针。因此func1执行的顺序为先为变量i赋值为100,执行return时会为变量i赋值为5,最后执行defer通过指针将i的值加1,最终返回值为6。(:不了解汇编和caller-saver的小伙伴可以看下这篇Go汇编简介
func2:首先变量i声明在func2中,所以变量i会被放在func2的栈空间中。其次defer执行的是一个闭包,闭包中的匿名函数有对外部变量i的写操作,所以闭包结构体中存的是变量i的指针。因此func2的执行顺序为先为变量i赋值为100,再执行return将变量i的值赋给返回值(放在main的栈空间),最后执行defer的闭包将func2栈空间的i加1,最终返回值仍为100。
面试官:不错!看来你对defer和闭包已经很了解了,你刚刚提到内存逃逸的问题,我们来详细谈谈吧。
超超:好呀!

⬇⬇⬇
