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 条如果你在别的版本上测出不同结果,欢迎在评论区告诉我版本号。