纯与不纯迭代器在Go中的对比
TL;DR ¶
Go现已标准化迭代器实现。迭代器功能强大,其本质是函数,因此可以是闭包。官方文档对迭代器的分类存在模糊性。将迭代器划分为“纯”和“不纯”两类更为合理。是否应尽可能设计纯迭代器仍存在争议。
Go中迭代器的出现 ¶
迭代器模式通过经典《设计模式》一书推广,其定义为:
提供一种方法顺序访问聚合对象中的各个元素,而又不暴露该对象的内部表示。
直到最近,Go中可通过for-range
循环迭代的数据结构仅限于数组(直接或通过指针)、切片、字符串、映射、通道和整数。然而,随着Go 1.18对参数化多态(即“泛型”)的支持,Go 1.23标准化了自定义迭代器的定义方式,在标准库中添加了iter
包,并在slices
和maps
包中引入了几个迭代器工厂(即返回迭代器的函数或方法)。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
几乎相同,您可能会认为它们实现上的差异不重要;事实上,如果您这样认为,您并不孤单。然而,fib1
和fib0
之间的差异远非仅仅是表面上的,实际上改变了它们结果的语义。要理解为什么,您需要熟悉闭包。
如有必要,请参阅本文的附录以复习闭包。
回想一下,在Go中,迭代器是函数;因此,迭代器可以是闭包!在fib0
中,变量a
和b
在结果迭代器内部声明。相反,在fib1
中,变量a
和b
是结果迭代器的自由变量,该迭代器也恰好改变了这些变量。因此,如果我们像之前对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())
}
|
输出:
到目前为止,没什么特别的。然而,闭包的力量在于它们更新自由变量的能力!这样的闭包本质上是有状态函数:维护一个状态(由它们的自由变量组成)从一个调用到下一个调用的函数。
在以下示例中(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),匿名函数是一个闭包,因为它捕获(并作用于)变量i
和wg
,这两个变量都在外部作用域中声明:
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()
}
|