7. 深度解析 golang 中的闭包
前言
本文将深度解析 golang 中的闭包,将从什么是闭包、闭包的实现、闭包的使用场景以及闭包常见的坑等四个方面进行讲解,希望可以帮助大家彻底理解闭包这个概念。
1.什么是闭包
什么是闭包?相信很多同学都有所了解,但没有深入地探究过,本小节就详细的讨论下闭包是什么?
我们先看看维基百科对闭包的定义;
在计算机科学中,闭包(英语:Closure),又称词法闭包(Lexical Closure)或函数闭包(function closures),是在支持头等函数的编程语言中实现词法绑定的一种技术。闭包在实现上是一个结构体,它存储了一个函数(通常是其入口地址)和一个关联的环境(相当于一个符号查找表)。环境里是若干对符号和值的对应关系,它既要包括约束变量(该函数内部绑定的符号),也要包括自由变量(在函数外部定义但在函数内被引用),有些函数也可能没有自由变量。闭包跟函数最大的不同在于,当捕捉闭包的时候,它的自由变量会在捕捉时被确定,这样即便脱离了捕捉时的上下文,它也能照常运行。捕捉时对于值的处理可以是值拷贝,也可以是名称引用,这通常由语言设计者决定,也可能由用户自行指定(如C++)。
闭包是如何产生的?
在支持头等函数的语言中,如果函数 f 内定义了函数 g,那么如果 g 存在自由变量,且这些自由变量没有在编译过程中被优化掉,那么将产生闭包。
闭包和匿名函数是一个东西吗?
闭包和匿名函数经常被用作同义词。但严格来说,匿名函数就是字面意义上没有被赋予名称的函数,而闭包则实际上是一个函数的实例,也就是说它是存在于内存里的某个结构体。如果从实现上来看的话,匿名函数如果没有捕捉自由变量,那么它其实可以被实现为一个函数指针,或者直接内联到调用点,如果它捕捉了自由变量那么它将是一个闭包;而闭包则意味着同时包括函数指针和环境两个关键元素。在编译优化当中,没有捕捉自由变量的闭包可以被优化成普通函数,这样就无需分配闭包结构体,这种编译技巧被称为函数跃升。
通过对维基百科中对闭包一词的定义和解释,我们可以了解到,闭包是由函数和与其相关的引用环境组合而成的实体。(闭包 = 函数指针 + 引用环境)
举个简单的例子,用闭包实现一个累加器:
package main
import "fmt"
func Add(value int) func() int {
return func() int {
value++
return value
}
}
func main() {
func main() {
value := 1
// 第一个累加器
accumulator := Add(value)
fmt.Println(accumulator()) // 2
fmt.Println(accumulator()) // 3
fmt.Println(accumulator()) // 4
// 创建另一个累加器
accumulator2 := Add(value)
fmt.Println(accumulator2()) // 2
fmt.Println(accumulator2()) // 3
fmt.Println(accumulator2()) // 4
}
定义一个累加函数,返回类型为 func() int
,入参为整数类型,每次调用函数对该值进行累加。用同一个 value 创建两个累加器,分别执行三次,两个累加器独立执行。此时闭包 = 函数指针(func() int)+ 引用环境(入参 value),由于两个累加器引用环境不同,所以两个累加器互不影响。
闭包和匿名函数严格意义上讲不是一个东西,但闭包的产生条件之一是函数的嵌套,而匿名函数刚好适用于函数的嵌套。因此,匿名函数常用于闭包的使用,当匿名函数引用了外部作用域中的变量时就形成了闭包。
2闭包的实现
在 Go 语言中,函数是一等公民(支持头等函数),这意味着函数可以作为别的函数的参数、函数的返回值,赋值给变量或存储在数据结构中。因此,Go 也支持闭包,接下来我们探究一下 Go 中闭包的实现。
在 Go 语言中,函数被当做一种变量,本质上是一个指针,指向 runtime.funcval
结构体,这个结构体保存了函数的入口地址 fn uintptr
。
代码位置:src/runtime/runtime2.go
type funcval struct {
fn uintptr
// variable-size, fn-specific data here
}
一个函数变量会经过两层才能找到函数的入口地址:函数变量 -> runtime.funcval -> fn uintptr;Go 增 runtime.funcval 这一层的原因是为了实现闭包,Go 在编译期间会将引用环境变量加入到 funcval 结构体中实现闭包,这样不同的函数变量就拥有了不同的闭包实体。这里我们通过汇编语句来验证一下:
2.1 闭包结构的汇编验证
举个闭包的例子:
package main
func add() func() int {
x := 1
return func() int {
x++
return x
}
}
func main() {
f1 := add()
f2 := add()
f1() // 2
f2() // 2
}
执行 go tool compile -S -N main.go
生成汇编语句,主要看 add 函数的汇编代码:
main.add STEXT size=157 args=0x0 locals=0x30 funcid=0x0 align=0x0
0x0000 00000 (main.go:3) TEXT main.add(SB), ABIInternal, $48-0
0x0000 00000 (main.go:3) CMPQ SP, 16(R14)
0x0004 00004 (main.go:3) PCDATA $0, $-2
0x0004 00004 (main.go:3) JLS 147
0x000a 00010 (main.go:3) PCDATA $0, $-1
0x000a 00010 (main.go:3) SUBQ $48, SP
0x000e 00014 (main.go:3) MOVQ BP, 40(SP)
0x0013 00019 (main.go:3) LEAQ 40(SP), BP
0x0018 00024 (main.go:3) FUNCDATA $0, gclocals·J5F+7Qw7O7ve2QcWC7DpeQ==(SB)
0x0018 00024 (main.go:3) FUNCDATA $1, gclocals·wdmTuppZUxZYftR7OCq88Q==(SB)
0x0018 00024 (main.go:3) MOVQ $0, main.~r0+16(SP)
0x0021 00033 (main.go:4) LEAQ type:int(SB), AX
0x0028 00040 (main.go:4) PCDATA $1, $0
0x0028 00040 (main.go:4) CALL runtime.newobject(SB)
0x002d 00045 (main.go:4) MOVQ AX, main.&x+32(SP)
0x0032 00050 (main.go:4) MOVQ $1, (AX)
0x0039 00057 (main.go:5) LEAQ type:noalg.struct { F uintptr; main.x *int }(SB), AX
0x0040 00064 (main.go:5) PCDATA $1, $1
0x0040 00064 (main.go:5) CALL runtime.newobject(SB)
0x0045 00069 (main.go:5) MOVQ AX, main..autotmp_2+24(SP)
0x004a 00074 (main.go:5) LEAQ main.add.func1(SB), CX
0x0051 00081 (main.go:5) MOVQ CX, (AX)
0x0054 00084 (main.go:5) MOVQ main..autotmp_2+24(SP), DI
0x0059 00089 (main.go:5) TESTB AL, (DI)
0x005b 00091 (main.go:5) MOVQ main.&x+32(SP), CX
0x0060 00096 (main.go:5) LEAQ 8(DI), DX
0x0064 00100 (main.go:5) PCDATA $0, $-2
0x0064 00100 (main.go:5) CMPL runtime.writeBarrier(SB), $0
0x006b 00107 (main.go:5) JEQ 111
0x006d 00109 (main.go:5) JMP 117
0x006f 00111 (main.go:5) MOVQ CX, 8(DI)
0x0073 00115 (main.go:5) JMP 127
0x0075 00117 (main.go:5) MOVQ DX, DI
0x0078 00120 (main.go:5) CALL runtime.gcWriteBarrierCX(SB)
0x007d 00125 (main.go:5) JMP 127
0x007f 00127 (main.go:5) PCDATA $0, $-1
0x007f 00127 (main.go:5) MOVQ main..autotmp_2+24(SP), AX
0x0084 00132 (main.go:5) MOVQ AX, main.~r0+16(SP)
0x0089 00137 (main.go:5) MOVQ 40(SP), BP
0x008e 00142 (main.go:5) ADDQ $48, SP
0x0092 00146 (main.go:5) RET
0x0093 00147 (main.go:5) NOP
0x0093 00147 (main.go:3) PCDATA $1, $-1
0x0093 00147 (main.go:3) PCDATA $0, $-2
0x0093 00147 (main.go:3) CALL runtime.morestack_noctxt(SB)
0x0098 00152 (main.go:3) PCDATA $0, $-1
0x0098 00152 (main.go:3) JMP 0
0x0000 49 3b 66 10 0f 86 89 00 00 00 48 83 ec 30 48 89 I;f.......H..0H.
0x0010 6c 24 28 48 8d 6c 24 28 48 c7 44 24 10 00 00 00 l$(H.l$(H.D$....
0x0020 00 48 8d 05 00 00 00 00 e8 00 00 00 00 48 89 44 .H...........H.D
0x0030 24 20 48 c7 00 01 00 00 00 48 8d 05 00 00 00 00 $ H......H......
0x0040 e8 00 00 00 00 48 89 44 24 18 48 8d 0d 00 00 00 .....H.D$.H.....
0x0050 00 48 89 08 48 8b 7c 24 18 84 07 48 8b 4c 24 20 .H..H.|$...H.L$
0x0060 48 8d 57 08 83 3d 00 00 00 00 00 74 02 eb 06 48 H.W..=.....t...H
0x0070 89 4f 08 eb 0a 48 89 d7 e8 00 00 00 00 eb 00 48 .O...H.........H
0x0080 8b 44 24 18 48 89 44 24 10 48 8b 6c 24 28 48 83 .D$.H.D$.H.l$(H.
0x0090 c4 30 c3 e8 00 00 00 00 e9 63 ff ff ff .0.......c...
rel 36+4 t=14 type:int+0
rel 41+4 t=7 runtime.newobject+0
rel 60+4 t=14 type:noalg.struct { F uintptr; main.x *int }+0
rel 65+4 t=7 runtime.newobject+0
rel 77+4 t=14 main.add.func1+0
rel 102+4 t=14 runtime.writeBarrier+-1
rel 121+4 t=7 runtime.gcWriteBarrierCX+0
rel 148+4 t=7 runtime.morestack_noctxt+0
汇编代码中有这样一行:LEAQ type:noalg.struct { F uintptr; main.x *int }(SB), AX
这里明确定义了闭包的结构体指针:
struct {
F uintptr // 函数指针
main.x *int // 引用环境 x 变量
}
main 函数中给 f1、f2 变量进行赋值,分别执行了 CALL main.add(SB)
语句,两次调用了 add 函数;在 add 函数汇编语句中, CALL runtime.newobject(SB)
语句的执行将引用环境和函数指针都分配到了堆上,为 f1 和 f2 都分配了闭包结构体指针,f1 变量指向 main 函数栈地址 main.f1+8(SP
;f2 指向 main 函数栈地址 main.f2(SP)
。当 main 函数中两次调用 add 函数时,会产生两个闭包的结构体指针,拥有共同的函数入口,但引用环境互相隔离,因此 f1 和 f2 执行互不影响。
0x0014 00020 (main.go:12) PCDATA $1, $0
0x0014 00020 (main.go:12) CALL main.add(SB)
0x0019 00025 (main.go:12) MOVQ AX, main.f1+8(SP)
0x001e 00030 (main.go:13) PCDATA $1, $1
0x001e 00030 (main.go:13) NOP
0x0020 00032 (main.go:13) CALL main.add(SB)
0x0025 00037 (main.go:13) MOVQ AX, main.f2(SP)
0x0029 00041 (main.go:14) MOVQ main.f1+8(SP), DX
0x002e 00046 (main.go:14) MOVQ (DX), AX
0x0031 00049 (main.go:14) PCDATA $1, $2
0x0031 00049 (main.go:14) CALL AX
0x0033 00051 (main.go:15) MOVQ main.f2(SP), DX
0x0037 00055 (main.go:15) MOVQ (DX), AX
0x003a 00058 (main.go:15) PCDATA $1, $0
0x003a 00058 (main.go:15) CALL AX
自由变量捕捉时对于值的处理可以是值拷贝,也可以是名称引用,这个也是由 Go 编译器进行优化的,还是刚刚的例子,去掉 x++ 这一行代码,我们再执行一下汇编对比一下:
func add() func() int {
x := 1
return func() int {
return x
}
}
通过对x := 1
这一行汇编语句的前后对比,发现 x 变量没有被分配到堆上,而是分配到了栈上。
// 之前
LEAQ type:int(SB), AX
PCDATA $1, $0
CALL runtime.newobject(SB)
MOVQ AX, main.&x+32(SP)
MOVQ $1, (AX)
// 之后
MOVQ $1, main.x+16(SP)
同时闭包结构体中 x 自由变量前后也发生了变化:
// 之前
struct {
F uintptr // 函数指针
main.x *int // 引用环境 x 变量
}
// 之后
struct {
F uintptr
main.x int
}
2.2 自由变量的捕获
通过对比闭包结构体 x 自由变量的变化,可以发现当闭包中改变自由变量时(x++),自由变量捕获方式为名称引用,而相对的则为值拷贝。
识别出变量需要在堆上分配,是由编译器的一种叫 escape analyze 的技术实现的,中文也称之为“逃逸分析”,我们用逃逸分析再验证一下自由变量的捕获方式。
再次利用上边的两种代码分别执行:go build -gcflags '-m -l' main.go
,得到两种结果,接下来对这两种结果进行一下分析和讲解。
2.2.1 自由变量名称引用
通过逃逸分析,我们不难发现:当函数中改变了自由变量 x(例如 x++),x 发生了逃逸,变量不在栈上分配,而是逃逸到了堆上 moved to heap: x。此时,自由变量的捕获变为了名称引用:main.x *int,闭包结构体中自由变量引用了逃逸到堆上的 x 变量,以指针的形式存在。
与此同时,我们发现函数字面量(闭包结构体)也发生了逃逸,被分配到了堆上,这是因为 add 函数返回的 func() int
函数字面量是一个指针,指向了闭包结构体,add 函数内部无法判断其是否被外部引用,所以内存不能随 add 函数栈一起消亡。
当修改代码如下,x 变量并不是在匿名函数中改变,而是在引用环境中发生改变,x 依然会发生逃逸,和在匿名函数中发生改变是一样的结果:
func add() func() int {
x := 1
f := func() int {
return x
}
x++
return f
}
2.2.2 自由变量值拷贝
相反,当 x 在函数中不发生改变时,x 变量没有发生逃逸,只是简单的被分配到了 add 的函数栈里,随着函数栈的消亡而消亡,那 x 是如何被闭包引用的呢?
此时,闭包结构体依然以指针返回的形式逃逸到了堆上,其中自由变量 x 变为值拷贝,而不是指向 x 的引用,x 随闭包结构体一起被分配到堆上存储,最终也能被闭包结构使用。
自由变量的捕获方式是由 Go 编译器经过分析后决定的,不仅仅是因为函数中有没有对自由变量做更改而决定捕获方式,还存在一部分例外情况,当自由变量占用存储空间过大时,也会优化为名称引用,我们可以使用逃逸分析其捕获情况。
3闭包的使用场景
3.1 隔离数据
使用闭包最主要的意义就是:缩小变量作用域,减少对全局变量的污染。换句话说就是隔离数据。
举个例子,闭包用于计算函数调用次数:
package main
import (
"fmt"
"time"
)
// 函数计数器
func counter(x int, f func()) func() int {
return func() int {
f()
x += 1
return x
}
}
func fn() {
time.Sleep(time.Second)
fmt.Println("exec fn")
}
func main() {
fc := counter(0, fn)
fc()
fc()
fc()
fmt.Println(fc())
}
// 执行结果
exec fn
exec fn
exec fn
exec fn
4
通过这个例子可以看出,一旦使用了 counter 函数包装了要执行的 fn 函数,就为每一个 fn 函数建立了独立的计数变量,互不影响,且很难在外部对该变量进行改变,防止了变量污染和全局变量的维护工作。
3.2 搭配 defer 使用
闭包可以很好保存程序运行的状态,搭配 defer 关键字能让程序的逻辑更加清晰。下边是统计代码耗时的例子:
func main() {
start := time.Now()
defer func() {
fmt.Println(time.Since(start)) // 1.005156635s
}()
// 省略 100 行代码
...
time.Sleep(time.Second)
}
3.3 用作装饰器
高阶函数一般以其他函数作为参数传入或把其他函数作为结果返回,多用于逻辑的封装,而装饰器模式的实现离不开高阶函数。 举个例子,封装一个用于加减乘除运算的函数,两个整数和具体的操作都由该函数的调用方给出,并添加装饰器对参数进行校验。
package main
import (
"errors"
"fmt"
)
type operate func(x, y int) int
type calculateFunc func(x int, y int) (int, error)
func checkCalculator(op string, fn operate) calculateFunc {
return func(x int, y int) (int, error) {
switch op {
case "add":
case "sub":
case "multi":
case "divide":
if y == 0 {
return 0, errors.New("invalid y")
}
default:
return 0, errors.New("invalid operation")
}
if fn == nil {
return 0, errors.New("invalid operation")
}
return fn(x, y), nil
}
}
func main() {
addop := func(x, y int) int {
return x + y
}
add := checkCalculator("add", addop) // 加法
result, err := add(1, 2) // 3
fmt.Println(result, err)
subop := func(x, y int) int {
return x - y
}
sub := checkCalculator("sub", subop) // 减法
result, err = sub(6, 3) // 3
fmt.Println(result, err)
multiop := func(x, y int) int {
return x * y
}
multi := checkCalculator("multi", multiop) // 乘法
result, err = multi(1, 3) // 3
fmt.Println(result, err)
divideop := func(x, y int) int {
return x / y
}
divide := checkCalculator("divide", divideop) // 乘法
result, err = divide(6, 2) // 3
result, err = divide(6, 0) // 0 invalid y
fmt.Println(result, err)
}
4.闭包常见的坑
4.1 常见坑
案例一:
func main() {
a := []int{1, 2, 3, 4}
fns := make([]func(), 0)
for _, v := range a {
fns = append(fns, func() {
fmt.Println(v)
})
}
for _, f := range fns {
f()
}
}
// 输出结果
0xc0000240a0
0xc0000240a0
0xc0000240a0
0xc0000240a0
4
4
4
4
讲解:首先 for range 中 v 是循环可复用变量,变量地址都是不变的 0xc0000240a0;其次闭包中的自由变量 v 在函数中会被修改,最终修改为 4,该自由变量是会发生逃逸的,自由变量捕捉为名称引用,所以闭包中变量会随 v 一起变化,最终全部为 4。这里可以用逃逸分析验证一下:
案例二:
package main
import "fmt"
func main() {
a := []int{1, 2, 3, 4}
for _, v := range a {
go func() {
fmt.Println(v)
}()
}
select {}
}
// 输出结果
4
4
4
4
案例二和案例一同样是自由变量 v 发生了逃逸导致的名称引用,协程中 v 随 for range 变化而改变;这个案例也是常见的,和案例一略有不同;案例一明确知道 v 变为了 4 之后执行的,所以结果必然是 4;而案例二是协程执行的,只是因为 for range 太快了,导致 v 变化太快,而协程调度执行需要时间,闭包执行的时候 v = 4 的概率最大,所以结果全为 4。我们加入一行,做一个小实验来验证一下:
func main() {
a := []int{1, 2, 3, 4}
for _, v := range a {
go func() {
fmt.Println(v)
}()
time.Sleep(time.Second)
}
select {}
}
// 输出结果
1
2
3
4
4.2 解决方案
一般解决方案有两种
- 一种是给 for range 中添加新的变量指向 v
- 一种是通过给匿名函数传递参数的方式
方案一:给 for range 中添加新的变量指向 v
func main() {
a := []int{1, 2, 3, 4}
fns := make([]func(), 0)
for _, v := range a {
vt := v
fmt.Println(&vt)
fns = append(fns, func() {
fmt.Println(vt)
})
}
for _, f := range fns {
f()
}
}
// 输出结果
0xc00018e008
0xc00018e020
0xc00018e028
0xc00018e030
1
2
3
4
由于每次 vt 都是新局部变量,且并未发生改变,因此匿名函数中引用的环境没有发生变化。
方案二:通过给匿名函数传递参数的方式
func main() {
a := []int{1, 2, 3, 4}
for _, v := range a {
go func(vt int) {
fmt.Println(vt)
}(v)
}
select {}
}
// 输出结果
2
4
1
3
通过值拷贝的方式传递到匿名函数,成为匿名函数的新变量,不受外部环境 v 的影响。
小结
本篇文章最关键的点在于理解:闭包 = 函数指针 + 引用环境,而不同闭包间引用环境是互相隔离的;闭包的引用环境中存在名称引用的自由变量,使用起来得格外小心,虽然 Go 源码中闭包很常见,也可以利用闭包完成很多高阶函数编程,但闭包还是要慎用,误用有可能导致内存泄漏。
转载自:https://juejin.cn/post/7295926959841247267