Go 1.23 迭代器入门:range over func 能解决什么问题

面向 Go 初学者介绍 Go 1.23 range over func 的基本写法、适用场景、与切片返回的取舍,以及如何避免过度设计。

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

继续阅读

探索更多技术文章

浏览归档,发现更多关于系统设计、工具链和工程实践的内容。

全部文章 返回首页