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 函数依赖于两个闭包(buildCurTokenupdate)来使代码更短且更易于理解。

当将闭包(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)有两个参数 ij。在闭包内部使用了切片 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
}

这里 incrementget 都引用了 makeCounters 中的同一个 count

调用:inc() 会修改这份共享状态,而之后调用:get() 读取的也是同一份状态,所以最终得到 2

这也是闭包经常被用来实现状态封装的原因。count 本身没有暴露给外部代码,外部只能通过 incrementget 两个函数访问它,但这两个函数内部又共享同一份数据。

类比

类似于 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 就会产生未定义行为。

函数是无状态的:函数本身只是机器指令

闭包是有状态的:闭包不只有函数代码,还包含函数运行时所依赖的外部环境,例如捕获的变量。

闭包总结

闭包不只有函数代码,还包含函数运行时所依赖的外部环境。

闭包捕获的是变量本身的访问关系,因此变量的生命周期可能因为闭包而延长,多个闭包也可能共同操作同一份状态。

评论