“闭包”(Closure)是编程中一个非常重要、但初学者容易晕的概念。它在函数式编程(Functional Programming)中无处不在,Go 语言对它的支持非常强大。
简单来说,闭包就是:一个函数和它周围环境的绑定。
为了让你彻底理解,我们从三个层面来拆解:通俗定义、代码演示、以及底层原理。
1. 通俗定义:自带“背包”的函数
通常情况下,一个函数执行完,它内部定义的变量就会被销毁(从内存栈中弹出)。
但是闭包不一样。闭包是一个特殊的函数,它被创建的时候,会偷偷把当时它能看到的变量抓取过来,装进自己的“背包”里带走。
公式:
闭包 = 函数代码 + 捕获的外部变量
哪怕创造它的那个外部函数已经执行结束了,闭包依然能通过“背包”访问和修改那些变量。
2. 代码演示:最经典的计数器
这是理解闭包的“Hello World”例子。
Go
|
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
28
29
30
|
package main
import "fmt"
// seq 此时是一个“工厂”,它返回一个函数
// 这个返回的函数返回 int
func seq() func() int {
i := 0 // 这是一个局部变量,按理说 seq 执行完它就该销毁了
// 返回一个匿名函数
return func() int {
i++ // 这个匿名函数引用了外部的 i
return i
}
}
func main() {
// 1. 创建第一个闭包实例 nextNum
// 此时 seq() 执行结束,但变量 i 被 nextNum 的背包抓走了
nextNum := seq()
fmt.Println(nextNum()) // 输出 1
fmt.Println(nextNum()) // 输出 2
fmt.Println(nextNum()) // 输出 3 (i 的状态被保留了!)
// 2. 创建第二个闭包实例 nextNum2
// 它会有自己全新的 i,和上面那个互不干扰
nextNum2 := seq()
fmt.Println(nextNum2()) // 输出 1
}
|
为什么叫闭包? 注意看 i。它定义在 seq 里,但在 main 里通过 nextNum() 依然能一直修改它。nextNum 这个函数把 i “封闭”在自己的作用域里了,这就叫闭包。
3. 底层原理:变量去哪了?(CS 专业向)
作为一个计算机专业的学生,你可能会问:“栈上的局部变量不是函数返回就销毁了吗?为什么 i 还能活下来?”
这里涉及到了 Go 编译器的逃逸分析 (Escape Analysis)。
-
正常情况:函数内部的变量分配在栈 (Stack) 上,函数结束,栈帧弹出,数据销毁。
-
闭包情况:编译器扫描代码,发现变量 i 被内部的匿名函数引用了,而且这个匿名函数被返回到了外部。
-
逃逸:编译器判定 i 不能死在栈上,于是把它分配到了堆 (Heap) 上。
-
引用:返回的闭包函数(nextNum)内部持有一个指针,指向堆上的这个 i。
所以,当你调用 makeHandler(viewHandler) 时,那层“中间件”之所以能记住 viewHandler 是谁,就是因为 fn 这个变量逃逸到了堆上,被返回的那个匿名函数死死抓在手里。
4. 闭包在实战中的三大用途
回到你的 HTTP 题目,以及以后的开发中,闭包主要有这就三种用法:
A. 封装状态(像简化的类)
不需要专门写一个 struct 或者 class 来存状态,直接用闭包。上面的计数器就是例子。
B. 延迟执行 / 回调 (Callback)
当你把一个函数 A 传给另一个函数 B,B 在未来某个时间点调用 A,A 还能记得当初传给它的数据。
C. 中间件 / 装饰器 (Middleware / Decorator)
这就是你那段代码的本质。 你想给原本的函数 viewHandler 加一层“安检”。
Go
|
1
2
3
4
5
6
7
8
9
10
11
12
|
// 这就是一个典型的装饰器模式(Decorator Pattern)
func makeHandler(fn ...) http.HandlerFunc {
return func(...) {
// 1. 前置逻辑 (Pre-processing)
// ... 验证 title ...
// 2. 调用原函数 (Original logic)
fn(...)
// 3. 后置逻辑 (Post-processing) - 可选
}
}
|
总结
看一段代码:
|
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
|
package main
import "fmt"
func adder() func(int) int {
sum := 0
return func(x int) int {
sum += x
return sum
}
}
func main() {
pos, neg := adder(), adder()
for i := 0; i < 10; i++ {
fmt.Println(
pos(i),
neg(-2*i),
)
}
}
|
输出结果:
0 0
1 -2
3 -6
6 -12
10 -20
15 -30
每次调用 makeHandler(工厂),都会产生一个新的、独立的闭包环境。它们互不干扰,不引用同一个变量。
为了让你彻底明白,我们要分两种情况来看:
情况一:调用两次“工厂函数” (你的 main 函数里的情况)
这是你在 main 函数里写的代码逻辑:
Go
|
1
2
3
4
5
6
7
|
// 第 1 次调用 makeHandler
// 产生了一个闭包 A,它的背包里装的是 viewHandler
h1 := makeHandler(viewHandler)
// 第 2 次调用 makeHandler
// 产生了一个闭包 B,它的背包里装的是 editHandler
h2 := makeHandler(editHandler)
|
结论: 它们不引用同一个变量。
原理: 每次你调用 makeHandler 时,Go 都会在内存中开辟一个新的栈帧 (Stack Frame)。
虽然变量名都叫 fn,但它们在内存里是两块完全不同的地址。就好比你去了两次肯德基,第一次买了汉堡,第二次买了薯条。虽然都装在“打包袋”里,但这是两个不同的袋子,装的东西也不一样。
情况二:调用同一个“闭包实例”多次
这是当服务器运行起来,多个用户访问同一个 URL 时的情况:
Go
|
1
2
3
4
5
6
7
8
|
// 假设这是 main 函数里注册好的那个 handler
viewHandlerWrapper := makeHandler(viewHandler)
// 用户甲访问 /view/1
viewHandlerWrapper(w1, r1)
// 用户乙访问 /view/2
viewHandlerWrapper(w2, r2)
|
结论: 它们引用的是同一个外部变量。
原理: 因为 viewHandlerWrapper 是同一个闭包实例。 在这个例子里,它们引用的那个“外部变量”就是被捕获的 fn (也就是 viewHandler)。 无论用户调用多少次 viewHandlerWrapper,它都会去自己的背包里找那个 fn。这也是正确的,因为我们希望大家用的处理逻辑是一样的。
用那个计数器的例子看最清楚
回到刚才的计数器 seq,这能最直观地展示“变量隔离”:
Go
|
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
|
func seq() func() int {
i := 0 // 这就是那个“外部变量”
return func() int {
i++
return i
}
}
func main() {
// 【工厂调用两次】:创造了两个独立的世界
counterA := seq()
counterB := seq()
// counterA 的背包里有一个 i (地址 0x1111)
// counterB 的背包里有一个 i (地址 0x2222)
fmt.Println(counterA()) // 输出 1 (修改的是 0x1111)
fmt.Println(counterA()) // 输出 2 (修改的是 0x1111)
// 关键点来了:
fmt.Println(counterB()) // 输出 1 (修改的是 0x2222)
// 发现了吗?counterB 从头开始计数,完全没受 counterA 影响!
}
|
总结你的 makeHandler 代码
在你的代码中:
Go
|
1
2
3
4
5
6
|
func makeHandler(fn func(...)) http.HandlerFunc {
return func(w, r) {
// ...
fn(w, r, title) // 这里的 fn 是被捕获的
}
}
|
它们互不干扰,绝对安全。 这也是为什么闭包在 Go 的并发编程和中间件设计中如此好用的原因——它天然地实现了状态的隔离。