↓ 跳过正文

Go 的函数迭代器:写清 yield、提前退出与错误传递

正能量5 分钟阅读

用任务过滤和文本解析的示例解释 Go 函数迭代器,明确 yield 的停止信号、错误约定、数据所有权和适用范围。

彩色 Go Gopher 形象

如果一个 Go 模块需要把任务逐个交给调用方处理,除了返回整个切片,还可以提供迭代函数。这样调用方仍使用 for range,模块内部则控制如何产生元素。真正要理解的,是双方如何协作停止,以及出错时怎样让调用方知道。

示例使用 Go 1.23 或以上工具链。

用函数提供统一遍历入口
#

Go 1.23 支持对特定函数类型使用 for range,自定义容器因此可以提供一致的遍历入口。调用方不需要知道容器内部是切片、树还是其他结构,只需要依照公开接口获取元素。

标准库 iter 包定义了 Seq[V] 和 Seq2[K,V],分别向回调传递一个或两个值。回调通常命名为 yield,返回布尔值决定是否继续。它首先是一种函数调用约定,不等于启动后台生产者。

先看实现,再看循环
#

假设任务数据已经在内存里,下面只把待处理任务交给调用方。片段需要导入标准库 iter,类型与状态值均为示例:

type Task struct {
    ID     string
    Status string
}

func Pending(tasks []Task) iter.Seq[Task] {
    return func(yield func(Task) bool) {
        for _, task := range tasks {
            if task.Status != "pending" {
                continue
            }
            if !yield(task) {
                return
            }
        }
    }
}

Pending 返回的是一个函数。真正开始遍历时,这个函数才按自己的规则读取切片,把符合条件的任务交出去。这里没有复制出新的结果切片,也没有创建 goroutine;原始任务切片仍然已经存在。

调用方可以只取第一个符合条件的任务:

for task := range Pending(tasks) {
    fmt.Println(task.ID)
    break
}

调用片段还需要导入 fmt,并由调用方提供 tasks。它与常见循环一样可以提前结束,不需要为了这个需求创建通道,也不需要额外约定“读完后记得发一个停止消息”。

yield 的 false 不能忽略
#

循环体提前结束后,yield 返回 false,迭代函数不能再次调用它。因此实现中的 if !yield(task) { return } 是接口约定的一部分。

一个常见错误是调用 yield(task) 后忽略返回值,继续生成后续元素。即使完整遍历时看起来正常,只取一个元素的调用方也会暴露这个错误。

可以用人工推演检查流程:切片有三项待处理任务,第一次交付后调用方退出,实现是否立即返回?若还会读取第二项或执行后续外部操作,就要检查这些行为是否符合接口承诺。

上面的纯内存过滤在停止后不会继续处理元素。换成分页查询时,则应额外说明已预取的数据和已经发生的请求。停止消费不意味着能够撤回此前发出的远程查询。

两个值不一定非要是键和值
#

Seq2 的类型可以表达两个值,但业务含义由 API 自己约定。比如解析一组文本时,可以设计为“整数、错误”。以下需要导入 iter 与 strconv,选择遇到首个错误就结束:

func Integers(lines []string) iter.Seq2[int, error] {
    return func(yield func(int, error) bool) {
        for _, line := range lines {
            n, err := strconv.Atoi(line)
            if !yield(n, err) {
                return
            }
            if err != nil {
                return
            }
        }
    }
}

调用方必须先检查错误,再使用整数。错误对应的数值没有业务含义,不能把它当作正常的零处理。若忽略错误,会把无效输入变成貌似合理的数据。

这不是 Go 强制规定的统一错误模式。如果项目更习惯“迭代结束后读取 Err”或一次返回结果与错误,也可以保留既有 API。选择时需要考虑错误是否关联单项输入、能否继续,以及调用方是否容易漏检。

对于批量导入,可以进一步区分两类需求:一类遇到无效项立即停止,另一类收集全部错误后统一反馈。如果采用后一种需求,就需要携带输入位置等信息,并明确已成功处理的数据是否保留。仅把 return 删除,不足以形成完整的容错设计。

如果调用方在处理元素时还会写数据库,解析成功和业务写入成功也应分别表达。迭代器只负责提供输入,不能把“已经遍历到最后”当成所有业务操作均已完成的证据。失败恢复仍然需要由任务状态与事务边界决定。

迭代接口没有替你解决数据所有权
#

Pending 捕获了调用方传入的切片。返回迭代函数之后,底层数据仍可能被其他代码修改。这里既没有创建快照,也没有增加并发保护;不能因为返回类型变成迭代器,就宣称数据读取天然安全。

一个模块可以选择复制输入,以换取稳定视图;也可以要求调用期间不修改输入。两种约定都成立,但要把成本与边界写清。对于可变容器,还应说明迭代过程中增加或删除元素是否被允许。

这个函数每次调用都会重新从切片开头遍历,不维护跨调用游标。如果封装的是文件或网络流,可能只能消费一次。是否可重复遍历,应在接口文档中明确,不能让调用方根据函数签名猜测。

也不要默认遍历顺序就是业务顺序。底层若换成不保证顺序的容器,调用方依赖“第一个任务最早创建”的逻辑就会出问题。需要稳定排序时,应把排序依据写进接口约定,并评估为了排序而收集全部元素的成本。

有资源的迭代,先决定谁负责关闭
#

把文件读取或数据库结果包装成迭代器时,除了值,还涉及句柄、连接和取消。如果在迭代函数内部打开资源,应保证正常结束、提前退出和错误路径都能清理;如果资源由调用方传入,就要明确关闭责任。

不要在创建迭代函数之前就取得一个昂贵资源,却依赖“以后总会有人完整遍历”来释放它。调用方可能根本不开始遍历,也可能只读取一项。这种设计容易让使用方式与资源生命周期脱节。

对存在超时和取消的远程读取,还应保留显式的上下文参数和错误反馈。迭代语法可以让循环更自然,但不会自动提供重试、幂等或事务语义。

是否值得改,取决于调用方
#

如果只是遍历一个现成切片,普通 range 已经很清楚,无需为了使用新语法再包一层。模块需要隐藏容器实现、按需产生结果,或向多个调用方提供一致遍历方式时,迭代接口才更有意义。

改造前可以先检查空集合、完整遍历、首次退出、首项错误和中途错误五种情况。若涉及资源,再补充不开始遍历与提前取消。性能收益需要针对真实数据测量,不能仅凭写法推断更省内存或更快。

好的迭代接口,应让调用方知道拿到的是什么、何时停止、错误在哪里,以及谁负责资源。语法只是入口,这些约定决定它能否成为可维护的模块边界。

参考来源
#