前几天在机械的堆砌业务代码时,不小心掉进了 Go 循环中使用闭包的一个坑,因此借这个机会总结一下 Go 闭包问题相关的知识。

1. 什么是闭包?

一个函数和对其周围状态(词法环境)的引用捆绑在一起,这样的组合就是闭包。

直接看定义,很难理解闭包到底什么。所以我画了下面这张图:

这张图中,自由变量 i 和函数 f 构成了闭包。

由此,可以总结闭包的几个关键点:

  1. 自由变量 i 和函数 f 同属于一个局部环境
  2. 函数 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 的副本。

参考文章


原创不易,欢迎关注我的公众号【码农的自由之路】,左手代码,右手理财,一路同行,行至自由~~

都看到这里了,不如点个 赞/在看,加个关注呗~~