很多流行的编程语言中都以某种方式提供迭代器,其中包括 C++、Java、Javascript、Python 和 Rust。Go 语言现在也加入了迭代器。iter 包是 Go 1.23 新增的标准库,提供了迭代器的基本定义和相关操作。

为什么需要迭代器

在 Go 1.18 引入泛型之后,便可以很方便的定义一些泛型容器类型来提升编码效率。

例如我们可以基于 map 类型定义了一个集合类型—— Set

// Set 基于 map 定义一个存放元素的集合类型
type Set[E comparable] struct {
	m map[E]struct{}
}

// NewSet 返回一个 Set
func NewSet[E comparable]() *Set[E] {
	return &Set[E]{m: make(map[E]struct{})}
}

// Add 向 Set 中添加元素
func (s *Set[E]) Add(v E) {
	s.m[v] = struct{}{}
}

除了上面的集合类型,我们还可以定义很多不同的容器类型。而对于容器类型,通常都会需要遍历其中的所有元素。但是如何实现遍历功能,不同的容器类型、不同的人都会有不同的实现方案。

想一想在团队协作过程中,当我们拿到一个别人定义好的容器类型,我们必须先了解一下容器的内部细节,然后才能去尝试遍历它。这样我们就无法编写一个适用于多种不同类型容器的迭代函数。因此, Go 生态系统需要有一个迭代容器内元素的官方标准,让 Gopher 们能够尽量统一起来。

迭代器

在介绍 Go1.23 中新增的迭代器之前,我们先来看下 for/range 语句的变化。

for/range 语句改进

Go 语言内置的容器类型有数组、切片和映射。它们都有一种无需暴露底层实现即可访问其元素的方法:for/range 语句。

for/range 语句除了适用于 Go 的内置容器类型外,还适用于字符串和通道。此外 Go 1.22 开始 for/range 语句支持 int。

从 Go 1.23 开始,for/range 语句支持单参数的函数。 并且单参数本身必须是一个接收 0 到 2 个参数并返回一个 bool 的函数;按照惯例,我们称之为 yield 函数。

例如,下面三个常见的 yield 函数。

func(yield func() bool)

func(yield func(V) bool)

func(yield func(K, V) bool)

当我们在 Go 中谈论迭代器时,我们指的是具有这三种类型之一的函数。

Go 迭代器定义

1994年出版的《设计模式:可复用面向对象软件的基础》一书中介绍了 迭代器模式——“它让用户透过特定的接口巡访容器中的每一个元素而不用了解底层的实现。”

Go 1.23 中增加的迭代器是一个函数类型,它把另一个函数( yield 函数)作为参数,将容器中的连续元素传递给 yield 函数。

迭代器函数会在以下两种情况停止。

  • 在序列迭代结束后停止,表示此次迭代结束。
  • yield 函数返回 false 时停止,表示提前停止迭代。

Go 标准库 iter 包 中定义了 SeqSeq2 作为迭代器的简称,它们将每个序列元素的 1 或 2 个值传递给 yield 函数:

type (
	Seq[V any]     func(yield func(V) bool)
	Seq2[K, V any] func(yield func(K, V) bool)
)

其中:

  • Seq 是 sequence 的缩写,因为迭代器会循环遍历一系列值

  • Seq2 表示成对值的序列,通常是键值对或索引值对。

看到这里,你应该就明白了为什么需要改进 for/range 语句来支持单参数函数类型了,因为 for/range 语句要支持遍历新增的迭代器。

Push 迭代器(标准迭代器)

实现迭代器

我们现在为先前定义的集合类型定义一个返回所有元素的迭代器。

// All 返回一个迭代器,迭代集合中的所有元素
func (s *Set[E]) All() iter.Seq[E] {
	return func(yield func(E) bool) {
		for v := range s.m {
			if !yield(v) {
				return
			}
		}
	}
}

上面的代码中,对集合中的每个元素调用 yield,如果 yield 返回 false 则停止。

使用迭代器

当我们调用 s.All 后会得到一个迭代器函数,然后可以直接使用 for/range 语句来遍历它。

func forRangeSet() {
	s := NewSet[string]()
	s.Add("Golang")
	s.Add("Java")
	s.Add("Python")
	s.Add("C++")

	for v := range s.All() {
		fmt.Println(v)
	}
}

Pull 迭代器

我们已经了解了如何在 for/range 循环中使用迭代器。但简单的循环并不是使用迭代器的唯一方法。

例如,有时我们可能需要并行迭代两个容器。这时我们就需要用到另外一种不同类型的迭代器:Pull 迭代器。

Push 迭代器和 Pull 迭代器的区别:

  • Push 迭代器将序列中的每个值推送到 yield 函数。Push 迭代器是 Go 标准库中的标准迭代器,并由 for/range 语句直接支持。
  • Pull 迭代器的工作方式则相反。每次调用 Pull 迭代器时,它都会从序列中拉出另一个值并返回该值。for/range 语句直接支持 Pull 迭代器;但可以通过编写一个简单的 for 循环遍历 Pull 迭代器。

通常不需要自行实现一个 Pull 迭代器,新的标准库中 iter.Pulliter.Pull2 函数能够将标准迭代器转为 Pull 迭代器。

func Pull[V any](seq Seq[V]) (next func() (V, bool), stop func())

func Pull2[K, V any](seq Seq2[K, V]) (next func() (K, V, bool), stop func())

它们会返回两个函数。

  • 第一个是 Pull 迭代器:每次调用时都会返回序列中的下一个值和一个布尔值,该布尔值表示该值是否有效。
  • 第二个是停止函数,应在完成 Pull 迭代器后调用。

Pull 迭代器示例,将一个迭代器中的两个连续值对作为一个元素,返回一个新的迭代器。

// Pairs 返回一个迭代器,遍历 seq 中连续的值对。
func Pairs[V any](seq iter.Seq[V]) iter.Seq2[V, V] {
	return func(yield func(V, V) bool) {
		next, stop := iter.Pull(seq)
		defer stop()
		for {
			v1, ok1 := next()
			if !ok1 {
				return
			}
			v2, ok2 := next()
			// If ok2 is false, v2 should be the
			// zero value; yield one last pair.
			if !yield(v1, v2) {
				return
			}
			if !ok2 {
				return
			}
		}
	}
}

迭代器实践

命名约定

迭代器函数或者方法通常根据被遍历的序列命名,例如用 All 方法返回序列中所有的元素。

// All 返回 s 中所有元素的迭代器。
func (s *Set[V]) All() iter.Seq[V]

对于包含多个可能序列的类型,迭代器的名称需要能够指明正在提供的是哪个序列:

// Country 国家
type Country struct {
	cities    []*City  // 主要城市
	languages []string // 官方语言
}

// City 城市
type City struct {
	Name string
	Code int
}

// Cities 返回一个迭代器,迭代该国家的主要城市。
func (c *Country) Cities() iter.Seq[*City] {
	return func(yield func(city *City) bool) {
		for _, v := range c.cities {
			if !yield(v) {
				return
			}
		}
	}
}

// Languages 返回一个迭代器,迭代该国家的官方语言。
func (c *Country) Languages() iter.Seq[string] {
	return func(yield func(language string) bool) {
		for _, v := range c.languages {
			if !yield(v) {
				return
			}
		}
	}
}

一次性迭代器(Single-Use Iterators)

通常迭代器都会支持多次遍历,但是在某些场景下,例如,从网络或文件读取字节流时,迭代结束后就不会再读到值了。

这种返回一次性迭代器的函数或方法需要在文档注释中标明。

// Lines 返回一个从 r 按行读取的迭代器
// 返回的是一次性迭代器
func (r *Reader) Lines() iter.Seq[string]

标准库使用示例

标准库中的一些包已经提供了基于迭代器的 API,最常见的是 maps 包和 slices 包。

例如,maps.Keys 返回一个映射中所有键的迭代器,而 slices.Sorted 将迭代器的值收集到一个切片中,对它们进行排序,并返回排序后的切片。

func sortDomo() {
	m := map[int]string{
		1: "liwenzhou.com",
		3: "q1mi.cn",
		2: "go.dev",
	}
	for _, key := range slices.Sorted(maps.Keys(m)) {
		fmt.Println(key)
	}
}

适配器

迭代器的标准定义的一个优点是能够编写使用它们的标准适配器函数。

例如,可以定义一个过滤值序列并返回新序列的 Filter 函数。

这个 Filter 函数接受迭代器作为参数,并返回一个新的迭代器。它另一个参数是一个过滤器函数,它决定过滤器返回的新迭代器中应该过滤掉哪些值。

// Filter 返回一个迭代器序列,过滤掉 s 中 f(v) 为 false 的值
func Filter[V any](f func(V) bool, s iter.Seq[V]) iter.Seq[V] {
	return func(yield func(V) bool) {
		for v := range s {
			if f(v) {
				if !yield(v) {
					return
				}
			}
		}
	}
}

func filterDemo() {
	m := map[int]string{
		1: "liwenzhou.com",
		3: "q1mi.cn",
		2: "go.dev",
	}
	isLong := func(v string) bool {
		return len(v) > 6
	}
	for _, key := range slices.Collect(Filter(isLong, maps.Values(m))) {
		fmt.Println(key)
	}
}

在这个例子中,你也完全可以使用 for 循环来过滤掉不符合要求的元素,这样代码可能会更清晰;但是使用一个通用的 Filter 函数配合迭代器可以让代码更通用。

直接传递函数给迭代器

对于一些简单的场景,比如只是想打印下元素,我们甚至可以不使用 for/range 语句遍历迭代器。可以直接将函数当成参数传递给迭代器。例如上面打印集合中元素的示例,也可以写成下面的形式。

func forRangeSet() {
	s := NewSet[string]()
	s.Add("Golang")
	s.Add("Java")
	s.Add("Python")
	s.Add("C++")

	//for v := range s.All() {
	//	fmt.Println(v)
	//}

	// 直接把函数传入迭代器
	s.All()(func(v string) bool {
		fmt.Println(v)
		return true
	})
}

这里,我们相当于传递了一个自定义的 yield 函数。

总结

虽然看起来 Go 里面的迭代器很复杂(有太多的嵌套),但是只要理解了 yield 函数的作用,其原理还是比较简单的。

至于执行效率的问题,Go 编译器和运行时中做了相当多的工作来提高效率,并正确处理循环中的 breakpanic

参考链接:


扫码关注微信公众号