Go语言中的纯与不纯迭代器深度解析

本文深入探讨Go语言1.23版本引入的标准迭代器实现,分析纯迭代器与不纯迭代器的本质区别,通过斐波那契数列生成器的具体案例展示闭包对迭代器行为的影响,并讨论性能优化与API一致性的设计权衡。

纯与不纯迭代器在Go中的对比

TL;DR ¶

Go现已标准化迭代器实现。迭代器功能强大,其本质是函数,因此可以是闭包。官方文档对迭代器的分类存在模糊性。将迭代器划分为“纯”和“不纯”两类更为合理。是否应尽可能设计纯迭代器仍存在争议。

Go中迭代器的出现 ¶

迭代器模式通过经典《设计模式》一书推广,其定义为:

提供一种方法顺序访问聚合对象中的各个元素,而又不暴露该对象的内部表示。

直到最近,Go中可通过for-range循环迭代的数据结构仅限于数组(直接或通过指针)、切片、字符串、映射、通道和整数。然而,随着Go 1.18对参数化多态(即“泛型”)的支持,Go 1.23标准化了自定义迭代器的定义方式,在标准库中添加了iter包,并在slicesmaps包中引入了几个迭代器工厂(即返回迭代器的函数或方法)。Go 1.24更在标准库中推出了更多迭代器工厂,如strings.SplitSeq

1
2
3
4
// SplitSeq返回一个迭代器,遍历s中由sep分隔的所有子字符串。
// 迭代器产生与Split(s, sep)相同的字符串,但不构建切片。
// 它返回一个单次使用的迭代器。
func SplitSeq(s, sep string) iter.Seq[string]

如果您不熟悉Go 1.23+中迭代器的语法和语义,建议阅读Ian Lance Taylor在Go博客上发表的介绍性文章。一旦度过最初的困惑期(“为什么这么多func?!”),就会变得非常顺畅。而且,迭代器的调用者通常与其实现中的任何复杂性隔离。

作为第一个示例,考虑以下程序(playground),其中包含一个名为fib0的工厂函数,该函数生成一个斐波那契数列的迭代器:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
package main

import (
    "fmt"
    "iter"
)

func fib0() iter.Seq[int] {
    return func(yield func(int) bool) {
        for a, b := 0, 1; yield(a); a, b = b, a+b {
            // 故意为空
        }
    }
}

func main() {
    for n := range fib0() {
        if n > 100 {
            break
        }
        fmt.Printf("%d ", n)
    }
}

如您所料,该程序按递增顺序打印小于100的斐波那契数:

1
0 1 1 2 3 5 8 13 21 34 55 89 

迭代器的力量 ¶

迭代器的标准化是Go语言的一个受欢迎的新特性。迭代器确实提供了切实的好处:

  • 促进灵活性和关注点分离:调用者可以不了解序列是如何产生的,而专注于如何处理数据;抽象GitHub API分页消费的迭代器就是一个很好的例子。
  • 鼓励封装:因为它们将数据暴露为不可变的序列,不像切片和映射那样可以改变。
  • 有潜力提升性能:它们不是具体化包含整个数据体的数据结构,而是根据调用者的要求逐个分配元素,从而在许多(虽然不是所有)情况下承诺更低的延迟和更少的堆分配;它们也比依赖通道的迭代器实现更高效。
  • 允许无限序列(例如素数序列),这是切片和映射等有限数据结构永远无法提供的功能。

由于迭代器如此强大,它们很可能在库中大量出现,甚至超出Go的标准库。因此,为了防止关于迭代器的讨论中出现任何混淆,围绕它们的术语应尽可能精确。

关于迭代器分类的官方指导 ¶

“单次使用迭代器”这一短语可能在strings.SplitSeq的文档中引起了您的注意。这在iter包的一个部分中有解释:

大多数迭代器提供遍历整个序列的能力:当被调用时,迭代器执行启动序列所需的任何设置,然后对序列的连续元素调用yield,并在返回之前进行清理。再次调用迭代器会再次遍历序列。 一些迭代器打破了这一惯例,提供仅能遍历序列一次的能力。这些“单次使用迭代器”通常报告来自无法回滚重新开始的数据流的值。在提前停止后再次调用迭代器可能会继续流,但在序列结束后再次调用将不会产生任何值。返回单次使用迭代器的函数或方法的文档注释应记录这一事实:

1
2
3
// Lines返回一个从r读取的行的迭代器。
// 它返回一个单次使用的迭代器。
func (r *Reader) Lines() iter.Seq[string]

文档的这段话似乎将迭代器分为两类。我将尝试通过几个例子来阐明它们。

“纯”迭代器的例子 ¶

我的fib0函数的结果清楚地例示了第一类:

大多数迭代器提供遍历整个序列的能力:当被调用时,迭代器执行启动序列所需的任何设置,然后对序列的连续元素调用yield,并在返回之前进行清理。再次调用迭代器会再次遍历序列。

如果我们将fib0的结果赋值给一个变量,然后多次遍历该迭代器,我们确实每次都会从头开始遍历斐波那契数列:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
package main

import (
    "fmt"
    "iter"
)

func fib0() iter.Seq[int] {
    return func(yield func(int) bool) {
        for a, b := 0, 1; yield(a); a, b = b, a+b {
            // 故意为空
        }
    }
}

func main() {
    seq := fib0()
    for n := range seq {
        if n > 10 {
            break
        }
        fmt.Printf("%d ", n)
    }
    fmt.Println()
    for n := range seq {
        if n > 100 {
            break
        }
        fmt.Printf("%d ", n)
    }
    fmt.Println()
    for n := range seq {
        if n > 1000 {
            break
        }
        fmt.Printf("%d ", n)
    }
}

输出:

1
2
3
0 1 1 2 3 5 8 
0 1 1 2 3 5 8 13 21 34 55 89 
0 1 1 2 3 5 8 13 21 34 55 89 144 233 377 610 987 

根据我对文档的解释,第一类迭代器对应于外部纯函数,即迭代器可能在内部依赖突变,但不显示外部可观察的副作用。因此,“纯”似乎是一个合适的限定词;“无状态”是其次。

“单次使用”迭代器的例子 ¶

第二类呢?

一些迭代器打破了这一惯例,提供仅能遍历序列一次的能力。这些“单次使用迭代器”通常报告来自无法回滚重新开始的数据流的值。在提前停止后再次调用迭代器可能会继续流,但在序列结束后再次调用将不会产生任何值。

考虑以下例子:

1
2
3
4
5
6
7
8
func fib1() iter.Seq[int] {
    a, b := 0, 1
    return func(yield func(int) bool) {
        for ; yield(a); a, b = b, a+b {
            // 故意为空
        }
    }
}

乍一看,迭代器工厂fib1似乎与fib0几乎相同,您可能会认为它们实现上的差异不重要;事实上,如果您这样认为,您并不孤单。然而,fib1fib0之间的差异远非仅仅是表面上的,实际上改变了它们结果的语义。要理解为什么,您需要熟悉闭包。

如有必要,请参阅本文的附录以复习闭包。

回想一下,在Go中,迭代器是函数;因此,迭代器可以是闭包!在fib0中,变量ab在结果迭代器内部声明。相反,在fib1中,变量ab是结果迭代器的自由变量,该迭代器也恰好改变了这些变量。因此,如果我们像之前对fib0的结果那样重复遍历fib1的结果(playground),我们会观察到非常不同的行为:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
package main

import (
    "fmt"
    "iter"
)

func fib1() iter.Seq[int] {
    a, b := 0, 1
    return func(yield func(int) bool) {
        for ; yield(a); a, b = b, a+b {
            // 故意为空
        }
    }
}

func main() {
    seq := fib1()
    for n := range seq {
        if n > 10 {
            break
        }
        fmt.Printf("%d ", n)
    }
    fmt.Println()
    for n := range seq {
        if n > 100 {
            break
        }
        fmt.Printf("%d ", n)
    }
    fmt.Println()
    for n := range seq {
        if n > 1000 {
            break
        }
        fmt.Printf("%d ", n)
    }
}

输出:

1
2
3
0 1 1 2 3 5 8 
13 21 34 55 89 
144 233 377 610 987

简单来说,迭代器“记住”了调用者上次在斐波那契数列中停止的位置,当调用者恢复遍历时,迭代器从上次停止的地方继续;因为这个迭代器产生的序列无法回滚但可以恢复,所以迭代器可能被描述为“单次使用”但“可恢复”。

有问题的分类 ¶

我认为文档中建议的迭代器分类并不理想。

首先,它缺乏一个术语来描述我之前称为“纯”的迭代器,这令人困惑。在所有迭代器中,“纯”迭代器 arguably 因其最容易推理而脱颖而出;因此,它们不应该有自己的限定词吗?与动力系统的类比浮现在脑海中:线性动力系统从非线性系统中被单独挑出来, precisely 因为它们被认为更简单且更容易理解。

其次,我发现第二类的描述令人困惑地不精确和模糊。特别是,“单次使用”这一术语是 intended 仅涵盖部分还是所有“不纯”迭代器,这对我来说不清楚……而且根据我最近在Gophers Slack上进行的一项非正式调查的结果,其他Gopher也是如此。如果“单次使用”限定符仅用于“不纯”迭代器的一个子类别,我不确定为什么这个子类别应该在文档中特别关注。如果“单次使用”限定符用于所有“不纯”迭代器,我认为这是一个误称;毕竟,许多形式的“不纯”迭代器是可能的,并非所有都可以合理地描述为“单次使用”,正如我们将看到的。

“不纯”迭代器的奇妙动物园 ¶

在下面的程序中,fib2产生的迭代器(playground)可以合理地描述为“可使用两次”和“不可恢复”(或者可能“重新启动”):

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
package main

import (
    "fmt"
    "iter"
)

func fib2() iter.Seq[int] {
    var n int
    return func(yield func(n int) bool) {
        if n > 1 {
            return
        }
        for a, b := 0, 1; yield(a); a, b = b, a+b {
            // 故意为空
        }
        n++
    }
}

func main() {
    seq := fib2()
    for n := range seq {
        if n > 10 {
            break
        }
        fmt.Printf("%d ", n)
    }
    fmt.Println()
    for n := range seq {
        if n > 100 {
            break
        }
        fmt.Printf("%d ", n)
    }
    fmt.Println()
    for n := range seq {
        if n > 1000 {
            break
        }
        fmt.Printf("%d ", n)
    }
}

输出:

1
2
0 1 1 2 3 5 8 
0 1 1 2 3 5 8 13 21 34 55 89 

在下面的程序中,fib3产生的迭代器(playground)可以合理地描述为“可使用两次”和“可恢复”:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
package main

import (
    "fmt"
    "iter"
)

func fib3() iter.Seq[int] {
    var n int
    a, b := 0, 1
    return func(yield func(n int) bool) {
        if n > 1 {
            return
        }
        for ; yield(a); a, b = b, a+b {
            // 故意为空
        }
        n++
    }
}

func main() {
    seq := fib3()
    for n := range seq {
        if n > 10 {
            break
        }
        fmt.Printf("%d ", n)
    }
    fmt.Println()
    for n := range seq {
        if n > 100 {
            break
        }
        fmt.Printf("%d ", n)
    }
    fmt.Println()
    for n := range seq {
        if n > 1000 {
            break
        }
        fmt.Printf("%d ", n)
    }
}

输出:

1
2
0 1 1 2 3 5 8 
13 21 34 55 89 

我相信您可以想到更多围绕这个主题的变体。一旦函数纯度被抛弃,可能性是无限的。

是否应尽可能设计“纯”迭代器? ¶

另一个问题出现了:如果“纯”迭代器比“不纯”迭代器更容易推理,难道不应该尽可能将迭代器设计为“纯”吗?

性能考虑 ¶

也许应该,至少当我们强调性能作为设计标准时。“纯”迭代器确实往往比其“不纯”对应物产生更少的堆分配。以strings.Lines作为案例研究;以下是其在Go 1.24.3中的源代码:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
// Lines返回一个迭代器,遍历字符串s中的以换行符终止的行。
// 迭代器产生的行包括它们的终止换行符。
// 如果s为空,迭代器不产生任何行。
// 如果s不以换行符结尾,最后产生的行将不以换行符结尾。
// 它返回一个单次使用的迭代器。
func Lines(s string) iter.Seq[string] {
    return func(yield func(string) bool) {
        for len(s) > 0 {
            var line string
            if i := IndexByte(s, '\n'); i >= 0 {
                line, s = s[:i+1], s[i+1:]
            } else {
                line, s = s, ""
            }
            if !yield(line) {
                return
            }
        }
        return
    }
}

结果迭代器是“不纯”的,因为它改变了s,这恰好是其唯一的自由变量。由于逃逸分析的结果,s逃逸到堆:

1
2
3
4
$ go build -gcflags='-m=2' ./strings
-snip-
strings/iter.go:18:12: s escapes to heap:
-snip-

strings.Lines本可以设计为产生一个“纯”迭代器:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
func Lines(s string) iter.Seq[string] {
    return func(yield func(string) bool) {
        s := s // 本地副本!
        for len(s) > 0 {
            var line string
            if i := strings.IndexByte(s, '\n'); i >= 0 {
                line, s = s[:i+1], s[i+1:]
            } else {
                line, s = s, ""
            }
            if !yield(line) {
                return
            }
        }
        return
    }
}

与相关迭代器的一致性 ¶

然而,性能只是可能的设计标准之一:正如Ian Lance Taylor精明地向我指出的,与紧密相关的迭代器工厂的行为一致性是另一个标准。例如,bytes包提供了一个名为Lines的函数,类似于strings.Lines;以下是其在Go 1.24.3中的源代码:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
// Lines返回一个迭代器,遍历字节切片s中的以换行符终止的行。
// 迭代器产生的行包括它们的终止换行符。
// 如果s为空,迭代器不产生任何行。
// 如果s不以换行符结尾,最后产生的行将不以换行符结尾。
// 它返回一个单次使用的迭代器。
func Lines(s []byte) iter.Seq[[]byte] {
    return func(yield func([]byte) bool) {
        for len(s) > 0 {
            var line []byte
            if i := IndexByte(s, '\n'); i >= 0 {
                line, s = s[:i+1], s[i+1:]
            } else {
                line, s = s, nil
            }
            if !yield(line[:len(line):len(line)]) {
                return
            }
        }
        return
    }
}

尽管我很想重新设计bytes.Lines以使其产品“纯”,但我想不出一种高效的方法。与字符串相反,切片是可变的;而且,多个切片可以引用同一个底层数组。因此,即使结果迭代器被修改为操作源切片的本地副本,它仍然不会是“纯”的,如下面程序所示(playground):

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
package main

import (
    "bytes"
    "fmt"
    "iter"
    "os"
)

func main() {
    src := []byte("foo\nbar\nbaz\n")
    seq := Lines(src)
    for s := range seq {
        os.Stdout.Write(s)
    }
    for s := range seq { // 传递改变源切片的通道
        if len(s) == 0 {
            continue
        }
        s[0] = 'a'
    }
    fmt.Println()
    for s := range seq {
        os.Stdout.Write(s)
    }
}

func Lines(s []byte) iter.Seq[[]byte] {
    return func(yield func([]byte) bool) {
        s := s // 本地副本
        for len(s) > 0 {
            var line []byte
            if i := bytes.IndexByte(s, '\n'); i >= 0 {
                line, s = s[:i+1], s[i+1:]
            } else {
                line, s = s, nil
            }
            if !yield(line[:len(line):len(line)]) {
                return
            }
        }
        return
    }
}

输出:

1
2
3
4
5
6
7
foo
bar
baz

aoo
aar
aaz

为了使bytes.Lines产生一个纯迭代器,需要源切片的“深”副本(即一个将引用源切片后备数组克隆的切片)(playground);这种方法在我看来会违背迭代器的目的。

如果bytes.Lines不能轻易设计为产生“纯”迭代器,strings.Lines arguably 应该仍然返回一个“不纯”迭代器。总的来说,是否应该不惜一切代价追求迭代器的“纯度”,还是应该将与相关迭代器的一致性作为一个因素?陪审团仍在审议中。

结论 ¶

正如Austin Clements最近写道:

Go中的迭代器还很年轻[…] 围绕迭代器的约定尚未牢固建立,更不用说渗透到Go社区;仍有实验的空间和时间来消除术语上的问题。不过,如果您问我,越快越好。

致谢 ¶

感谢Valentin Deleplace激励我写这篇文章并审阅了其早期草稿。

附录:闭包复习 ¶

闭包是一个捕获外部词法作用域中声明的变量的函数。此类变量称为该函数的自由变量。在以下示例中,匿名函数f读取一个名为truth的变量,该变量在main函数中声明:

1
2
3
4
5
6
7
8
9
package main

import "fmt"

func main() {
    truth := true
    f := func() bool { return truth }
    fmt.Println(f())
}

输出:

1
true

到目前为止,没什么特别的。然而,闭包的力量在于它们更新自由变量的能力!这样的闭包本质上是有状态函数:维护一个状态(由它们的自由变量组成)从一个调用到下一个调用的函数。

在以下示例中(playground),工厂函数falseAfterN返回一个闭包,该闭包捕获一个名为truth的局部变量,在每次调用期间更新该变量,并根据自由变量的值改变其布尔结果:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
package main

import "fmt"

func falseAfterN(n uint) func() bool {
    var count uint
    return func() bool {
        if count < n {
            count++
            return true
        }
        return false
    }
}

func main() {
    const n = 4
    f := falseAfterN(n)
    for i := range 2 * n {
        fmt.Printf("%d %t\n", i, f())
    }
}

如果您编写Go已有一段时间,您可能使用过闭包,甚至可能没有意识到您正在使用。在下面显示的经典并发程序示例中(playground),匿名函数是一个闭包,因为它捕获(并作用于)变量iwg,这两个变量都在外部作用域中声明:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
package main

import (
    "fmt"
    "sync"
)

func main() {
    var wg sync.WaitGroup
    for i := range 10 {
        wg.Add(1)
        go func() {
            defer wg.Done()
            fmt.Println(i)
        }()
    }
    wg.Wait()
}
comments powered by Disqus
使用 Hugo 构建
主题 StackJimmy 设计