← [ BACK_TO_NOTES ]

Go 闭包

函数字面量捕获外部变量引用;中间件与回调惯用法,以及循环变量语义在 Go 1.22 前后的关键变化。

#concept #growing #dev #go

[!info] related notes

Go 闭包

这个概念为什么会出现

Go 把函数当作一等值:可赋值、可传参、可返回。一旦函数字面量引用了外层作用域的变量,就形成闭包——它不只是代码指针,还绑着对环境的引用。于是可以:

  • 工厂返回“带状态的行为”(计数器、配置好的 handler)
  • 中间件包装 http.Handler
  • 在 go / defer 里挂接短逻辑

代价是:捕获的是变量,不是拍照瞬时值;再叠加循环与并发,就出现经典陷阱。Go 1.22 起循环变量语义变化,读写旧代码必须分清版本。

[!abstract] 一句话理解 闭包是绑定了外部变量引用的函数值;它延长被捕获变量的生命周期,共享可变状态,并在 go/defer/循环场景中要求你清楚“何时读到什么”。

最小可运行示例

先把示例放进业务场景,再看代码:

场景:给每个 API 客户端发“本地递增请求序号”

网关或 SDK 里,每个客户端连接常要带一个单调递增的本地 seq:
重试去重、日志关联、简易幂等键都会用到。

你不希望把 seq 做成全局变量(多客户端会打架),
而是:工厂函数创建“带私有计数器的 next()”,每个客户端各自一份状态。
这就是闭包最典型的工程形态——返回“绑了环境的行为”。

package main

import "fmt"

// newRequestSeq 模拟“为某个客户端创建序号生成器”。
//
// 业务意图:
// - start 是初始水位(例如从持久化恢复的上次 seq);
// - 返回的函数每次调用都给出下一个序号;
// - 不同客户端互不影响。
//
// 教学点:
// - 内层函数引用了外层变量 n → 形成闭包;
// - n 的生命周期被闭包延长到“还有人握着返回的函数”为止;
// - 每次调用 newRequestSeq 都得到**新的** n,状态隔离。
func newRequestSeq(start int) func() int {
	// n 是工厂的局部变量;返回后按理说栈帧该结束,
	// 但闭包还引用它,语言保证它继续存活;具体分配位置由编译器决定。
	n := start
	return func() int {
		// 捕获的是变量 n 本身(引用语义),不是创建时的快照。
		n++
		return n
	}
}

func main() {
	// 客户端 A:从 10 恢复水位
	nextA := newRequestSeq(10)
	fmt.Println("A", nextA()) // 11
	fmt.Println("A", nextA()) // 12

	// 客户端 B:新连接从 0 开始
	nextB := newRequestSeq(0)
	// B 与 A 各有一份 n;B 递增不会带动 A
	fmt.Println("B", nextB(), "A", nextA()) // B 1  A 13
}

建议运行:

go run .

期望输出:

A 11
A 12
B 1 A 13

结合场景再看三个关注点

  1. 闭包 = 行为 + 私有状态
    nextA / nextB 是同一个函数字面量形状,但各自绑着不同的 n。

  2. 捕获的是变量,不是拍照
    多次调用读到的是 n 的当前值;若要“绑定当时的值”,需拷贝或经参数传入(见下文)。

  3. 工厂每调用一次就隔离一份状态
    对应“每客户端一个 seq”,而不是进程级全局计数器。

核心概念与准确模型

函数值与函数字面量

var op func(int, int) int
op = func(a, b int) int { return a + b }
fmt.Println(op(1, 2))

类型是 func(...) ...;零值为 nil,调用 nil 函数会 panic。

捕获语义:引用而非拷贝

x := 1
f := func() { fmt.Println(x) }
x = 2
f() // 2

闭包读到的是变量当前值。若需要“绑定当时的值”,应在构造时拷贝到新变量或通过参数传入:

x := 1
v := x
f := func() { fmt.Println(v) }
// 或
f = func(v int) func() { return func() { fmt.Println(v) } }(x)

逃逸与堆分配

语言保证被捕获变量在仍被使用时保持有效;编译器可根据逃逸分析、内联等决定分配位置,不能从“返回闭包”直接断言必有堆分配。见 go-escape-analysis。

循环变量:Go 1.22 前后

Go 1.21 及更早:for 循环的迭代变量在整个循环中复用同一个变量。循环结束后调用闭包会看到最终值;若在 goroutine 中边循环边读取,则还可能与循环更新产生数据竞争。

// 旧语义下的经典坑
for i := 0; i < 3; i++ {
	go func() {
		fmt.Println(i) // 旧语义下与循环更新 i 存在数据竞争,输出不确定
	}()
}

旧版本惯用修复:

for i := 0; i < 3; i++ {
	i := i // 每轮新变量
	go func() { fmt.Println(i) }()
}
// 或
go func(i int) { fmt.Println(i) }(i)

Go 1.22 起:由 for 初始化语句或 range 的 := 声明的循环变量,每轮是新变量。若使用 = 给循环外已有变量赋值,仍复用同一变量。官方说明见 Go 1.22 Release Notes — Language changes 以及设计讨论 LoopvarExperiment / 提案相关材料。详见 range 语义。

工程含义:

  1. 新代码可依赖 1.22+ 语义,但仍建议在并发启动时显式传参,可读性更好。
  2. 维护老模块时确认 go 版本与 go.mod 的 go 行。
  3. 工具与 linter 可能仍警告“loop closure”——以版本与真实语义为准。

defer 与闭包

defer 的实参在注册时求值;闭包体在函数返回前执行,读到的是执行时的变量状态:

func f() (n int) {
	defer func() { n++ }() // 命名返回值可被 defer 修改
	return 1               // 实际返回 2
}

与“值参数 defer”对比:

func g() {
	x := 1
	defer fmt.Println(x) // 注册时求值,打印 1
	x = 2
}

方法值与方法表达式

type T struct{ N int }
func (t *T) Inc() { t.N++ }

var t T
mv := t.Inc // 方法值:绑定接收者
mv()

方法值类似闭包,绑定了接收者。

边界情况

  1. 闭包长时间持有大对象:延迟 GC,形似泄漏。

  2. 多 goroutine 写同一捕获变量:data race,需同步或改为通道/参数。

  3. 递归闭包:需先声明变量再赋值。

    var fib func(int) int
    fib = func(n int) int { ... fib(n-1) ... }
  4. 接口包装:闭包适配 http.HandlerFunc 等,注意栈与分配。

  5. 测试中捕获 t 并并行:-parallel 下需 t.Parallel 规则与参数拷贝习惯。

常见误区

[!warning] 常见误区:以为捕获的是当时的值 错误:x 改了闭包仍打印旧值。
正确:默认捕获变量;要快照就拷贝或传参。

[!warning] 常见误区:忽略 go 版本谈循环坑 错误:在 1.22 项目里仍断言“一定打印最后值”。
正确:查 Go 1.22 发行说明;老代码按旧语义审。

[!warning] 常见误区:用闭包共享状态替代清晰所有权 错误:深层回调改一堆外层变量。
正确:状态上移为结构体字段或显式参数。

[!warning] 常见误区:defer 闭包与返回值时机混淆 错误:以为 return x 后 defer 改 x 一定影响返回。
正确:仅命名返回值等规则下可影响;见 go-defer-panic-and-recover。

工程实践

  1. 中间件:func(http.Handler) http.Handler 返回闭包,捕获 logger/config。
  2. 选项模式:WithTimeout(d) 返回修改 options 的闭包。
  3. 并发:优先 go func(v T){ ... }(v),不依赖读者记忆循环语义。
  4. 热路径先测量:闭包不一定分配到堆上;用基准测试与逃逸分析判断成本。
  5. 竞态:-race 测闭包共享写。
  6. 可读性:闭包超过 ~20 行考虑具名函数。
func passthrough(next http.Handler) http.Handler {
	return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
		// 捕获 next;请求范围状态放 context
		next.ServeHTTP(w, r)
	})
}

可验证实验

实验 1:引用捕获

修改外层变量后调用闭包,观察输出。

实验 2:循环 + goroutine

使用 go 1.21 与 go 1.22 的模块分别运行经典循环,并用 go run -race . 检查;主函数须等待 goroutine 完成。语言语义取决于模块 go 行,不能只看工具链版本。

实验 3:显式传参

go func(i int){ fmt.Println(i) }(i) 在两版本都接收各轮的值;并发打印顺序仍不保证。

实验 4:命名返回值 + defer

验证 defer 闭包递增命名结果。

实验 5:逃逸

go build -gcflags='-m' 查看被捕获变量是否 escape。

本节总结

  • 本质:带环境引用的函数值。
  • 关键规则:捕获变量;循环语义看版本(1.22 修复);defer 求值时机;并发要同步。
  • 最易错:循环+goroutine 旧语义、共享可变捕获、生命周期钉死大对象。
  • 下一步:函数 全貌;goroutine 并发启动纪律。

自测题

概念题

  1. 闭包捕获的是值还是变量?
  2. Go 1.22 对 for 循环变量做了什么?
  3. 为何 go func(i int){...}(i) 仍然值得写?

代码推理题

var fns []func()
for i := 0; i < 3; i++ {
	fns = append(fns, func() { fmt.Println(i) })
}
for _, f := range fns {
	f()
}

在 1.21 与 1.22 语言语义下各打印什么?

工程思考题

中间件闭包捕获了 *sql.DB 与请求级 userID,何者应捕获、何者应每请求注入?

参考答案

展开
  1. 变量(引用语义上的绑定)。
  2. 对循环中声明的变量,每轮使用新变量;循环外声明后用 = 赋值的变量仍共享;见 go1.22 语言变更。
  3. 意图显式、跨版本稳定、避免读者怀疑。
    代码题:这里在循环结束后按顺序调用闭包,旧语义打印 3 3 3;1.22+ 打印 0 1 2。 工程题:DB 等进程级依赖可捕获;userID 等请求数据应来自 r.Context() 或参数,避免串请求。

延伸阅读与资料来源

资料类型支撑
Go 1.22 Release Notes — Language发行说明循环变量语义变更(权威)
Fixing For Loops in Go 1.22官方博客变更动机与预览
Spec — Function literals规范字面量
Effective Go — Functions文档函数与闭包风格
Tour — Function closures教程入门
创建于 2026/6/25 更新于 2026/10/6
Digital Biome
Access

键盘导航

j / k / ↑ / ↓
选择对象
Enter / o
打开选择
/ 或 Ctrl+K
全站搜索
i / f / 1–4
筛选当前列表
g → h / l / p / t / a
首页 / 笔记 / 项目 / 工具 / About
r → 1–9
打开所选对象的关联
u
返回上一页
Esc
关闭浮层 / 取消命令;输入框内清空文本