Go 1.26 新特性全解:iter 迭代器与 range-over-func 实战

深入解析 Go 1.26 中 iter 迭代器与 range-over-func 的设计动机、核心机制、常见误区及实战场景。对比传统迭代方案的优劣,给出从引入到落地的工程建议,帮助你在不滥用抽象的前提下用好标准库迭代器。

很多 Go 开发者对泛型带来的迭代器期待已久,但真正看到标准库 iter 包和 range-over-func 落地时,反而会犹豫:这玩意儿到底该不该用?怎么用才不会让代码变得更难维护?Go 1.26 没有给出一个炫技式的答案,而是用一种非常克制的方式,把迭代器嵌入了大家已经熟悉的 for range 里。

Go 1.26 新特性全解:iter 迭代器与 range-over-func 实战

如果你之前写过类似 bufio.Scanner 或者 sql.Rows 的遍历逻辑,就会明白这种“调用 Next() 然后判断 err”的模式有多普遍。每个库都有一套自己的迭代契约,看起来差不多,但实现细节完全不同。而当你想用 for range 直接遍历一个自定义树结构,或者惰性地从远程拉取一批数据时,以前只能通过 channel 或者闭包来模拟,代码可读性总差一口气。

Go 1.26 的 iter 迭代器并没有发明新的语法,而是把 for range 的能力扩展到了任何满足 iter.Seqiter.Seq2 签名的函数上。换句话说,编译器会把 for v := range myIter 编译成对函数的调用,并传入一个 yield 闭包。这种设计让迭代器变成了一种十分轻量的协议,而不是需要单独实现接口的重量级对象。

先把概念理清楚:Seq、Seq2 与 Push/Pull 模型

很多团队一上来就开始争论 Push 和 Pull 哪个更好,但真正需要先理解的是 iter.Seq 到底长什么样。它本质上是一个函数类型:

type Seq[V any] func(yield func(V) bool)

一个 Seq[int] 就是“接受一个 yield 函数,并不断调用 yield(v) 直到返回 false 或遍历结束”的函数。与之对应的 Seq2[K, V any] 则多了一个键,适合 map 或者带索引的遍历。这就是所谓的 Push 模型:迭代器主动把数据“推”给 yield。

而 Pull 模型则是通过 iter.Pull 函数将一个 Push 迭代器转换为两个函数:nextstop。你可以在需要提前退出、跨协程传递或者模拟 channel 行为时使用它。例如:

next, stop := iter.Pull(someSeq)
defer stop()
for v, ok := next(); ok; v, ok = next() {
    // 使用 v
}

但要注意,Pull 只是在 Push 基础上包了一层,内部仍然维护着一个协程和 channel,所以它不是“零成本”的抽象。如果只是为了在 for range 里用,完全没必要转成 Pull。

为什么这个设计适合 Go

如果你用过其他语言基于接口的迭代器,可能会觉得 Go 的做法有点奇怪——没有显式的 Iterator 接口,没有 HasNext 方法。这其实是在刻意避免一种“抽象泄漏”。因为一旦定义了迭代器接口,所有实现者都必须考虑状态管理、并发安全、资源释放等细节,而 Go 的 Seq 把这些问题都交给了函数闭包。你只需要写一个带有循环和 yield 的函数,剩下的交给编译器。

这种设计带来的一个直接好处是惰性求值变得非常自然。比如你要生成一个无限序列,只需在函数里写一个无限循环,每次调用 yield 就暂停,直到外部循环继续。内存占用接近零,因为不必一次性申请整个切片。

但这也带来一个常见的误区:很多人以为 range-over-func 会带来性能损失。实际上,编译器的内联优化已经足够把 yield 调用展开成简单的循环体,在大多场景下与手写循环没有显著差异。反而是那些为了“统一”而强行把所有循环都改成迭代器的做法,更容易因为闭包分配和额外函数调用而拖慢速度。

工程中容易踩的三个坑

第一个坑是过早地抽象。并不是所有 for 循环都需要升级成迭代器。如果你只是遍历一个简单的切片或者 map,原来的写法就很好。只有当遍历逻辑需要被复用、需要惰性求值或者需要跨多个模块共享时,才值得引入 Seq

第二个坑是资源泄漏。当你用 iter.Pull 时,如果没有调用 stop,内部 goroutine 可能会泄漏。举个例子,一个从数据库读取数据的迭代器,如果中途 break 掉但没有 stop,可能会导致数据库连接不释放。所以凡是使用了 Pull,最好用 defer stop() 来兜底。

第三个坑是把 Seq 当成万能管道。很多人会模仿函数式编程的 Map/Filter/Reduce 链,写出类似 Filter( Map( data, fn1 ), fn2 ) 的代码。这在 Go 里会引入大量嵌套的函数调用和闭包,让调试变得困难。如果业务逻辑本身不复杂,用显式的循环反而更清晰。

什么样的团队适合立即采用

  • 已经大量使用泛型,并且有自定义容器类(如 B 树、跳表)的团队,可以通过 Seq 统一遍历接口,减少同类代码的重复。
  • 业务中存在大量需要惰性加载或流式处理的场景,例如日志扫描、分页拉取数据、事件溯源等,可以直接用迭代器封装,避免一次性加载全部数据。
  • 正在设计或维护 SDK 的团队,可以利用 iter 包提供更符合 Go 习惯的遍历 API,让下游用户可以用 for range 轻松消费数据。

而对于一些业务逻辑简单、迭代模式固定的小型项目,引入迭代器可能会增加理解成本,不必强求。可以先在几个核心模块试点,看看代码审查的反馈。

一个真实的工程场景:按时间窗口遍历日志

假设你有一个日志系统,需要按小时遍历某个时间范围内的所有日志条目。如果不使用迭代器,你可能会写一个函数返回 []LogEntry,但这意味着必须一次性把整个时间窗口的数据加载到内存,窗口越大风险越高。改用迭代器后,可以这样设计:

func LogsInRange(start, end time.Time) iter.Seq[LogEntry] {
    return func(yield func(LogEntry) bool) {
        for t := start; t.Before(end); t = t.Add(time.Hour) {
            batch := fetchLogBatch(t) // 从存储拉取一批
            for _, entry := range batch {
                if !yield(entry) {
                    return
                }
            }
        }
    }
}

// 使用时
for entry := range LogsInRange(start, end) {
    process(entry)
}

这个迭代器内部按小时分批拉取,上游完全感知不到底层怎么分页、怎么控制频率。而且当 process 提前退出时,yield 返回 false,循环立即终止,不会浪费多余的网络请求。这种惰性、可中断的遍历模式,在以前需要手动维护一个状态机,现在用闭包就能表达得相当干净。

另一个场景:路由匹配的优先级遍历

在 HTTP 路由框架中,经常需要按优先级顺序遍历所有匹配的路由规则。传统实现可能是把所有路由塞进一个切片排序,然后返回。但如果你需要同时支持多个匹配条件,并且希望在第一个命中时就停止,用 Seq 可以避免排序整个切片,而是按需产出:

func MatchedRoutes(method, path string) iter.Seq2[int, Route] {
    return func(yield func(int, Route) bool) {
        for _, rule := range routeTable {
            if rule.match(method, path) {
                if !yield(rule.Priority, rule) {
                    return
                }
            }
        }
    }
}

for prio, route := range MatchedRoutes("GET", "/users") {
    if route.Handler != nil {
        // 使用第一个有效 handler
        break
    }
}

这里 MatchedRoutes 并没有一次性创建结果集,而是逐个 yield 匹配的规则,一旦调用方 break,迭代就自然停止。这种模式在路由中间件、责任链、拦截器等场景下都非常适用。

和传统迭代方式对比一下

下面这张表总结了不同迭代方案在几个典型维度上的差异,不一定全面,但可以帮助做技术选型时理清思路:

方案 惰性求值 与 for range 兼容 典型内存开销 可组合性 学习成本
返回切片 是(直接 range) 高(全量) 高(可链式调用) 极低
channel 遍历 中(goroutine+缓冲) 低(需额外管理)
Next() 模式 可选 中(取决于实现) 低(接口不统一)
iter.Seq (Push) 低(仅闭包) 中(可组合但需克制)

可以看到,iter.Seq 在惰性求值、兼容性和内存开销之间找到了一个不错的平衡点。但它不是银弹——如果你需要跨多个 goroutine 传递数据,或者需要复杂的迭代器组合(如 Zip、FlatMap),直接用 channel 或显式循环可能更简单。

如何开始落地

如果你决定在项目中引入迭代器,可以按以下步骤慢慢推进:

  1. 先从最痛苦的地方入手:那些反复出现的自定义遍历逻辑,或者导致内存飙升的批量查询。
  2. 优先在内部模块中使用,不要直接暴露在公共 API 里,观察一段时间团队是否适应。
  3. 为每个 Seq 编写单元测试,尤其要测试提前停止和资源释放的情况,因为这是最容易出 bug 的地方。
  4. 如果发现某个迭代器需要同时用于 Push 和 Pull,先评估是否真的有必要。大多数情况下只要 Push 就足够了。
  5. 避免在同一个调用链中混合使用多种迭代模型,比如一半用 Seq 一半用 channel,这会让代码的理解成本翻倍。

Go 的迭代器设计很“Go”:它不追求花哨的函数式编程,而是专注于解决一个具体问题——让自定义容器和惰性序列也能享受 for range 的便利。一旦你习惯了这种写法,就会发现很多以前需要几十行代码的遍历逻辑,现在可以用一个闭包干净地表达出来。但请不要因为新特性就急于重写所有循环,真正好的抽象,都是在需要时才引入的。

原创文章,作者:fudengji,如若转载,请注明出处:https://fudengji.cn/article/398/

(0)
上一篇 8小时前
下一篇 4小时前

相关推荐