Go 迭代器的七个坑:break 后 panic、一次性迭代器、iter.Pull 不 stop,还有一个跑错地方的 defer

Go 1.23 正式放出 range-over-func 之后,我陆续把几处"分页拉数据再拼切片"的代码改成了 iter.Seq。写起来确实顺手,但也踩了几次,其中一个到现在我都觉得是编译器的问题。

下面七条都在 Go 1.25.0(darwin/arm64)上实跑过,第 7 条另外在 1.24.6 和 1.25.3 上复现过,输出直接贴在代码后面。

1. 不检查 yield 的返回值,break 一下就 panic

最常见的写法错误:

go 复制代码
func BadCount(n int) iter.Seq[int] {
	return func(yield func(int) bool) {
		for i := 0; i < n; i++ {
			yield(i) // 忽略返回值
		}
	}
}

for v := range BadCount(5) {
	if v == 1 {
		break
	}
}
vbnet 复制代码
panic: runtime error: range function continued iteration after function for loop body returned false

调用方 break、return、goto 跳出循环,都会让 yield 返回 false。迭代器拿到 false 必须立刻停,再调一次 yield 运行时就直接 panic。

正确写法只差一行:

go 复制代码
if !yield(i) {
	return
}

单测里如果只测"完整遍历",这个问题永远测不出来。我现在给每个迭代器都补一个"遍历到第 2 个就 break"的用例。

2. maps.Keys 返回的是迭代器,不是切片

从 golang.org/x/exp/maps 迁过来的人很容易写成 keys := maps.Keys(m) 然后当切片用,编译直接报类型不对,这个还好。

真正的坑是顺序。maps.Keys 和 range map 一样是随机顺序,同一个 map 连续 Collect 五次:

go 复制代码
m := map[string]int{"b": 2, "a": 1, "c": 3, "d": 4}
for i := 0; i < 5; i++ {
	fmt.Print(strings.Join(slices.Collect(maps.Keys(m)), ""), " ")
}
// bacd bacd bacd bacd acdb

前四次一样,第五次变了。这种"大部分时候稳定"的行为最坑,本地测几次都过,线上偶尔出一次顺序不同的结果。要稳定顺序就用 slices.Sorted(maps.Keys(m))。

3. 一次性的迭代器,第二次 range 什么都没有

iter.Seq 看起来像一个"集合",但它本质是一个函数,能不能重复遍历取决于你在闭包外面持有了什么状态:

go 复制代码
func Lines(s string) iter.Seq[string] {
	sc := bufio.NewScanner(strings.NewReader(s)) // 状态在闭包外
	return func(yield func(string) bool) {
		for sc.Scan() {
			if !yield(sc.Text()) {
				return
			}
		}
	}
}

ls := Lines("x\ny\nz")
fmt.Println(slices.Collect(ls)) // [x y z]
fmt.Println(slices.Collect(ls)) // []

第二次是空的,而且不报错。如果函数签名返回 iter.Seq,调用方默认会认为它可以重复遍历。要么把 Scanner 挪进闭包里(每次 range 重新读),要么在文档里写清楚"只能遍历一次"。标准库里 iter 包的文档也专门提了这一点,单次使用的迭代器应该在注释里说明。

4. iter.Pull 拿出来的 stop 必须调用

iter.Pull 把推模式的 Seq 变成拉模式的 next(),适合"两个序列交替取"这类场景:

go 复制代码
func WithCleanup(name string) iter.Seq[int] {
	return func(yield func(int) bool) {
		fmt.Println("open", name)
		defer fmt.Println("close", name)
		for i := 0; i < 3; i++ {
			if !yield(i) {
				return
			}
		}
	}
}

next, stop := iter.Pull(WithCleanup("pull"))
v, ok := next()   // open pull / 0 true
stop()            // close pull
v, ok = next()    // 0 false

迭代器里的 defer(关文件、释放连接)是在 stop() 时才执行的。只取了前几个就不管了,资源就一直挂着。习惯上拿到 stop 立刻 defer stop()。

5. break 的时候,迭代器里的 defer 会执行

这条是好消息,但很多人不确定,所以单独说一下:

go 复制代码
for v := range WithCleanup("range") {
	if v == 1 {
		fmt.Println("break")
		break
	}
}
// open range
// break
// close range

break 让 yield 返回 false,迭代器 return,它自己的 defer 正常执行。所以"迭代器里打开、迭代器里关"是安全的,前提是第 1 条做对了。

6. 循环体里写 defer,等的是外层函数结束

range-over-func 的循环体会被编译成一个闭包,但语言规范明确说了:循环体里的 defer 语义和普通 for 一样,在包含这个 for 的函数返回时执行,不是每轮循环结束执行:

go 复制代码
func inner() {
	for v := range Count(3) {
		defer fmt.Println("defer", v)
	}
	fmt.Println("inner loop done")
}
// inner loop done
// defer 2
// defer 1
// defer 0

这和普通 for 一致,但在迭代器场景下更容易写错:比如遍历一批文件,每轮 defer f.Close(),实际上要等整个函数结束才一起关。每轮都要释放的资源,把循环体抽成一个函数。

7. 在函数字面量里这样写,defer 跑到了更外层

这条是我实测到的、和规范描述不一致的现象,单独列出来。

go 复制代码
func main() {
	f := func() {
		for v := range Count(2) {
			defer fmt.Println("f defer", v)
		}
		fmt.Println("f loop done")
	}
	f()
	fmt.Println("after f")

	func() {
		for _, v := range []int{0, 1} {
			defer fmt.Println("slice defer", v)
		}
	}()
	fmt.Println("after slice closure")
}

按规范,f defer 应该在 f() 返回时、after f 之前打印。实际输出:

go 复制代码
f loop done
after f
slice defer 1
slice defer 0
after slice closure
f defer 1
f defer 0

f 里的两个 defer 一直拖到 main 结束才执行。同样的写法换成普通切片 range,就是正常的。

加 -gcflags=-l 关掉内联再跑:

go 复制代码
f loop done
f defer 1
f defer 0
after f
...

就正常了。所以我的判断是:函数字面量被内联进外层函数之后,range-over-func 循环体里的 defer 挂到了外层函数上 。写成具名函数(第 6 条的 inner)不受影响。

我在 1.24.6、1.25.0、1.25.3 上都复现了,没有在 issue 列表里找到完全对应的条目,不确定是否已知或已在更新版本修复。在确认之前,我的做法是:不在函数字面量里的 range-over-func 循环体中写 defer,需要的话抽成具名函数。

小结

坑 一句话
不检查 yield 返回值 break 后再 yield 直接 panic
maps.Keys 返回迭代器,顺序随机,用 slices.Sorted
一次性迭代器 第二次 range 为空且不报错
iter.Pull 不调 stop,迭代器里的清理不执行
break 与清理 迭代器内的 defer 会正常执行
循环体 defer 等外层函数返回,不是每轮
函数字面量 + defer 被内联时实测挂到更外层,具名函数正常

这些是我在给自己的站点 forxi.cn 写 Go 服务时陆续碰到的,第 7 条如果你在别的版本上测出不同结果,欢迎在评论区告诉我版本号。

相关推荐
ThinkerQAQ_2 小时前
并发编程(七):volatile——从语言规则到 CPU
java·python·go
墨鱼老师3 小时前
Go+Gin+Vue 毕设项目:Gin 框架搭建后端基础接口实战
vue.js·golang·go·gin·前后端分离·计算机毕业设计·go 后端
福兮说1 天前
Go slices 包的七个坑:Delete 把旧切片清零、Insert 时而写穿、SortFunc 不稳定(Go 1.25 实跑)
go
我的div丢了肿么办2 天前
go语言中如何安装第3方的包
后端·go
tachibana22 天前
与其他语言相比,使用 Go 有什么好处?
开发语言·后端·golang·go
JWASX3 天前
Java 转 go 学习 - 项目管理
go
喵个咪3 天前
Go 写业务,Rust 扛底盘:一套可落地的混合架构
后端·rust·go
EatFan3 天前
Go语言全栈实战:基于 Gin + Vue + JWT + RBAC 从零搭建前后端分离权限管理系统
vue.js·golang·go·vue·gin·jwt·rbac
ZealSinger3 天前
Go slog生产落地LevelVar与共存
开发语言·后端·golang·go