Golang 闭包
5.3 闭包(closures)
闭包(closures)是计算机科学领域中的一个专业术语,在函数内部声明的函数能够访问和修改外部函数中声明的变量。在 Go 语言中,闭包(closures)通常指的是在函数内部(称为外部函数)定义的匿名函数,它可以捕获并使用其外部函数的局部变量。
func main() {
a := 20
f := func() { // 内部函数(闭包)能访问外部函数的局部变量 a
fmt.Println(a) // 闭包内可以访问外部函数的局部变量 a
a = 30
}
f()
fmt.Println(a)
}
运行上述程序,将看到如下输出:
20
30
与在任何内部作用域中一样,也可以在闭包(closures)内部遮蔽其外部函数的变量。如果将代码更改为:
func main() {
a := 20
f := func() {
fmt.Println(a) // ❶ 闭包内可以访问外部函数的局部变量 a
a := 30 // ❷ 闭包内声明了一个新的局部变量 a,遮蔽了外部函数的同名变量
fmt.Println(a) // ❸
}
f()
fmt.Println(a) // ❹ 外部函数的局部变量 a 未被修改
}
在闭包(closures)内部(❷)使用 :=(而不是 =)会创建一个新的变量 a。当闭包(closures)退出时,这个新的 a 就不再存在。在处理内部函数时,要小心使用短变量赋值运算符,特别是在赋值运算符左侧有多个变量时。运行此代码,将得到如下输出:
20
30
20
初次接触闭包和内部函数时,它们的重要性可能不会立即显现。这是因为这些概念比较抽象,而且它们的用途可能不像其他编程概念那样直观。在较大的函数内,创建迷你函数有什么好处?为什么 Go 语言要提供闭包(closures)这个特性?
闭包(closures)可以限制函数的作用域。如果一个函数 f1 只被另一个函数 f2 调用,那么将函数 f1 定义为内部函数可以将其“隐藏”起来,这样函数 f1 就不会在包的其他部分中被意外调用或修改。这减少了包级别的声明数量,使得查找未使用的名称变得更加容易,因为内部函数不会在包的顶层命名空间中占据位置。
如果函数内部有重复多次的业务逻辑,可以使用闭包(closures)来消除这种重复,从而使代码更加简洁。我编写了一个简单的 Lisp 解释器,其中有一个 Scan 函数,它处理输入字符串以查找 Lisp 程序的部分。Scan 函数依赖于两个闭包(buildCurToken 和 update)来使代码更短且更易于理解。
当将闭包(closures)传递给其他函数或从函数返回闭包(closures)时,闭包会变得特别有趣,因为闭包会携带它们被定义时的作用域中的变量。
闭包(closures)允许在函数外部使用函数内部的变量,这是闭包最强大的特性之一。这意味着闭包可以“记住”并访问创建它们时的环境中的变量,即使那个环境已经不存在了(即扩展了变量的作用域)。
5.3.1 将函数作为参数传递
既然函数也是值,并且可以通过其参数和返回值类型来指定函数的类型。那么,就可以将函数作为参数传递给其他函数。如果不习惯将函数当作数据来处理,那么在创建一个引用了局部变量的闭包(closures),并且将这个闭包传递给另一个函数时,可能需要花些时间来思考这一行为的含义。这是一种非常有用的模式,它在标准库中多次出现。
其中一个示例是切片排序。标准库 sort 包中含有一个函数 sort.Slice。该函数接受任意类型的切片,以及一个用于对传入的切片进行排序的函数。接下来,通过使用两个不同的字段来对一个结构体切片进行排序,看看它是如何工作的。
看看如何使用闭包(closures)以不同的方式对相同的数据进行排序。首先,定义一个结构体 Person(❶),用该结构体创建一个切片 people(❷),并打印此切片(❸),如下所示:
type Person struct { // ❶ 定义一个结构体 Person
FirstName string
LastName string
Age int
}
people := []Person{ // ❷ 创建一个 Person 的切片
{"Pat", "Patterson", 37},
{"Tracy", "Bobdaughter", 23},
{"Fred", "Fredson", 18},
}
fmt.Println(people) // ❸ 打印切片
其次,按 LastName 对切片进行排序,并打印排序结果:
// 按 LastName 对切片进行排序
sort.Slice(people, func(i, j int) bool {
return people[i].LastName < people[j].LastName
})
fmt.Println(people)
传递给 sort.Slice 函数的闭包(closures)有两个参数 i 和 j。在闭包内部使用了切片 people,因此可以按 LastName 字段对切片 people 进行排序。用计算机科学术语来说,people 被闭包捕获了。
最后,用同样的处理方式,按 Age 字段对切片 people 进行排序:
// 按 Age 字段排序
sort.Slice(people, func(i, j int) bool {
return people[i].Age < people[j].Age
})
fmt.Println(people)
运行此示例代码,将得到如下输出结果:
[{Pat Patterson 37} {Tracy Bobdaughter 23} {Fred Fredson 18}]
[{Tracy Bobdaughter 23} {Fred Fredson 18} {Pat Patterson 37}]
[{Fred Fredson 18} {Tracy Bobdaughter 23} {Pat Patterson 37}]
将函数作为参数传递给其他函数,通常对于在同一类型的数据上执行不同的操作非常有用。
5.3.2 从函数中返回函数
闭包(closures)不仅可以用来在函数之间传递状态,还可以作为一个函数的返回值。下面通过一个乘法器函数来演示这一点。如下所示,函数 makeMult 返回一个闭包(closures)。
func makeMult(base int) func(int) int { // 返回一个闭包函数
return func(factor int) int {
return base * factor
}
}
如下是函数 makeMult 的使用方法:
func main() {
twoBase := makeMult(2)
threeBase := makeMult(3)
for i := 0; i < 3; i++ {
fmt.Println(twoBase(i), threeBase(i))
}
}
运行上述程序,将得到如下结果:
0 0
2 3
4 6
通过前面的示例,对闭包(closures)的概念已经有了初步了解,但或许会好奇:“Go 开发者是否频繁使用闭包?”事实上,闭包(closures)的应用场景非常广泛。闭包(closures)可以用于切片排序,可以通过 sort.Search 高效地搜索有序切片。此外,返回闭包的编程模式在构建 Web 中间件时也会用到(详见“13.4.2.1 中间件<298页>”)。Go 还通过 defer 关键字利用闭包来实现资源清理。
闭包原理
闭包可以理解为“函数 + 函数所引用的外部变量”。一个函数即使离开了这些变量原本所在的作用域,只要它仍然持有对这些变量的引用,就可以继续访问甚至修改它们。
例如下面这个计数器:
func makeCounter() func() int {
count := 0
return func() int {
count++
return count
}
}
func main() {
counter := makeCounter()
fmt.Println(counter()) // 1
fmt.Println(counter()) // 2
}
正常情况下,makeCounter 执行结束之后,它内部定义的局部变量 count 也应该随函数栈帧一起失效。但这里返回的匿名函数仍然引用着 count,所以 Go 必须保证 makeCounter 返回以后,这个变量依旧存活。
这就是闭包能够“记住状态”的根本原因。
从 Go runtime 的实现来看,一个函数值底层可以用 funcval 来描述:
// runtime/runtime2.go
type funcval struct {
fn uintptr // 指向函数代码
// 后面跟随闭包环境数据
}
因此,一个闭包并不只是单纯的代码地址。除了知道“调用哪段代码”,它还需要携带这段代码运行时依赖的外部环境。
概念上,可以把一个闭包函数值理解成下面这样的内存结构:
+-------------------+
| fn(函数代码指针) |
+-------------------+
| captured var1 |
+-------------------+
| captured var2 |
+-------------------+
这里需要注意,这只是为了帮助理解闭包实现方式的简化模型。编译器会根据变量的使用方式决定具体如何保存捕获变量,并不意味着所有闭包都严格按照这样固定的结构存储。
捕获变量为什么会逃逸
编译闭包时,编译器会分析匿名函数引用了哪些外部变量,同时结合逃逸分析判断这些变量应该放在栈上还是堆上。
还是前面的例子:
func makeCounter() func() int {
count := 0
return func() int {
count++
return count
}
}
匿名函数引用了 count,并且这个匿名函数会作为返回值离开 makeCounter。也就是说,makeCounter 返回以后,闭包仍然可能继续访问 count。
此时 count 的生命周期已经超过了 makeCounter 本身的栈帧,因此它不能简单地随着函数返回而销毁。编译器通常会让这个变量逃逸,使其拥有足够长的生命周期。
可以通过下面的命令查看 Go 编译器的逃逸分析结果:
go build -gcflags="-m" main.go
通常可以看到类似输出:
./main.go:6:2: moved to heap: count
./main.go:7:9: func literal escapes to heap
所以,更准确地说,并不是“只要出现闭包,变量就一定分配到堆上”,而是编译器会根据闭包的生命周期和实际使用情况进行逃逸分析。当闭包可能在外层函数返回后继续使用某个变量时,这个变量通常就必须逃逸。
为了理解这个过程,可以把编译器生成的逻辑粗略想象成这样:
type counterClosure struct {
count *int
}
func (c *counterClosure) call() int {
*c.count = *c.count + 1
return *c.count
}
func makeCounter() func() int {
count := new(int)
closure := &counterClosure{
count: count,
}
return closure.call
}
实际编译结果并不会真的生成这样一个 Go 结构体,这段代码只是一个等价的概念模型。
关键在于:原来的局部变量 count 不能随着 makeCounter 返回而消失,因此闭包需要保存一份能够继续找到 count 的环境信息。之后每次调用 counter(),实际上都是在操作同一个 count。
这也解释了为什么:
fmt.Println(counter()) // 1
fmt.Println(counter()) // 2
第二次调用能够看到第一次调用留下来的状态。
闭包捕获的是变量,而不是简单的值拷贝
理解 Go 闭包时,一个非常重要的点是:闭包捕获的是外部变量本身,而不是按值拷贝。
这一点在循环中尤其容易产生问题。
例如:
func main() {
funcs := make([]func(), 3)
for i := 0; i < 3; i++ {
funcs[i] = func() {
fmt.Println(i)
}
}
for _, f := range funcs {
f()
}
}
在 Go 1.22 之前,这类代码的经典结果是:
3
3
3
原因是三个闭包捕获的是同一个循环变量 i。
等到后面真正调用这些闭包时,循环已经执行结束,此时共享的 i 已经变成了 3,所以三个闭包读取到的都是相同的值。
Go 1.22 修改了循环变量的语义。对于循环中声明的变量,每一轮迭代都会拥有对应的迭代变量,因此上面的代码在新的语义下会输出:
0
1
2
也就是说,过去非常常见的“循环变量闭包陷阱”,从 Go 1.22 开始已经在语言层面得到了改善。
不过,在阅读旧代码或者维护使用旧版 Go 编译的项目时,仍然会经常遇到以前的写法。例如最经典的解决方案,是在循环内部重新创建一个局部变量:
for i := 0; i < 3; i++ {
i := i
funcs[i] = func() {
fmt.Println(i)
}
}
i := i会创建一个新的局部变量,因此每次循环中的闭包引用的是各自对应的局部变量 i。
另一种思路是通过函数参数完成值传递:
for i := 0; i < 3; i++ {
func(i int) {
funcs[i] = func() {
fmt.Println(i)
}
}(i)
}
每次调用外层匿名函数时,当前的 i 都会作为实参传进去,形成一个新的参数变量。内部闭包捕获的就是这个独立的参数,而不是外层循环变量。
虽然 Go 1.22 之后很多场景已经不再需要这两种写法,但它们仍然非常有助于理解闭包真正捕获的是什么。
多个闭包可以共享同一个变量
闭包捕获变量的另一个直接结果是:如果多个闭包引用的是同一个外部变量,那么它们看到的就是同一份状态。
例如:
func makeCounters() (func(), func() int) {
count := 0
increment := func() {
count++
}
get := func() int {
return count
}
return increment, get
}
func main() {
inc, get := makeCounters()
inc()
inc()
fmt.Println(get()) // 2
}
这里 increment 和 get 都引用了 makeCounters 中的同一个 count。
调用:inc() 会修改这份共享状态,而之后调用:get() 读取的也是同一份状态,所以最终得到 2。
这也是闭包经常被用来实现状态封装的原因。count 本身没有暴露给外部代码,外部只能通过 increment 和 get 两个函数访问它,但这两个函数内部又共享同一份数据。
类比
类似于 C++ lambda 表达式按引用捕获变量:
#include <iostream>
int main() {
int count = 0;
auto counter = [&count]() {
++count;
return count;
};
std::cout << counter() << '\n'; // 1
std::cout << counter() << '\n'; // 2
}
粗略理解,编译器把这个 lambda 生成了一个匿名结构体:
struct __Lambda {
int* count;
int operator()() const {
++(*count);
return *count;
}
};
区别是,Go 编译器发现局部变量被返回的闭包继续引用时,可以通过逃逸分析让变量拥有更长的生命周期;C++ 的引用捕获则可能存在悬空引用问题,例如:
auto makeCounter() {
int count = 0;
return [&count]() {
return ++count;
};
}
makeCounter() 返回之后,局部变量 count 已经销毁,但返回的 lambda 仍然保存着对它的引用。之后调用 lambda 就会产生未定义行为。
函数是无状态的:函数本身只是机器指令
闭包是有状态的:闭包不只有函数代码,还包含函数运行时所依赖的外部环境,例如捕获的变量。
闭包总结
闭包不只有函数代码,还包含函数运行时所依赖的外部环境。
闭包捕获的是变量本身的访问关系,因此变量的生命周期可能因为闭包而延长,多个闭包也可能共同操作同一份状态。
评论