Go 博客
函数类型上的 Range
简介
这是我在 GopherCon 2024 上演讲的博客文章版本。
函数类型上的 Range 是 Go 1.23 版本中的一项新的语言特性。这篇博客文章将解释我们添加此新特性的原因,它到底是什么以及如何使用它。
为什么?
从 Go 1.18 开始,我们能够在 Go 中编写新的泛型容器类型。例如,让我们考虑这个非常简单的 Set
类型,它是一个在 map 之上实现的泛型类型。
// Set holds a set of elements.
type Set[E comparable] struct {
m map[E]struct{}
}
// New returns a new [Set].
func New[E comparable]() *Set[E] {
return &Set[E]{m: make(map[E]struct{})}
}
自然地,集合类型具有一种添加元素的方法和一种检查元素是否存在的方法。这里的细节并不重要。
// Add adds an element to a set.
func (s *Set[E]) Add(v E) {
s.m[v] = struct{}{}
}
// Contains reports whether an element is in a set.
func (s *Set[E]) Contains(v E) bool {
_, ok := s.m[v]
return ok
}
除此之外,我们还需要一个函数来返回两个集合的并集。
// Union returns the union of two sets.
func Union[E comparable](s1, s2 *Set[E]) *Set[E] {
r := New[E]()
// Note for/range over internal Set field m.
// We are looping over the maps in s1 and s2.
for v := range s1.m {
r.Add(v)
}
for v := range s2.m {
r.Add(v)
}
return r
}
让我们花一分钟看看 Union
函数的实现。为了计算两个集合的并集,我们需要一种方法来获取每个集合中所有的元素。在这段代码中,我们对集合类型的未导出字段使用 for/range 语句。这只有在 Union
函数在集合包中定义的情况下才能工作。
但是,很多人可能想要遍历集合中的所有元素。这个集合包必须为其用户提供一种方法来做到这一点。
应该如何实现呢?
推送集合元素
一种方法是提供一个 Set
方法,该方法接受一个函数,并对集合中的每个元素调用该函数。我们将称之为 Push
,因为 Set
将每个值推送到函数中。在这里,如果函数返回 false,我们将停止调用它。
func (s *Set[E]) Push(f func(E) bool) {
for v := range s.m {
if !f(v) {
return
}
}
}
在 Go 标准库中,我们看到这种通用模式用于诸如 sync.Map.Range
方法、flag.Visit
函数和 filepath.Walk
函数等情况。这是一个通用模式,而不是一个确切的模式;碰巧的是,这三个示例中的任何一个都没有完全以相同的方式工作。
这就是使用 Push
方法打印集合中所有元素的方式:您使用一个执行您希望对元素执行的操作的函数来调用 Push
。
func PrintAllElementsPush[E comparable](s *Set[E]) {
s.Push(func(v E) bool {
fmt.Println(v)
return true
})
}
拉取集合元素
遍历 Set
元素的另一种方法是返回一个函数。每次调用函数时,它都会从 Set
返回一个值,以及一个布尔值,该布尔值报告该值是否有效。当循环遍历完所有元素时,布尔结果将为 false。在这种情况下,我们还需要一个可以在不再需要更多值时调用的停止函数。
此实现使用一对通道,一个用于集合中的值,另一个用于停止返回值。我们使用一个 goroutine 在通道上发送值。next
函数通过从元素通道读取来从集合中返回一个元素,stop
函数通过关闭停止通道来告诉 goroutine 退出。我们需要 stop
函数来确保当不再需要更多值时 goroutine 退出。
// Pull returns a next function that returns each
// element of s with a bool for whether the value
// is valid. The stop function should be called
// when finished calling the next function.
func (s *Set[E]) Pull() (func() (E, bool), func()) {
ch := make(chan E)
stopCh := make(chan bool)
go func() {
defer close(ch)
for v := range s.m {
select {
case ch <- v:
case <-stopCh:
return
}
}
}()
next := func() (E, bool) {
v, ok := <-ch
return v, ok
}
stop := func() {
close(stopCh)
}
return next, stop
}
标准库中没有任何东西完全按照这种方式工作。 runtime.CallersFrames
和 reflect.Value.MapRange
都很相似,尽管它们返回带有方法的值,而不是直接返回函数。
这就是使用 Pull
方法打印集合中所有元素的方式。您调用 Pull
获取一个函数,然后在 for 循环中重复调用该函数。
func PrintAllElementsPull[E comparable](s *Set[E]) {
next, stop := s.Pull()
defer stop()
for v, ok := next(); ok; v, ok = next() {
fmt.Println(v)
}
}
标准化方法
我们现在已经看到了两种遍历集合中所有元素的不同方法。不同的 Go 包使用这些方法和其他几种方法。这意味着,当您开始使用新的 Go 容器包时,您可能需要学习一种新的循环机制。这也意味着我们无法编写一个适用于多种不同容器类型的函数,因为容器类型将以不同的方式处理循环。
我们希望通过开发用于遍历容器的标准方法来改进 Go 生态系统。
迭代器
当然,这是许多编程语言中出现的问题。
广受欢迎的 设计模式书籍 首次出版于 1994 年,将其描述为迭代器模式。您使用迭代器来“提供一种按顺序访问聚合对象元素的方法,而不暴露其底层表示。”这句话中所说的聚合对象就是我一直在说的容器。聚合对象或容器只是一个包含其他值的 value,例如我们一直在讨论的 Set
类型。
与编程中的许多想法一样,迭代器可以追溯到 Barbara Liskov 在 1970 年代开发的 CLU 语言。
如今,许多流行语言都以某种方式提供了迭代器,包括但不限于 C++、Java、Javascript、Python 和 Rust。
但是,1.23 版本之前的 Go 并没有。
for/range
众所周知,Go 具有内置于语言中的容器类型:切片、数组和映射。它还具有一种无需暴露底层表示即可访问这些值元素的方法:for/range 语句。for/range 语句适用于 Go 的内置容器类型(也适用于字符串、通道以及从 Go 1.22 开始的 int)。
for/range 语句是迭代,但它不是当今流行语言中出现的迭代器。尽管如此,能够使用 for/range 迭代用户定义的容器类型(如 Set
类型)会很好。
但是,1.23 版本之前的 Go 并不支持这一点。
此版本中的改进
对于 Go 1.23,我们决定支持 for/range 遍历用户定义的容器类型以及标准化的迭代器形式。
我们扩展了 for/range 语句以支持遍历函数类型。我们将在下面看到这如何帮助遍历用户定义的容器。
我们还添加了标准库类型和函数来支持使用函数类型作为迭代器。迭代器的标准定义使我们能够编写与不同容器类型无缝协作的函数。
(某些)函数类型上的 Range
改进的 for/range 语句不支持任意函数类型。从 Go 1.23 开始,它现在支持遍历接受单个参数的函数。单个参数本身必须是一个接受零到两个参数并返回布尔值的函数;按照惯例,我们称之为 yield 函数。
func(yield func() bool)
func(yield func(V) bool)
func(yield func(K, V) bool)
当我们谈论 Go 中的迭代器时,我们的意思是具有这三种类型之一的函数。正如我们将在下面讨论的那样,标准库中还有另一种迭代器:拉取迭代器。当有必要区分标准迭代器和拉取迭代器时,我们将标准迭代器称为推送迭代器。那是因为,正如我们将看到的,它们通过调用 yield 函数来推出一系列值。
标准(推送)迭代器
为了使迭代器更容易使用,新的标准库包 iter 定义了两种类型:Seq
和 Seq2
。这些是迭代器函数类型的名称,这些类型可以与 for/range 语句一起使用。Seq
是序列的缩写,因为迭代器循环遍历一系列值。
package iter
type Seq[V any] func(yield func(V) bool)
type Seq2[K, V any] func(yield func(K, V) bool)
// for now, no Seq0
Seq
和 Seq2
之间的区别仅仅在于 Seq2
是一个成对的序列,例如映射中的键和值。在这篇文章中,为了简单起见,我们将重点关注 Seq
,但我们所说的大多数内容也适用于 Seq2
。
用一个例子来解释迭代器的工作原理最容易。这里 Set
方法 All
返回一个函数。All
的返回类型是 iter.Seq[E]
,因此我们知道它返回一个迭代器。
// All is an iterator over the elements of s.
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 函数。在这种情况下,迭代器(由 Set.All
返回的函数)与我们之前看到的 Set.Push
函数非常相似。
这展示了迭代器的工作原理:对于某些值序列,它们使用序列中的每个值调用 yield 函数。如果 yield 函数返回 false,则不再需要更多值,迭代器可以简单地返回,执行可能需要的任何清理工作。如果 yield 函数从未返回 false,则迭代器可以在使用序列中的所有值调用 yield 后简单地返回。
这就是它们的工作原理,但让我们承认,当你第一次看到其中之一时,你的第一反应可能是“这里有很多函数在飞来飞去”。你并没有说错。让我们关注两件事。
首先,一旦你跳过这个函数代码的第一行,迭代器的实际实现就非常简单:使用集合中的每个元素调用 yield,如果 yield 返回 false 则停止。
for v := range s.m {
if !yield(v) {
return
}
}
其次,使用它真的很容易。您调用 s.All
获取一个迭代器,然后使用 for/range 遍历 s
中的所有元素。for/range 语句支持任何迭代器,这展示了使用它的简单性。
func PrintAllElements[E comparable](s *Set[E]) {
for v := range s.All() {
fmt.Println(v)
}
}
在这种代码中,s.All
是一个返回函数的方法。我们正在调用 s.All
,然后使用 for/range 遍历它返回的函数。在这种情况下,我们可以让 Set.All
本身成为一个迭代器函数,而不是让它返回一个迭代器函数。但是,在某些情况下这将无法正常工作,例如如果返回迭代器的函数需要接受一个参数,或者需要进行一些设置工作。作为一项约定,我们鼓励所有容器类型提供一个返回迭代器的 All
方法,以便程序员不必记住是直接遍历 All
,还是调用 All
来获取可以遍历的值。他们始终可以执行后者。
如果你仔细想想,你会发现编译器必须调整循环以创建一个 yield 函数,并将其传递给 s.All
返回的迭代器。Go 编译器和运行时在使此操作高效方面存在相当大的复杂性,并且可以正确处理循环中的 break
或 panic
等内容。我们不会在这篇博文中介绍任何内容。幸运的是,在实际使用此功能时,实现细节并不重要。
拉取迭代器
我们已经看到了如何在 for/range 循环中使用迭代器。但是简单的循环并不是使用迭代器的唯一方法。例如,有时我们可能需要并行迭代两个容器。我们该怎么做呢?
答案是,我们使用一种不同类型的迭代器:拉取迭代器。我们已经了解到,标准迭代器(也称为推送迭代器)是一个函数,它将一个 yield 函数作为参数,并通过调用 yield 函数将序列中的每个值推送出去。
拉取迭代器的工作方式则相反:它是一个函数,每次调用它时都会返回序列中的下一个值。
我们将重复两种类型的迭代器之间的区别,以帮助你记住。
- 推送迭代器将序列中的每个值推送到一个 yield 函数。推送迭代器是 Go 标准库中的标准迭代器,并且直接由 for/range 语句支持。
- 拉取迭代器的工作方式则相反。每次调用拉取迭代器时,它都会从序列中拉取另一个值并将其返回。拉取迭代器不直接由 for/range 语句支持;但是,编写一个遍历拉取迭代器的普通 for 语句非常简单。事实上,我们之前在查看
Set.Pull
方法时看到了一个示例。
你可以自己编写一个拉取迭代器,但通常你不需要这样做。新的标准库函数 iter.Pull
将一个标准迭代器(即一个推送迭代器函数)作为参数,并返回一对函数。第一个是拉取迭代器:一个函数,每次调用它时都会返回序列中的下一个值。第二个是停止函数,当我们完成拉取迭代器时应该调用它。这类似于我们之前看到的 Set.Pull
方法。
iter.Pull
返回的第一个函数(即拉取迭代器)返回一个值和一个布尔值,该布尔值报告该值是否有效。在序列末尾,布尔值将为 false。
iter.Pull
返回一个停止函数,以防我们没有遍历到序列的末尾。在一般情况下,推送迭代器(iter.Pull
的参数)可能会启动 goroutine 或构建新的数据结构,这些数据结构需要在迭代完成后清理。当 yield 函数返回 false(表示不再需要更多值)时,推送迭代器将执行任何清理操作。当与 for/range 语句一起使用时,for/range 语句将确保如果循环过早退出(通过 break
语句或任何其他原因),则 yield 函数将返回 false。另一方面,对于拉取迭代器,没有办法强制 yield 函数返回 false,因此需要停止函数。
换句话说,调用停止函数将导致 yield 函数在被推送迭代器调用时返回 false。
严格地说,如果拉取迭代器返回 false 来指示它已到达序列的末尾,则不需要调用停止函数,但通常总是调用它会更简单。
以下是如何使用拉取迭代器并行遍历两个序列的示例。此函数报告两个任意序列是否按相同顺序包含相同的元素。
// EqSeq reports whether two iterators contain the same
// elements in the same order.
func EqSeq[E comparable](s1, s2 iter.Seq[E]) bool {
next1, stop1 := iter.Pull(s1)
defer stop1()
next2, stop2 := iter.Pull(s2)
defer stop2()
for {
v1, ok1 := next1()
v2, ok2 := next2()
if !ok1 {
return !ok2
}
if ok1 != ok2 || v1 != v2 {
return false
}
}
}
此函数使用 iter.Pull
将两个推送迭代器 s1
和 s2
转换为拉取迭代器。它使用 defer
语句确保在完成使用它们时停止拉取迭代器。
然后代码循环,调用拉取迭代器来检索值。如果第一个序列已完成,则如果第二个序列也已完成,则返回 true,否则返回 false。如果值不同,则返回 false。然后它循环以拉取接下来的两个值。
与推送迭代器一样,Go 运行时在使拉取迭代器高效方面也存在一些复杂性,但这不会影响实际使用 iter.Pull
函数的代码。
迭代迭代器
现在你已经了解了关于 range over 函数类型和迭代器的所有知识。我们希望你享受使用它们!
不过,还有一些值得一提的事情。
适配器
迭代器标准定义的一个优点是能够编写使用它们的标准适配器函数。
例如,以下是一个过滤值序列的函数,它返回一个新序列。此 Filter
函数将迭代器作为参数,并返回一个新的迭代器。另一个参数是一个过滤器函数,它决定哪些值应该出现在 Filter
返回的新迭代器中。
// Filter returns a sequence that contains the elements
// of s for which f returns true.
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
}
}
}
}
}
与前面的示例一样,函数签名在第一次看到时看起来很复杂。一旦你克服了签名,实现就非常简单了。
for v := range s {
if f(v) {
if !yield(v) {
return
}
}
}
代码遍历输入迭代器,检查过滤器函数,并使用应该进入输出迭代器的值调用 yield。
我们将在下面展示使用 Filter
的示例。
(Go 标准库中目前还没有 Filter
版本,但可能会在将来的版本中添加。)
二叉树
作为推送迭代器对于循环遍历容器类型有多方便的一个例子,让我们考虑一下这个简单的二叉树类型。
// Tree is a binary tree.
type Tree[E any] struct {
val E
left, right *Tree[E]
}
我们不会展示将值插入树中的代码,但自然应该有一些方法可以遍历树中的所有值。
事实证明,如果迭代器代码返回一个布尔值,则更容易编写。由于 for/range 支持的函数类型不返回任何内容,因此此处的 All
方法返回一个小的函数字面量,它调用迭代器本身(此处称为 push
)并忽略布尔值结果。
// All returns an iterator over the values in t.
func (t *Tree[E]) All() iter.Seq[E] {
return func(yield func(E) bool) {
t.push(yield)
}
}
// push pushes all elements to the yield function.
func (t *Tree[E]) push(yield func(E) bool) bool {
if t == nil {
return true
}
return t.left.push(yield) &&
yield(t.val) &&
t.right.push(yield)
}
push
方法使用递归遍历整个树,对每个元素调用 yield。如果 yield 函数返回 false,则该方法会一直向上返回 false 到堆栈顶端。否则,它只会在迭代完成后返回。
这展示了使用这种迭代器方法遍历复杂数据结构是多么简单。不需要维护单独的堆栈来记录树中的位置;我们只需使用 goroutine 调用堆栈来为我们执行此操作即可。
新的迭代器函数。
Go 1.23 中还新增了切片和映射包中的一些函数,这些函数可以使用迭代器。
以下是切片包中新增的函数。All
和 Values
是返回切片元素的迭代器的函数。Collect
从迭代器中获取值,并返回一个包含这些值的切片。请参阅文档以了解其他内容。
All([]E) iter.Seq2[int, E]
Values([]E) iter.Seq[E]
Collect(iter.Seq[E]) []E
AppendSeq([]E, iter.Seq[E]) []E
Backward([]E) iter.Seq2[int, E]
Sorted(iter.Seq[E]) []E
SortedFunc(iter.Seq[E], func(E, E) int) []E
SortedStableFunc(iter.Seq[E], func(E, E) int) []E
Repeat([]E, int) []E
Chunk([]E, int) iter.Seq([]E)
以下是映射包中新增的函数。All
、Keys
和 Values
返回映射内容的迭代器。Collect
从迭代器中获取键和值,并返回一个新的映射。
All(map[K]V) iter.Seq2[K, V]
Keys(map[K]V) iter.Seq[K]
Values(map[K]V) iter.Seq[V]
Collect(iter.Seq2[K, V]) map[K, V]
Insert(map[K, V], iter.Seq2[K, V])
标准库迭代器示例
以下是如何使用这些新函数以及我们之前看到的 Filter
函数的示例。此函数接收一个从 int 到 string 的映射,并返回一个包含映射中所有长度大于某个参数 n
的值的切片。
// LongStrings returns a slice of just the values
// in m whose length is n or more.
func LongStrings(m map[int]string, n int) []string {
isLong := func(s string) bool {
return len(s) >= n
}
return slices.Collect(Filter(isLong, maps.Values(m)))
}
maps.Values
函数返回 m
中值的迭代器。Filter
读取该迭代器并返回一个新的迭代器,该迭代器仅包含长字符串。slices.Collect
从该迭代器中读取到一个新的切片中。
当然,你可以轻松地编写一个循环来完成此操作,并且在许多情况下循环会更清晰。我们不想鼓励每个人总是以这种方式编写代码。也就是说,使用迭代器的优点是,这种函数对任何序列都以相同的方式工作。在本例中,请注意 Filter
如何使用映射作为输入,使用切片作为输出,而无需更改 Filter
中的代码。
循环遍历文件中的行
虽然我们看到的大多数示例都涉及容器,但迭代器很灵活。
考虑以下简单代码,它不使用迭代器,而是循环遍历字节切片中的行。这很容易编写并且相当高效。
nl := []byte{'\n'}
// Trim a trailing newline to avoid a final empty blank line.
for _, line := range bytes.Split(bytes.TrimSuffix(data, nl), nl) {
handleLine(line)
}
但是,bytes.Split
会分配并返回一个字节切片切片,以保存行。垃圾收集器将不得不做一些工作来最终释放该切片。
以下是一个返回字节切片中行的迭代器的函数。在通常的迭代器签名之后,函数非常简单。我们不断从数据中提取行,直到没有剩余的行,然后我们将每行传递给 yield 函数。
// Lines returns an iterator over lines in data.
func Lines(data []byte) iter.Seq[[]byte] {
return func(yield func([]byte) bool) {
for len(data) > 0 {
line, rest, _ := bytes.Cut(data, []byte{'\n'})
if !yield(line) {
return
}
data = rest
}
}
}
现在,循环遍历字节切片中行的代码如下所示。
for line := range Lines(data) {
handleLine(line)
}
这与之前的代码一样容易编写,并且效率更高,因为它不需要分配行切片。
将函数传递给推送迭代器
在最后一个例子中,我们将看到您不必在范围语句中使用 push 迭代器。
早些时候我们看到了一个 `PrintAllElements` 函数,它打印集合中的每个元素。这里还有另一种方法可以打印集合中的所有元素:调用 `s.All` 获取迭代器,然后传入一个手写的 yield 函数。这个 yield 函数只是打印一个值并返回 true。请注意这里有两个函数调用:我们调用 `s.All` 获取一个迭代器,它本身是一个函数,然后我们用我们手写的 yield 函数调用该函数。
func PrintAllElements[E comparable](s *Set[E]) {
s.All()(func(v E) bool {
fmt.Println(v)
return true
})
}
没有特别的理由要用这种方式编写代码。这只是一个例子,表明 yield 函数不是魔法。它可以是你喜欢的任何函数。
更新 go.mod
最后一点:每个 Go 模块都指定了它使用的语言版本。这意味着为了在一个现有模块中使用新的语言特性,您可能需要更新该版本。这对于所有新的语言特性都是如此;它不是特定于对函数类型进行范围操作的。由于对函数类型进行范围操作是 Go 1.23 版本中的新功能,因此使用它需要至少指定 Go 语言版本 1.23。
有(至少)四种方法可以设置语言版本
- 在命令行中,运行 `go get [email protected]`(或 `go mod edit -go=1.23` 仅编辑 `go` 指令)。
- 手动编辑 `go.mod` 文件并更改 `go` 行。
- 将旧的语言版本保留在整个模块中,但使用 `//go:build go1.23` 构建标签允许在特定文件中对函数类型进行范围操作。
下一篇文章:新的 unique 包
上一篇文章:Go 1.23 发布
博客索引