大家好,我是十三!欢迎来到十三Tech。
前阵子做分页查询——结果集有几十万条,一次性 load 进内存会 OOM。最初想用 LIMIT/OFFSET 分页,但 OFFSET 大了性能急剧下降(数据库要扫过所有跳过的行)。后来改用 cursor-based 遍历——每次查 1000 条,调 Next() 取下一条,取完再查下一批。内存占用从几十万条降到 1000 条。
但调用方代码很别扭——既要管"当前批内的索引"、又要管"何时查下一批"。后来抽了一个 RowIterator 接口,调用方只看到 Next() / HasNext(),背后是游标管理、批量加载、网络请求。遍历逻辑被彻底封装。
这就是迭代器模式——提供统一接口遍历容器,不暴露内部结构。但 Go 的 for range 已经是迭代器了——这个模式还有价值吗?这一篇就聊聊迭代器解决什么,以及 Go 1.23 range-over-func 怎么把它变成语言级特性。
一、迭代器在解决什么——不是"循环"
教科书定义:提供一种方法顺序访问聚合对象中的元素,而不暴露其内部表示。这话在 C++ 时代有意义——容器实现五花八门,统一遍历接口能简化算法。但在 Go 里,for range 已经统一了切片、map、channel 的遍历——迭代器还有什么价值?
迭代器的真实价值有两个:隐藏非标准容器的内部结构 和 支持懒加载。
"隐藏内部结构"指的是——你的数据可能是数据库游标、文件流、网络分页、生成器序列,不是切片。让调用方看到 []Item 就要全量加载到内存,看到 Iterator 可以一条一条取。Iterator 是"取一个算一个"的抽象。
判断口诀:容器是切片/map → 直接 for range;容器是游标/流/分页/树 → 用迭代器。
二、Go 里迭代器怎么写——从接口到 range-over-func
Go 里迭代器有三种实现层次,从老到新。
type Iterator[T any] interface {
HasNext() bool
Next() (T, bool)
}
type SliceIterator[T any] struct {
data []T
pos int
}
func (it *SliceIterator[T]) HasNext() bool { return it.pos < len(it.data) }
func (it *SliceIterator[T]) Next() (T, bool) {
var zero T
if !it.HasNext() { return zero, false }
v := it.data[it.pos]; it.pos++
return v, true
}
这是经典 OO 风格——HasNext/Next 接口。Go 1.23 之前的标准写法。
但 Go 1.23 引入了 range-over-func——迭代器从此有了语言级支持:
func (l *List[T]) All() func(yield func(T) bool) {
return func(yield func(T) bool) {
for _, v := range l.items {
if !yield(v) { return }
}
}
}
for v := range list.All() {
fmt.Println(v)
}
这种写法让任何返回 func(yield func(T) bool) 的方法都能直接 for range——Iterator 接口不需要了。Go 1.23+ 项目首选这种写法。
写迭代器有个工程坑:遍历中修改容器。大多数迭代器对修改行为未定义——要么 fail-fast(修改立刻 panic),要么明确禁止。database/sql.Rows 在遍历时如果连接被复用去做其他查询,行为是未定义的。
三、什么时候别用迭代器
迭代器不是所有"遍历"场景都适用,至少有三个反模式。
反模式一:容器就是切片且永远不变。直接 for range slice 完了,写一个 SliceIterator 是过度设计。迭代器的成本(接口定义、Iterator 实现)只有非标准容器才摊得平。
反模式二:需要随机访问。迭代器是顺序访问——Next() 拿下一个,不能跳到第 100 个。如果你的算法需要随机访问(比如二分查找),用切片+索引更直接。
反模式三:性能极致敏感。迭代器多一层间接(接口调用、closure 调用)。热路径上每秒几百万次遍历的场景,直接 for range 切片比 Iterator 快几倍。
Go 标准库里迭代器最典型的地方是 database/sql.Rows——rows.Next() 是教科书级的迭代器。底层是数据库游标(cursor),数据可能分多批次从网络到达。调用方只看到 for rows.Next() { rows.Scan(...) },不需要知道当前批次的边界、网络缓冲、游标状态。bufio.Scanner、strings.Reader、json.Decoder 全是同样的设计——遍历接口把"数据在哪里"彻底隐藏。
回头看那个分页查询——值得用迭代器吗?值得,几十万条结果集、cursor-based 遍历、内存敏感,正是迭代器的甜区。但如果只是几十条记录的全量加载,直接 db.Query + for rows.Next() 就够了。
迭代器真正教会我的,不是"统一遍历接口",而是让调用方知道得越少越好——调用方只看到 Next(),不感知数据来自切片、文件、网络还是数据库。这个"接口隔离"原则比模式本身重要得多。
至此,图解 Go 设计模式系列 17 篇完结。创建型管"对象怎么来",结构型管"对象怎么搭",行为型管"对象怎么协作"——三类各有侧重,掌握后面对复杂设计也能拆解出模式影子。
迭代器最容易被忽略的是 Err 和 Close
for rows.Next() 看起来很轻,但真正的边界在循环外:遍历过程中发生的错误要从 Err() 拿,游标、文件、网络流要明确 Close()。如果只写 Next,不处理错误和资源释放,迭代器就会把复杂度藏到最后一行。
Go 里简单切片当然直接 range。迭代器适合那些"数据不是一次性在内存里"的场景:分页、游标、文件扫描、流式解码、树遍历。它让调用方少知道数据来源,但不能让调用方忘记遍历也是一次资源交互。
把问题再问深一层
排障追问一:Go 有 range 还需要迭代器吗?
内置集合直接 range 足够;当数据来自分页、游标、流式读取或复杂树结构时,自定义迭代器仍有价值。
排障追问二:迭代器最容易漏什么?
错误返回和资源关闭。数据库游标、文件扫描、网络流都需要明确 Close 或 context 取消。
排障追问三:迭代器和 channel 哪个更好?
channel 适合并发生产消费,但有 goroutine 生命周期成本;显式 Next/Err/Close 更适合可控遍历和错误处理。
关于十三Tech 资深服务端研发,AI 实践者,专注分享真实可落地的技术经验。 相信 AI 是程序员的最佳搭档。
联系方式:569893882@qq.com GitHub:@TriTechAI
