前几天在机械的堆砌业务代码时,不小心掉进了 Go 循环中使用闭包的一个坑,因此借这个机会总结一下 Go 闭包问题相关的知识。
1. 什么是闭包?
一个函数和对其周围状态(词法环境)的引用捆绑在一起,这样的组合就是闭包。
直接看定义,很难理解闭包到底什么。所以我画了下面这张图:

这张图中,自由变量 i 和函数 f 构成了闭包。
由此,可以总结闭包的几个关键点:
- 自由变量 i 和函数 f 同属于一个局部环境
- 函数 f 内部直接使用了自由变量 i
在外部环境无法直接访问自由变量,通过执行函数 f 能实现对 i 的操作。
需要注意的是,自由变量不一定是在局部环境中定义的,也有可能是以参数的形式传进局部环境;另外在 Go 中,函数也可以作为参数传递,因此函数也可能是自由变量。
2. 闭包的应用场景
2.1 数据隔离
需求:
统计一个函数的执行次数,并打印出来(其实就是计数器)
不考虑闭包,短平快的一种实现方式是声明一个全局变量,函数每执行一次,变量值加一,并打印。
这种方法的一个缺点是全局变量容易被修改,安全性较差。闭包可以解决这个问题:
2.2 中间件
Go 中的中间件和 Python 中的装饰器十分类似。
在 Go 中,函数是 一等公民,即函数可以像普通类型一样,被赋值给变量,作为参数传递,作为返回值。
因此在闭包中,除了动态创建函数,还可以通过参数传递的方式,将函数穿进去,实现闭包。
典型的应用场景是中间件。
需求:
计算任意函数(函数签名一致)的执行耗时。
具体实现如下:
在这个例子中,函数 printN 是自由变量。
printN 原本是普通的函数,但是通过 timer 的包裹,返回的 printNWithTimer 不仅具备 printN 的全部功能(且不需要了解实现),还能计算 printN 的执行耗时。
2.3 访问原本访问不到的数据
在一些场景下,只能传递参数类型固定的函数,这个时候如果要访问额外的数据,就可以使用闭包。
net/http
http.HandleFunc 的第二个参数只接受函数签名如下的函数:
在不使用全局变量的情况下,我们可以通过闭包实现对 db 的访问。
当然,这种情况我们通常采取的是另一种解决方式:对结构体 Database 增加一个相同函数签名的成员函数。
2.4 二分查找
sort
需求:
对任意一个有序列表,查找大于指定值的索引。注意,有序列表的元素是自定义类型。
由于是自定义类型,常见的做法是每个自定义类型都实现自己的查找方法,但是如果使用的闭包的话,就简单很多。
sort.Search
上面这个作为参数传递的闭包,绑定了自由变量 numbers 和指定比较的对象 7,匿名函数实现了比较大小的功能。
sort.Search
2.5 defer
Go 中 defer 常常和闭包结合在一起用,常见的一种用法就是在函数返回后关闭文件。
defer 的机制是将后面的函数注册到 defer 的函数栈中,当前函数 handleFile 执行完成之后,defer 将函数栈的中函数取出来,一个一个的执行。
在这里,fPtr.Close() 其实是一个闭包(携带自由变量 fPtr),因此,即使 handleFile 执行结束,Close 函数仍然能对 fPtr 进行关闭操作。
3. 闭包的几个注意点
3.1 值还是引用?
闭包对自由变量的修改是引用的方式。
输出结果:
因为是引用,f1() 修改了 i 的值后,f2() 中 i 的初始值变成了1。
3.2 自由变量的生命周期
闭包中,自由变量的生命周期等同于闭包函数的生命周期,和局部环境的周期无关。
借用参考文章[3]中的一张图:

闭包函数第一次调用之后,自由变量即进入堆内存上,后续闭包函数的每一次调用,都是对自由变量的引用。
Go 循环中使用闭包的一个坑
前一段时间,在业务代码中写出了如下的代码:
go vet main.go
这段代码的输出结果如下:
每次输出都不一定一样,但是都不符合预期。
这是因为 for 循环中开启的协程其实是闭包,6个并发协程读的都是同一个变量。
修改方式也很简单,不直接引用变量 i,而是通过传参的方式读取 i 的副本。
参考文章
原创不易,欢迎关注我的公众号【码农的自由之路】,左手代码,右手理财,一路同行,行至自由~~
都看到这里了,不如点个 赞/在看,加个关注呗~~