Go 编程实战:闭包 Closure——函数如何记住外部变量

上一篇我们学习了匿名函数 Anonymous Function,知道函数不仅可以拥有名字,也可以直接写成:

go 复制代码
func() {
	fmt.Println("Hello Go")
}

匿名函数还可以保存到变量、作为参数传递,甚至作为另一个函数的返回值。在学习匿名函数时,我们留下了一个非常重要的问题:

scss 复制代码
count := 0
add := func() {
	count++
}
add()
add()
fmt.Println(count)

最终输出:

复制代码
2

为什么匿名函数内部可以访问并修改外部的 count?更有意思的是,一个函数已经执行结束后,它内部创建的某些变量为什么还能继续存在?这就涉及 Go 函数体系中非常重要的概念------闭包 Closure

一、什么是闭包

闭包可以简单理解为:函数与它所引用的外部变量组合形成的整体

例如:

scss 复制代码
func main() {
	count := 0
	add := func() {
		count++
		fmt.Println(count)
	}
	add()
	add()
	add()
}

输出:

复制代码
1
2
3

匿名函数:

scss 复制代码
func() {
	count++
	fmt.Println(count)
}

内部没有定义 count,却使用了外层作用域中的:

go 复制代码
count := 0

此时匿名函数捕获了外部变量 count,形成闭包。

可以简单理解为:

复制代码
闭包 = 函数 + 被函数引用的外部环境

二、最简单的闭包

例如:

go 复制代码
package main
import "fmt"
func main() {
	name := "Tom"
	hello := func() {
		fmt.Println("Hello", name)
	}
	hello()
}

输出:

复制代码
Hello Tom

name 定义在匿名函数外部:

go 复制代码
name := "Tom"

匿名函数内部直接使用:

arduino 复制代码
fmt.Println("Hello", name)

因此匿名函数捕获了 name

如果修改:

ini 复制代码
name = "Jack"
hello()

输出:

复制代码
Hello Jack

闭包引用的是变量本身,而不是简单复制变量定义那一刻的值。

三、闭包可以修改外部变量

例如:

scss 复制代码
func main() {
	count := 0
	add := func() {
		count++
	}
	add()
	add()
	add()
	fmt.Println(count)
}

输出:

复制代码
3

匿名函数不仅能读取:

复制代码
count

还可以修改它。

这也是闭包最常见的用途之一:保存某种状态。

四、函数返回闭包

闭包真正有意思的地方,是函数可以返回一个引用内部变量的函数。

例如:

go 复制代码
func counter() func() int {
	count := 0
	return func() int {
		count++
		return count
	}
}

调用:

css 复制代码
func main() {
	c := counter()
	fmt.Println(c())
	fmt.Println(c())
	fmt.Println(c())
}

输出:

复制代码
1
2
3

这里:

scss 复制代码
counter()

执行结束之后,按照普通局部变量的理解:

go 复制代码
count := 0

似乎应该已经不能访问。

但返回的匿名函数仍然引用 count,因此这个变量仍然能够被后续调用使用。

这就是闭包最核心的特性之一。

五、闭包为什么能记住变量

看下面的代码:

go 复制代码
func counter() func() int {
	count := 0
	return func() int {
		count++
		return count
	}
}

第一次:

css 复制代码
c := counter()

可以简单理解为创建了:

ini 复制代码
count = 0
+
一个操作 count 的函数

执行:

scss 复制代码
c()

变成:

ini 复制代码
count = 1

再次:

scss 复制代码
c()

变成:

ini 复制代码
count = 2

因此 c 不只是一个简单函数,它还关联着自己需要使用的外部变量环境。

六、不同闭包拥有不同状态

例如:

scss 复制代码
func counter() func() int {
	count := 0
	return func() int {
		count++
		return count
	}
}
func main() {
	a := counter()
	b := counter()
	fmt.Println(a())
	fmt.Println(a())
	fmt.Println(b())
	fmt.Println(a())
	fmt.Println(b())
}

输出:

复制代码
1
2
1
3
2

因为:

css 复制代码
a := counter()

和:

css 复制代码
b := counter()

分别执行了一次 counter(),每次都会创建自己的 count

可以理解为:

css 复制代码
a -> countA
b -> countB

两个闭包的状态彼此独立。

七、闭包实现计数器

闭包非常适合实现简单状态管理:

go 复制代码
package main
import "fmt"
func newCounter() func() int {
	count := 0
	return func() int {
		count++
		return count
	}
}
func main() {
	counter := newCounter()
	fmt.Println(counter())
	fmt.Println(counter())
	fmt.Println(counter())
}

输出:

复制代码
1
2
3

外部无法直接访问:

复制代码
count

只能通过返回的函数修改它。

这在一定程度上实现了数据隐藏。

八、带参数的闭包

闭包本身当然也可以接收参数:

go 复制代码
func accumulator() func(int) int {
	total := 0
	return func(value int) int {
		total += value
		return total
	}
}

调用:

css 复制代码
add := accumulator()
fmt.Println(add(10))
fmt.Println(add(20))
fmt.Println(add(30))

输出:

复制代码
10
30
60

这里 value 是每次调用时传入的参数,而:

复制代码
total

是闭包持续保存的状态。

九、闭包实现自定义累加器

还可以给闭包一个初始值:

go 复制代码
func accumulator(start int) func(int) int {
	total := start
	return func(value int) int {
		total += value
		return total
	}
}

使用:

css 复制代码
add := accumulator(100)
fmt.Println(add(10))
fmt.Println(add(20))

输出:

复制代码
110
130

再创建:

css 复制代码
other := accumulator(1000)
fmt.Println(other(10))

输出:

yaml 复制代码
1010

每次调用 accumulator() 都会产生独立状态。

十、闭包实现函数工厂

闭包可以根据参数生成不同功能的函数。

例如:

go 复制代码
func multiplier(n int) func(int) int {
	return func(value int) int {
		return value * n
	}
}

创建:

scss 复制代码
double := multiplier(2)
triple := multiplier(3)

调用:

less 复制代码
fmt.Println(double(10))
fmt.Println(triple(10))

输出:

复制代码
20
30

这里:

arduino 复制代码
double

记住了:

ini 复制代码
n = 2

而:

复制代码
triple

记住了:

ini 复制代码
n = 3

这种根据配置生成函数的方式通常称为函数工厂。

十一、闭包保存配置信息

例如:

go 复制代码
func logger(prefix string) func(string) {
	return func(message string) {
		fmt.Println(prefix, message)
	}
}

创建不同日志函数:

css 复制代码
info := logger("[INFO]")
errorLog := logger("[ERROR]")

调用:

scss 复制代码
info("服务器启动")
errorLog("连接失败")

输出:

csharp 复制代码
[INFO] 服务器启动
[ERROR] 连接失败

info 自动记住:

csharp 复制代码
[INFO]

errorLog 自动记住:

csharp 复制代码
[ERROR]

调用时不需要重复传递这些配置。

十二、闭包与回调函数

上一篇学习过回调:

go 复制代码
func process(nums []int, fn func(int)) {
	for _, num := range nums {
		fn(num)
	}
}

闭包可以让回调函数携带外部状态:

go 复制代码
total := 0
process([]int{10, 20, 30}, func(num int) {
	total += num
})
fmt.Println(total)

输出:

复制代码
60

匿名函数捕获:

复制代码
total

因此在遍历过程中不断修改外部变量。

这种写法在数据处理代码中非常常见。

十三、闭包与 defer

闭包经常和 defer 一起使用。

例如:

go 复制代码
func test() {
	name := "Tom"
	defer func() {
		fmt.Println(name)
	}()
	name = "Jack"
}

输出:

复制代码
Jack

为什么不是 Tom?

因为 defer 延迟执行的是匿名函数,匿名函数引用了外部变量 name。真正执行匿名函数时,name 已经被修改为:

复制代码
Jack

如果希望保存当前值,可以通过参数传递:

go 复制代码
func test() {
	name := "Tom"
	defer func(value string) {
		fmt.Println(value)
	}(name)
	name = "Jack"
}

这时候输出:

复制代码
Tom

理解这种区别,对下一篇学习 defer 非常重要。

十四、闭包与 goroutine

闭包还经常出现在 goroutine 中:

go 复制代码
name := "Tom"
go func() {
	fmt.Println(name)
}()

匿名函数引用了外部变量:

复制代码
name

因此形成闭包。

不过并发环境中要更加小心,因为外部变量可能同时被其他 goroutine 修改。

例如多个 goroutine 修改:

复制代码
count++

可能产生数据竞争。

因此闭包本身并不会自动保证并发安全。后面学习 Mutex、Channel 等并发知识时会详细讨论。

十五、循环中的闭包

循环配合闭包是一个需要特别注意的场景:

css 复制代码
for i := 0; i < 3; i++ {
	f := func() {
		fmt.Println(i)
	}
	f()
}

这里匿名函数立即执行,所以输出:

复制代码
0
1
2

如果函数被保存下来稍后执行,则需要理解它捕获的是哪个变量。现代 Go 版本已经改进了一些常见循环变量捕获行为,但阅读旧代码或处理复杂循环时,仍然应该明确变量的作用域和生命周期。

如果希望逻辑非常明确,可以显式创建局部变量:

css 复制代码
for i := 0; i < 3; i++ {
	value := i
	func() {
		fmt.Println(value)
	}()
}

这样匿名函数明确捕获当前循环中的 value

十六、闭包会影响变量生命周期

普通局部变量通常只在当前函数执行期间使用:

go 复制代码
func test() {
	num := 10
	fmt.Println(num)
}

但是:

go 复制代码
func test() func() int {
	num := 10
	return func() int {
		return num
	}
}

返回的函数仍然需要 num

因此编译器和运行时会保证它在仍被引用期间有效。

我们不需要手动决定变量一定存放在栈还是堆上,Go 编译器会通过逃逸分析等机制进行处理。

日常开发应该重点关注:

复制代码
变量是否仍然被引用
代码是否容易理解
是否产生不必要的长期引用

而不是手动管理内存。

十七、闭包不是数据复制

例如:

go 复制代码
func main() {
	num := 10
	f := func() {
		fmt.Println(num)
	}
	num = 100
	f()
}

输出:

复制代码
100

如果闭包只是把 num 创建时的值复制一份,那么结果应该是 10,但实际得到 100。

因此更准确的理解是:闭包可以捕获并引用外部变量。

再看:

go 复制代码
num := 10
f := func() {
	num++
}
f()
fmt.Println(num)

结果:

复制代码
11

外部和闭包看到的是同一个变量。

十八、闭包实现 ID 生成器

例如需要生成连续 ID:

go 复制代码
func newIDGenerator() func() int {
	id := 0
	return func() int {
		id++
		return id
	}
}

使用:

css 复制代码
nextID := newIDGenerator()
fmt.Println(nextID())
fmt.Println(nextID())
fmt.Println(nextID())

输出:

复制代码
1
2
3

如果希望从 1000 开始:

go 复制代码
func newIDGenerator(start int) func() int {
	id := start
	return func() int {
		id++
		return id
	}
}

调用:

css 复制代码
nextID := newIDGenerator(1000)
fmt.Println(nextID())

输出:

yaml 复制代码
1001

这是闭包保存状态的典型案例。

十九、闭包实现余额管理

再看一个稍微完整的例子:

go 复制代码
func account(balance float64) func(float64) float64 {
	return func(amount float64) float64 {
		balance += amount
		return balance
	}
}

使用:

css 复制代码
wallet := account(100)
fmt.Println(wallet(50))
fmt.Println(wallet(-20))
fmt.Println(wallet(100))

输出:

复制代码
150
130
230

balance 不需要定义成全局变量,而是由闭包维护。

不过真正的金融、订单等复杂业务不应该仅依赖简单闭包保存状态,这里主要用于理解闭包机制。

二十、什么时候适合使用闭包

闭包比较适合:

go 复制代码
保存少量状态
生成不同配置的函数
实现回调
数据过滤和处理
临时封装上下文
配合 defer
配合 goroutine

例如:

go 复制代码
validator := func(min int) func(int) bool {
	return func(value int) bool {
		return value >= min
	}
}

创建:

css 复制代码
adult := validator(18)
fmt.Println(adult(20))

输出:

arduino 复制代码
true

闭包让 18 这个配置直接与验证函数绑定在一起。

二十一、什么时候不要滥用闭包

闭包虽然灵活,但并不是越多越好。

如果:

go 复制代码
func() {
	// 几十行复杂业务
	// 多个数据库操作
	// 大量状态修改
	// 多层匿名函数
}

代码会越来越难阅读。

如果某段逻辑:

复制代码
需要重复调用
业务意义明确
代码比较复杂
需要独立测试

通常更适合提取为普通函数或方法。

闭包更适合小型、局部、需要携带上下文的逻辑。

二十二、闭包常见错误

第一个错误是认为闭包保存的是变量当时的副本:

go 复制代码
name := "Tom"
f := func() {
	fmt.Println(name)
}
name = "Jack"
f()

结果是:

复制代码
Jack

第二个错误是忽略闭包会延长相关变量的有效生命周期。闭包如果长期存在,它引用的数据也可能需要长期保留。

第三个错误是多个 goroutine 通过闭包修改同一个变量:

复制代码
count++

这可能产生数据竞争。

第四个错误是为了"高级写法"大量嵌套闭包,最终降低代码可读性。

第五个错误是混淆匿名函数和闭包。匿名函数不一定是闭包,只有当函数引用了其外部作用域中的变量时,才体现出闭包特性。

二十三、匿名函数和闭包的区别

匿名函数:

go 复制代码
func(a, b int) int {
	return a + b
}

它没有引用外部变量,只是一个没有名字的函数。

闭包:

go 复制代码
total := 0
add := func(value int) int {
	total += value
	return total
}

这里匿名函数引用:

复制代码
total

因此形成闭包。

可以简单记住:

复制代码
匿名函数:
重点是"没有名字"
闭包:
重点是"函数捕获外部变量"

闭包经常通过匿名函数实现,但两个概念并不完全等同。

二十四、总结

最简单的闭包:

go 复制代码
count := 0
add := func() {
	count++
}

返回闭包:

go 复制代码
func counter() func() int {
	count := 0
	return func() int {
		count++
		return count
	}
}

带参数:

go 复制代码
func accumulator() func(int) int {
	total := 0
	return func(value int) int {
		total += value
		return total
	}
}

函数工厂:

go 复制代码
func multiplier(n int) func(int) int {
	return func(value int) int {
		return value * n
	}
}

学习闭包需要重点掌握:

go 复制代码
1.闭包是函数与其引用的外部环境的组合
2.闭包可以读取外部变量
3.闭包也可以修改外部变量
4.函数返回后,被闭包引用的变量仍可继续有效
5.每次调用外层函数可以创建独立的闭包状态
6.闭包适合保存少量状态和上下文
7.闭包经常用于回调、defer 和 goroutine
8.闭包本身不会自动保证并发安全
9.匿名函数和闭包不是完全相同的概念

闭包中我们已经多次看到:

go 复制代码
defer func() {
	fmt.Println("函数结束")
}()

而前面的文件操作中也出现过:

go 复制代码
defer file.Close()

defer 看起来只是"延迟执行",实际上它还有参数求值时机、多个 defer 的执行顺序、与 return 的关系、资源释放、panic/recover 等一系列非常重要的规则。掌握这些规则,才能真正理解 Go 函数退出时发生了什么。

下一篇:defer------延迟执行、资源释放与执行顺序

相关推荐
JaguarJack1 小时前
NativePHP v4 让 Blade 构建原生 iOS 与 Android 界面
后端·php·服务端
笃行35010 小时前
向量数据库单独建一套,这笔账划不划算
后端
weixin_4316004411 小时前
Agent Workflow 学习向:Code 节点,比模板更强的变量加工
后端·python·学习·ai·
Rain的Java大神之路11 小时前
短信接口被狂刷怎么处理
java·运维·后端·web安全·面试·架构·xss
Lost of 程序猿12 小时前
AOP 实战:面向切面编程从理论到落地
后端·asp.net·aop·面向切片变成
Lost of 程序猿13 小时前
ASP.NET Core 认证授权实战:从 JWT 到 Policy,设计一套企业级 RBAC 权限系统
后端·asp.net
Lost of 程序猿13 小时前
ASP.NET Core 后台任务全景:从 BackgroundService 到 Channel 队列,再到分布式调度
后端·asp.net·.netcore
倾颜13 小时前
NestJS 核心概念梳理:从 Module、Controller 到 Guard、Interceptor
后端·node.js·nestjs
东风破_13 小时前
JWT 1:从一个登录请求开始,理解 React 项目里的 API 层与 Mock
前端·后端