目录

Golang 闭包详解

什么是闭包

闭包是由函数及其相关引用环境组合而成的实体( 即:闭包 = 函数 + 引用环境 )。换句话说,闭包是在 **匿名函数中引用该函数外的局部变量或全局变量,**通过一个示例来理解。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
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。所以每次调用 ci 都会发生变化。

如果使用下面方式运行闭包,结果就会不同。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
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 的内存地址不一样,所以互不影响。

闭包原理

还是通过上面的例子分析,可以看出,变量没有跟着函数运行结束而销毁,是因为该变量被匿名函数引用了,从上述代码的汇编看看:

 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
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 逃逸到了堆上面,所以匿名函数可以引用到。

上面的例子是局部变量发生了逃逸,如果闭包引用全局变量呢?

 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
// 定义全局变量
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 就被匿名函数引用了。在使用闭包的时候建议就不要用全局变量了,闭包的一个重要场景就是减少全局变量的使用。

闭包的坑

在使用闭包的过程中,可能会遇到以下坑

引用变量是函数形参

在使用闭包时,如果闭包内引用的变量是该闭包的入参,会有什么结果

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
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 运行闭包时可能会发生意想不到的结果

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
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

如何修改

修改方法其实有多种,但是原理都是重新创建临时变量保存当前遍历的值。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
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 语言一种语法糖,简化了编程但是同时也提高了开发者使用门槛,如果使用不当则会产生意想不到的结果,所以在使用闭包之前得先了解闭包的使用方法和适用场景。


WeChat Pay
关注微信公众号,可了解更多云原生详情~

相关文章