大家好,我是十三!欢迎来到十三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 篇完结。创建型管"对象怎么来",结构型管"对象怎么搭",行为型管"对象怎么协作"——三类各有侧重,掌握后面对复杂设计也能拆解出模式影子。
关于十三Tech 资深服务端研发,AI 实践者,专注分享真实可落地的技术经验。 相信 AI 是程序员的最佳搭档。
联系方式:569893882@qq.com GitHub:@TriTechAI
