Go 1.23 让 range 可以遍历函数形式的迭代器。对初学者来说,这个特性第一眼可能有点陌生:既然我们已经能遍历切片、map、channel,为什么还需要遍历函数?答案通常和“惰性生成”和“避免一次性装进内存”有关。
本文不追求把迭代器讲成高级概念,而是从一个分页读取任务的例子开始。你会看到什么时候返回切片更简单,什么时候迭代器更合适,以及写这种 API 时应该避免哪些过度设计。
最熟悉的切片返回
先看普通写法:
func ListTasks() []Task {
return []Task{
{ID: 1, Title: "写文档"},
{ID: 2, Title: "跑测试"},
}
}
for _, task := range ListTasks() {
fmt.Println(task.Title)
}
这很清楚。如果数据量小,返回切片是最简单的方案。不要为了新特性把所有函数都改成迭代器。Go 代码的第一目标仍然是可读。
问题出现在数据量很大,或者数据不是一次性生成的。比如从文件逐行读取、从数据库分页扫描、从外部 API 一页页拉取。一次性返回全部切片,可能占用很多内存,也会让调用方等到所有数据准备完才能开始处理。
range over func 的基本形状
一个简单迭代器可以这样写:
func Count(n int) func(func(int) bool) {
return func(yield func(int) bool) {
for i := 0; i < n; i++ {
if !yield(i) {
return
}
}
}
}
for n := range Count(3) {
fmt.Println(n)
}
yield 是调用方提供的函数。每生成一个值,就调用一次 yield。如果 yield 返回 false,表示调用方不想继续了,迭代器应该停止。比如调用方 break 时,就会走这个路径。
这个写法刚开始有点绕。你可以把它理解成:迭代器不是把所有值放进容器,而是每次把下一个值“推”给 range。
用在分页读取
假设我们有一个分页 API:
type PageClient interface {
ListTasks(ctx context.Context, pageToken string) (TaskPage, error)
}
type TaskPage struct {
Tasks []Task
NextToken string
}
传统写法可能一次拉完:
func LoadAllTasks(ctx context.Context, c PageClient) ([]Task, error) {
var all []Task
var token string
for {
page, err := c.ListTasks(ctx, token)
if err != nil {
return nil, err
}
all = append(all, page.Tasks...)
if page.NextToken == "" {
return all, nil
}
token = page.NextToken
}
}
数据少时没问题。数据多时,all 会越来越大。迭代器版本可以边拉边处理:
func Tasks(ctx context.Context, c PageClient) func(func(Task, error) bool) {
return func(yield func(Task, error) bool) {
var token string
for {
page, err := c.ListTasks(ctx, token)
if err != nil {
yield(Task{}, err)
return
}
for _, task := range page.Tasks {
if !yield(task, nil) {
return
}
}
if page.NextToken == "" {
return
}
token = page.NextToken
}
}
}
调用方:
for task, err := range Tasks(ctx, client) {
if err != nil {
return err
}
fmt.Println(task.Title)
}
这样调用方可以在第一条数据到达时就开始处理,不必等全部加载完。
错误怎么表达
迭代器里处理错误有几种方式。上面例子把 error 作为第二个值 yield 出去。这种方式直观,调用方每轮检查。另一种方式是迭代结束后通过外部变量取错误,但那会让 API 变得绕。
对初学者来说,先用 func(func(T, error) bool) 这种形式就够了。它虽然每轮都要看 error,但行为清楚。不要为了追求“漂亮”隐藏错误,尤其是 IO、网络、数据库这类随时可能失败的迭代。
如果迭代的是纯内存数据,不会产生错误,就用单值:
func Values(items []string) func(func(string) bool) {
return func(yield func(string) bool) {
for _, item := range items {
if !yield(item) {
return
}
}
}
}
API 形状应该服务场景,不要所有地方都套同一个模板。
break 必须能停止
迭代器实现里一定要检查 yield 返回值:
if !yield(task, nil) {
return
}
如果不检查,调用方即使 break,迭代器内部也可能继续拉数据或做计算。这会浪费资源,甚至造成意外请求。yield 返回 false 就是停止信号,要尊重。
这点和 channel 不同。channel 版本如果调用方不读了,发送方可能阻塞;迭代器版本通过 yield 返回值明确告诉生产方停止。写得好时,它比 channel 流水线更轻量。
什么时候不需要迭代器
以下情况返回切片更好:
- 数据量小
- 已经一次性在内存里
- 调用方经常需要随机访问
- API 使用者是初学者,简单比抽象重要
- 错误处理会因为迭代器变复杂
比如配置项、菜单列表、几十个枚举值,返回切片就很好。迭代器适合“大量、逐步、可提前停止”的数据流。不要因为 Go 新增了语法,就把普通列表包装成复杂 API。
和 channel 的区别
channel 也能表达数据流:
func TasksChan(ctx context.Context) <-chan Task {
ch := make(chan Task)
go func() {
defer close(ch)
// 发送任务
}()
return ch
}
channel 适合真正并发的生产和消费。迭代器更像普通函数调用,通常没有额外 goroutine,生命周期也更直接。如果只是顺序生成数据,迭代器往往比 channel 更简单。
一个实用判断:如果你不需要并发,就先别用 channel。range over func 能覆盖很多“只是想逐个产生值”的场景。
测试迭代器的停止行为
迭代器最容易漏测的是提前停止。可以写一个计数测试,确认 break 后不会继续生成:
func TestIteratorStopsOnBreak(t *testing.T) {
seen := 0
for n := range Count(100) {
seen++
if n == 2 {
break
}
}
if seen != 3 {
t.Fatalf("seen = %d, want 3", seen)
}
}
如果迭代器内部会访问网络或数据库,还应该用 fake client 记录调用次数。调用方提前停止后,迭代器不应继续拉下一页。这个行为直接影响资源使用,也是 range over func 比很多 channel 写法更容易控制的地方。
小结
Go 1.23 的 range over func 给了我们一种新的迭代方式,适合大数据量、分页读取、逐步生成和可提前停止的场景。它可以避免一次性构造大切片,也比不必要的 channel 更轻。
但新特性不是默认答案。数据少时返回切片仍然最清楚。写迭代器时要尊重 yield 的返回值,错误表达要直白。初学者掌握它的最好方式,是先在分页读取或文件扫描这类真实场景里使用,而不是到处替换普通 []T。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。