Golang 闭包详解
什么是闭包
闭包是由函数及其相关引用环境组合而成的实体( 即:闭包 = 函数 + 引用环境 )。换句话说,闭包是在 **匿名函数中引用该函数外的局部变量或全局变量,**通过一个示例来理解。
func Closure() func() {
i := 0
return func() {
i++
fmt.Printf("i: %d, address: %v\\n", i, &i)
}
}
func main() {
// c 就是一个闭包
c := Closure()
// c 保存着对 i 的引用,也就是 c 中有一个指针指向 i
c()
c()
}
运行结果:
i: 1, address: 0xc0000bc000
i: 2, address: 0xc0000bc000
Closure
函数返回一个匿名函数,该匿名函数引用了函数外的局部变量 i
,使得 Closure
函数运行结束,i
并没有跟着函数被销毁。
上面示例中 c := Closure()
,c
就是一个闭包。c
保存着对 i
的引用,也就是 c
中有一个指针指向 i
。所以每次调用 c
,i
都会发生变化。
如果使用下面方式运行闭包,结果就会不同。
func Closure() func() {
i := 0
return func() {
i++
fmt.Printf("i: %d, address: %v\\n", i, &i)
}
}
func main() {
Closure()()
Closure()()
}
运行结果:
i: 1, address: 0xc00001a0c0
i: 1, address: 0xc00001a0e0
这里的 i
值不变,是因为 main
函数返回了两个闭包,这两个闭包分别引用了两个 i
,这两个 i
的内存地址不一样,所以互不影响。
闭包原理
还是通过上面的例子分析,可以看出,变量没有跟着函数运行结束而销毁,是因为该变量被匿名函数引用了,从上述代码的汇编看看:
func Closure() func() {
i := 0
return func() {
i++
fmt.Println("i: ", i)
}
}
func main() {
// c 就是一个闭包
c := Closure()
// c 保存着对 i 的引用,也就是 c 中有一个指针指向 i
c()
c()
}
运行结果:
$ go build -gcflags=-m test.go
# command-line-arguments
./test.go:11:14: inlining call to fmt.Println
./test.go:8:2: moved to heap: i
./test.go:9:9: func literal escapes to heap
./test.go:11:15: "i: " escapes to heap
# 局部变量 i 发生了逃逸
./test.go:11:15: i escapes to heap
./test.go:11:14: []interface {}{...} does not escape
<autogenerated>:1: leaking param content: .this
根据汇编可以发现局部变量 i
逃逸到了堆上面,所以匿名函数可以引用到。
上面的例子是局部变量发生了逃逸,如果闭包引用全局变量呢?
// 定义全局变量
var j int
func Closure() func() {
return func() {
j++
fmt.Println("j: ", j)
}
}
func main() {
c := Closure()
c()
c()
}
运行结果:
$ go build -gcflags=-m test.go
# command-line-arguments
./test.go:10:14: inlining call to fmt.Println
./test.go:8:9: func literal escapes to heap
./test.go:10:15: "j: " escapes to heap
./test.go:10:15: j escapes to heap
./test.go:10:14: []interface {}{...} does not escape
<autogenerated>:1: leaking param content: .this
同样发现全局变量 j
也发生了逃逸,我们知道全局变量定义在内存的静态区域,不需要在堆上分配内存。但是,如果全局变量被其他函数引用,则可能会发生逃逸。这里的全局变量 j
就被匿名函数引用了。在使用闭包的时候建议就不要用全局变量了,闭包的一个重要场景就是减少全局变量的使用。
闭包的坑
在使用闭包的过程中,可能会遇到以下坑
引用变量是函数形参
在使用闭包时,如果闭包内引用的变量是该闭包的入参,会有什么结果
func Closure() func(int) {
return func(x int) {
x++
fmt.Printf("x: %d, address: %v\\n", x, &x)
}
}
func main() {
// c 就是一个闭包
c := Closure()
// x 作为参数传入闭包
x := 0
c(x)
c(x)
}
运行结果:
x: 1, address: 0xc00001a0c0
x: 1, address: 0xc00001a0e0
可以发现两次调用闭包,并不是对变量进行递增,且变量的内存地址也不同。是因为如果引用变量作为闭包形参,那么该参数会被值传递给闭包内使用,也就是说闭包内使用的参数其实是副本。
要解决这个问题,就去除闭包内的参数,然后定义局部变量,使得闭包内引用同一个变量,而不是副本。具体代码可参考最上面的篇幅。
goroutine 运行闭包
在一个循环内,使用 goroutine 运行闭包时可能会发生意想不到的结果
func main() {
s := []string{"a", "b", "c"}
for _, v := range s {
go func() {
fmt.Printf("v: %s, address: %v\\n", v, &v)
}()
}
select {} // 阻塞模式
}
运行结果:
v: c, address: 0xc00010c200
v: c, address: 0xc00010c200
v: c, address: 0xc00010c200
// 这里发生死锁,是因为 select{} 一直无法退出,导致 goroutine 泄露
fatal error: all goroutines are asleep - deadlock!
发现每次遍历运行结果都一样,且 v
的内存地址也一样。
首先看内存地址都一样,是因为 for range
在运行前会创建一个全局变量来保存每次遍历的结果,所以每次遍历的值的内存地址都是这个全局变量的内存地址,所以内存地址都一样。至于为什么每次编译打印的值都是 c
,是因为 goroutine
在运行之前 for range
已经运行完了,导致 v
最后是遍历的最后一个值,所以打印的都是 c
。
如何修改
修改方法其实有多种,但是原理都是重新创建临时变量保存当前遍历的值。
func main() {
s := []string{"a", "b", "c"}
for _, v := range s {
go func(v string) {
fmt.Printf("v: %s, address: %v\\n", v, &v)
}(v)
}
// 保证 goroutine 运行完成
time.Sleep(5 * time.Second)
}
运行结果:
v: c, address: 0xc000054210
v: a, address: 0xc000054230
v: b, address: 0xc000054250
上面就是通过在闭包中添加函数入参,使用临时变量来保存遍历值,所以每次内存地址都不一样,至于为什么不是按照 a、b、c
的打印顺序,这是因为 goroutine 的运行不确定性导致的。
闭包使用场景
经过上面的分析,只是大致了解了 Go 语言闭包的使用和简单原理,那闭包到底有哪些使用场景呢?
计数器
如果想知道一个函数的调用次数,可以通过闭包来实现。每次调用闭包时将计数器的值加 1 .
实现高阶函数
将一个函数作为另一个函数的参数或返回值
延迟调用
使用 defer 执行闭包
减少全局变量
实际上闭包完全可以通过普通函数搭配全局变量来实现,但是这样会导致程序中的全局变量泛滥。
总结
闭包作为 Go 语言一种语法糖,简化了编程但是同时也提高了开发者使用门槛,如果使用不当则会产生意想不到的结果,所以在使用闭包之前得先了解闭包的使用方法和适用场景。