for range 一个函数:Go 1.23 range-over-func 的改写规则与 iter.Pull 泄漏点
Go 1.23 把 for range 的右侧从内建容器扩展到了函数。能写 for v := range seq 的前提是 seq 满足一套固定签名,而不是任意函数。官方说明见 range-functions 博客。
能被 range 的函数长什么样
只认三种签名:
type Seq0 func(yield func() bool)
type Seq[V any] func(yield func(V) bool)
type Seq2[K, V any] func(yield func(K, V) bool)
标准库 iter 包给出 Seq / Seq2 两个类型别名(Seq 读作 seek)。被 range 的函数只接受一个参数,这个参数本身是函数,惯例叫 yield。
迭代器内部对序列里每个元素调用一次 yield。yield 返回 false 表示调用方不再需要后续元素,迭代器应当尽快 return 并做清理。yield 在返回 false 之后再次被调用会 panic。
func (s *Set[E]) All() iter.Seq[E] {
return func(yield func(E) bool) {
for v := range s.m {
if !yield(v) {
return
}
}
}
}
标准库里的 maps.Keys、slices.Values、strings.SplitSeq 都返回 iter.Seq / Seq2,可以直接交给 range:
for k := range maps.Keys(m) { ... }
for i, v := range slices.Backward(s) { ... }
for line := range strings.SplitSeq(s, "\n") { ... }
这里有个改老代码时会踩的点:maps.Keys / maps.Values 现在返回的是迭代器而不是切片(Go 1.23 之前返回 []K),所以不能直接赋值给切片变量。需要切片就写 slices.Collect(maps.Keys(m))。
编译器把循环体打包成一个函数
for range 一个迭代器函数本质是语法糖。编译器把循环体打包成函数(即 yield)传给迭代器:
// 源码
for v := range seq {
fmt.Println(v)
if v > 10 { break }
}
// 编译器等价改写(示意)
seq(func(v T) bool {
fmt.Println(v)
if v > 10 {
return false // break → yield 返回 false
}
return true
})
改写之后,循环体里 break / continue / return / defer / goto 的语义被保留,但保留的方式各不相同:
break:yield 返回 false,迭代器一收到就 return,defer 清理照常跑。continue:跳过本次 yield 调用的剩余部分。return:循环体内的 return 不能直接 return(它已经位于合成函数里),编译器改成往一个标志变量写值,等迭代器返回后再检查标志,决定是否从外层函数返回。defer:最容易踩。合成函数里写 defer,deferred 调用会在每次 yield 结束时触发,而不是在原函数返回前。Go 团队为此在 runtime 里加了deferprocat来处理被改写函数里的 defer 语义。依赖 defer 做延迟清理时,不要想当然认为它和普通 for 一样。
另外 yield 不能跨 goroutine 调用。迭代器是被 range 语句同步驱动的,在另一个 goroutine 里调 yield 属于未定义行为。
iter.Pull:push 转 pull 的代价
push 迭代器的形态是「for 循环 + 对 yield 的回调」,控制权在迭代器手里。有些场景用 pull 更自然,比如连续取两个元素配成一对,或者按条件只取前 N 个。iter.Pull / Pull2 做这个桥接:
next, stop := iter.Pull2(m.All())
defer stop()
k, v, ok := next()
实现上,Pull 用一个 goroutine(Go 1.23 里实际是 runtime 内部的协程 coro,语义等价)跑原迭代器,通过 channel 把「推送」变成「拉取」。next 每次唤醒迭代器取一个值,stop 负责终止。
陷阱在 stop。push 迭代器被 for range 提前 break 时,range 会保证 yield 返回 false,迭代器自己就能清理。但换用 Pull 之后,消费者不再调用 next,push 侧可能阻塞在 channel 发送上,永远退不出来——因为不存在 yield 返回 false 这条路径了。所以 defer stop() 必须有。忘掉 stop 的 goroutine 是活跃的(不算死锁),pprof / goroutine dump 里能看到,进程本身不报错,只会慢慢堆积。
还有更隐蔽的一层:Pull 把原本纯函数调用的一次迭代变成协程切换加 channel 收发,单次迭代成本远高于直接 range。只在确实需要 pull 语义的窄场景用,别为了代码好看把热路径上的迭代器全套一层 Pull。
用在哪,别用在哪
适合的地方:
- 自定义容器(树、链表、图)暴露统一遍历接口,调用方用 range,不关心内部结构。
- 标准库新 API:
maps/slices/strings的迭代器版本。 - 适配器与管道:filter、map、take 这类组合子,惰性求值不产生中间切片。比如
Take(Filter(src, pred), n)在 n 个元素后自然停止,不遍历整个序列。
不适合的地方:
- 已经有切片 / 映射、且只遍历一次的地方,直接 range 原容器更直观,也少一层函数调用。
- 需要在迭代过程中报错的地方。迭代器协议没有错误返回通道,错误只能靠闭包捕获变量,或者 panic 了再 recover,都不优雅;这种情况老老实实返回
(T, error)切片或显式回调更好。 - 热路径上对小切片做迭代——合成函数加上接口调用 / 闭包的开销可能抵消语法收益,测过再说。
Go 1.23 的编译器对 range-over-func 有优化(内联常见的 push 迭代器模式),但优化不保证覆盖所有写法,benchmark 才算数。
参考
- 官方博客:
- iter 包文档:
- 源码
src/iter/iter.go: