迭代器
在 Go 中,用於迭代特定數據結構的關鍵字為for range,之前的章節中已經介紹過它的一些應用,它僅能作用於語言內置的幾個數據結構
- 數組
- 切片
- 字符串
- map
- chan
- 整型值
這樣的話使用起來非常的不靈活,沒有拓展性,對於自定義類型幾乎不支持,不過好在 go1.23 版本更新以後,for range關鍵字支持了range over func,這樣一來自定義迭代器也就成為了可能。
認識
下面通過一個例子來初步認識迭代器,不知道各位還是否記得在函數小節中講解的閉包求解斐波那契數列的例子,它的實現代碼如下
func Fibonacci(n int) func() (int, bool) {
a, b, c := 1, 1, 2
i := 0
return func() (int, bool) {
if i >= n {
return 0, false
} else if i < 2 {
f := i
i++
return f, true
}
a, b = b, c
c = a + b
i++
return a, true
}
}我們可以把它改造成迭代器,如下所示,可以看到代碼量要減少了一些
func Fibonacci(n int) func(yield func(int) bool) {
a, b, c := 0, 1, 1
return func(yield func(int) bool) {
for range n {
if !yield(a) {
return
}
a, b = b, c
c = a + b
}
}
}Go 的迭代器是range over func風格,我們可以直接用for range關鍵字來進行使用,使用起來也要比原來更方便
func main() {
n := 8
for f := range Fibonacci(n) {
fmt.Println(f)
}
}輸出如下
0
1
1
2
3
5
8
13如上所示,迭代器就是一個閉包函數,它接受一個回調函數作為參數,你甚至可以在裡面看到yield這種字眼,寫過 python 的人應該都很熟悉,它與 python 中的生成器很類似。Go 的迭代器並沒有新增任何關鍵字,語法特性,在上述示例中yield也只是一個回調函數,它並非關鍵字,官方取這個名字是為了方便理解。
推送式迭代器
關於迭代器的定義,我們可以在iter庫中找到如下解釋
An iterator is a function that passes successive elements of a sequence to a callback function, conventionally named yield.
迭代器是一個函數,它將序列中的元素逐個傳遞給回調函數,通常稱為 yield。
我們從中可以明確的一點,迭代器就是一個函數,它接受一個回調函數作為參數,在迭代過程中會將序列中的元素逐個傳遞給回調函數yield。在之前示例中我們是按照下面的方式使用迭代器的
for f := range Fibonacci(n) {
fmt.Println(f)
}根據官方定義,上面迭代器Backward的例子使用就等同於下面這段代碼
Fibonacci(n)(func(f int) bool {
fmt.Println(f)
return true
})循環體的 body 就是迭代器的回調函數yiled,當函數返回true迭代器會繼續迭代,否則就會停止。
此外,iter標准庫中也定義了迭代器的類型iter.Seq,它的類型就是函數。
type Seq[V any] func(yield func(V) bool)
type Seq2[K, V any] func(yield func(K, V) bool)iter.Seq的回調函數只接受一個參數,那麼在迭代時for range僅有一個返回值,如下
for v := range iter {
// body
}iter.Seq2的回調函數接受兩個參數,那麼在迭代時for range就有兩個返回值,如下
for k, v := range iter {
// body
}雖然標准庫中沒有定義 0 個參數的 Seq,但這也是完全允許的,它相當於
func(yield func() bool)使用起來如下所示
for range iter {
// body
}回調函數的參數數量只能是 0 至 2 個,多了會無法通過編譯。
簡而言之,for range中的循環體就是迭代器中的yield回調函數,for range返回幾個值,相應的yeild函數就有幾個入參,每一輪迭代時,迭代器都會調用yield函數,也就是執行循環體中的代碼,主動將序列中的元素傳遞給yield函數,這種主動傳遞元素的迭代器我們一般稱之為推送式迭代器(pushing iterator),比較典型的例子就是其他語言中的foreach,比如 js
let arr = [1, 2, 3, 4, 5];
arr
.filter((e) => e % 2 === 0)
.forEach((e) => {
console.log(e);
});在 Go 中的表現形式就是由range返回被迭代的元素。
for index, value := range iterator() {
fmt.Println(index, value)
}在某些語言(比如 Java)中它還有另一個叫法:數據流處理。
既然循環體中的代碼是作為回調函數傳入迭代器的,而且它很可能是一個閉包函數,Go 就需要讓一個閉包函數在執行defer,return,break,goto等關鍵字時表現的像一個普通循環體代碼段一樣,思考下面幾種情況。
比如說在迭代器循環中返回,那麼在yield回調函數中要怎麼去處理這個 return 呢?
for index, value := range iterator() {
if value > 10 {
return
}
fmt.Println(index, value)
}不可能直接在回調函數中 return,這麼做只會讓迭代停止而已,達不到返回的效果
iterator()(func(index int, value int) bool {
if value > 10 {
return false
}
fmt.Println(index, value)
})再比如說在迭代器循環中使用defer
for index, value := range iterator() {
defer fmt.Println(index, value)
}也不能直接在回調函數中使用defer,因為這麼做的話在回調函數結束時就會直接延遲調用了
iterator()(func(index int, value int) bool {
defer fmt.Println(index, value)
})像其他的幾個關鍵字break,continue,goto也是類似的,好在這些情況 Go 已經幫我們處理好了,我們只需使用即可,可以暫時不需要關心這些,如果感興趣可以自行瀏覽rangefunc/rewrite.go中的源代碼。
拉取式迭代器
推送式迭代器(pushing iterator)是由迭代器來控制迭代的邏輯,用戶被動獲取元素,相反的拉取式迭代器(pulling iterator)就是由用戶來控制迭代邏輯,主動的去獲取序列元素。一般而言,拉取式迭代器都會有特定的函數如next(),stop()來控制迭代的開始或結束,它可以是一個閉包或者結構體。
scanner := bufio.NewScanner(file)
for scanner.Scan() {
line, err := scanner.Text(), scanner.Err()
if err != nil {
fmt.Println(err)
return
}
fmt.Println(line)
}如上所示,Scanner 通過方法Text()來獲取文件中的下一行文本,通過方法Scan()來表示迭代是否結束,這也是拉取式迭代器的一種模式。Scanner 采用結構體來記錄狀態,而在iter庫定義的拉取式迭代器采用閉包來記錄狀態,我們通過iter.Pull或iter.Pull2函數就可以將一個標准的推送式迭代器轉換為拉取式迭代器,iter.Pull與iter.Pull2的區別就是後者的返回值有兩個,簽名如下
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())它們都接受一個迭代器作為參數,然後會返回兩個函數next()和stop(),用於控制迭代的繼續和停止。
func next() (V, bool)
func stop()next會返回被迭代的元素,和一個表示當前值是否有效的布爾值,當迭代結束時next函數會返回元素的零值和false。stop函數會結束迭代過程,當調用者不再使用迭代器後,就必須使用stop函數來結束迭代。順帶一提,在多個協程調用同一個迭代器的next函數是錯誤的做法,因為它並非並發安全。
下面通過一個例子來演示,它的功能就是把之前的斐波那契迭代器改造成拉取式迭代器,如下
func main() {
n := 10
next, stop := iter.Pull(Fibonacci(n))
defer stop()
for {
fibn, ok := next()
if !ok {
break
}
fmt.Println(fibn)
}
}輸出
0
1
1
2
3
5
8
13
21
34這樣一來我們就可以通過next和stop函數來手動控制迭代的邏輯了。你或許可能會覺得這樣做多此一舉,如果要這樣做的話那為什麼不直接用最初的閉包版本就好了,一樣可以自己控制迭代,閉包的用法是這樣的
func main() {
fib := Fibonacci(10)
for {
n, ok := fib()
if !ok {
break
}
fmt.Prinlnt(n)
}
}轉換過程:閉包 → 迭代器 → 拉取式迭代器,閉包與拉取式迭代器的用法都大差不差,它們的思想都是一樣的,後者還會因為各種各樣的處理導致性能上的拖累。老實說這麼做確實多此一舉,它的應用場景確實不是很多,不過iter.pull是為了iter.Seq而存在的,也就是為了將推送式迭代器轉換成拉取式迭代器的而存在的,如果你僅僅只是想要一個拉取式迭代器,還專門為此去實現一個推送式迭代器來進行轉換,要這樣做的話不妨考慮下自己實現的復雜度和性能,就像這個斐波那契數列的例子一樣,繞了一圈又回到原點,唯一的好處可能就是符合官方的迭代器規范。
錯誤處理
在迭代時發生了錯誤怎麼辦?我們可以將其傳遞給yield函數讓for range返回,讓調用者來進行處理,就像下面這個行迭代器的例子一樣
func ScanLines(reader io.Reader) iter.Seq2[string, error] {
scanner := bufio.NewScanner(reader)
return func(yield func(string, error) bool) {
for scanner.Scan() {
if !yield(scanner.Text(), scanner.Err()) {
return
}
}
}
}TIP
值得注意的是,ScanLines迭代器是一次性使用的,文件關閉以後就不能再次使用了。
可以看到它的第二個返回值是error類型,使用起來如下
for line, err := range ScanLines(file) {
if err != nil {
fmt.Println(err)
break
}
fmt.Println(line)
}這樣處理起來就跟普通的錯誤處理沒什麼區別,拉取式迭代器也是同理
next, stop := iter.Pull2(ScanLines(file))
defer stop()
for {
line, err, ok := next()
if err != nil {
fmt.Println(err)
break
} else if !ok {
break
}
fmt.Println(line)
}如果發生了 panic,就像平常一樣使用recovery即可。
defer func() {
if err := recover(); err != nil {
fmt.Println("panic:", err)
os.Exit(1)
}
}()
for line, err := range ScanLines(file) {
if err != nil {
fmt.Println(err)
break
}
fmt.Println(line)
}拉取式迭代器依然同理,這裡就不演示了。
標准庫
有很多標准庫也支持了迭代器,最常用的就是slices和maps標准庫,下面介紹幾個比較實用的功能。
slices.All
func All[Slice ~[]E, E any](s Slice) iter.Seq2[int, E]slices.All會將切片轉換成一個切片迭代器
func main() {
s := []int{1, 2, 3, 4, 5}
for i, n := range slices.All(s) {
fmt.Println(i, n)
}
}輸出
0 1
1 2
2 3
3 4
4 5slices.Values
func Values[Slice ~[]E, E any](s Slice) iter.Seq[E]slices.Values會將切片轉換成一個切片迭代器,但是不帶索引
func main() {
s := []int{1, 2, 3, 4, 5}
for n := range slices.Values(s) {
fmt.Println(n)
}
}輸出
1
2
3
4
5slices.Chunk
func Chunk[Slice ~[]E, E any](s Slice, n int) iter.Seq[Slice]slices.Chunk函數會返回一個迭代器,該迭代器會以 n 個元素為切片推送給調用者
func main() {
s := []int{1, 2, 3, 4, 5}
for chunk := range slices.Chunk(s, 2) {
fmt.Println(chunk)
}
}輸出
[1 2]
[3 4]
[5]slices.Collect
func Collect[E any](seq iter.Seq[E]) []Eslices.Collect函數會將切片迭代器收集成一個切片
func main() {
s := []int{1, 2, 3, 4, 5}
s2 := slices.Collect(slices.Values(s))
fmt.Println(s2)
}輸出
[1 2 3 4 5]maps.Keys
func Keys[Map ~map[K]V, K comparable, V any](m Map) iter.Seq[K]maps.Keys會返回一個迭代 map 所有鍵的迭代器,配合slices.Collect可以直接收集成一個切片。
func main() {
m := map[string]int{"one": 1, "two": 2, "three": 3}
keys := slices.Collect(maps.Keys(m))
fmt.Println(keys)
}輸出
[three one two]由於 map 是無序的,所以輸出也不固定
maps.Values
func Values[Map ~map[K]V, K comparable, V any](m Map) iter.Seq[V]maps.Values會返回一個迭代 map 所有值的迭代器,配合slices.Collect可以直接收集成一個切片。
func main() {
m := map[string]int{"one": 1, "two": 2, "three": 3}
keys := slices.Collect(maps.Values(m))
fmt.Println(keys)
}輸出
[3 1 2]由於 map 是無序的,所以輸出也不固定
maps.All
func All[Map ~map[K]V, K comparable, V any](m Map) iter.Seq2[K, V]maps.All可以將一個 map 轉換為成一個 map 迭代器
func main() {
m := map[string]int{"one": 1, "two": 2, "three": 3}
for k, v := range maps.All(m) {
fmt.Println(k, v)
}
}一般不會這麼直接用,都是拿來配合其他數據流處理函數的。
maps.Collect
func Collect[K comparable, V any](seq iter.Seq2[K, V]) map[K]Vmaps.Collect可以將一個 map 迭代器收集成一個 map
func main() {
m := map[string]int{"one": 1, "two": 2, "three": 3}
m2 := maps.Collect(maps.All(m))
fmt.Println(m2)
}collect 函數一般作為數據流處理的終結函數來使用。
鏈式調用
通過上面標准庫提供的函數,我們可以將其組合來處理數據流,比如對數據流進行排序,如下
sortedSlices := slices.Sorted(slices.Values(s))go 的迭代器采用的是閉包,只能像這樣嵌套函數調用,本身沒法鏈式調用,調用鏈長了以後可讀性會很差,但我們可以自己通過結構體來記錄迭代器,就能夠實現鏈式調用。
demo
一個簡單的鏈式調用 demo 如下所示,它包含了Filter,Map,Find,Some等常用的功能。
package iterx
import (
"iter"
"slices"
)
type SliceSeq[E any] struct {
seq iter.Seq2[int, E]
}
func (s SliceSeq[E]) All() iter.Seq2[int, E] {
return s.seq
}
func (s SliceSeq[E]) Filter(filter func(int, E) bool) SliceSeq[E] {
return SliceSeq[E]{
seq: func(yield func(int, E) bool) {
// 重新組織索引
i := 0
for k, v := range s.seq {
if filter(k, v) {
if !yield(i, v) {
return
}
i++
}
}
},
}
}
func (s SliceSeq[E]) Map(mapFn func(E) E) SliceSeq[E] {
return SliceSeq[E]{
seq: func(yield func(int, E) bool) {
for k, v := range s.seq {
if !yield(k, mapFn(v)) {
return
}
}
},
}
}
func (s SliceSeq[E]) Fill(fill E) SliceSeq[E] {
return SliceSeq[E]{
seq: func(yield func(int, E) bool) {
for i, _ := range s.seq {
if !yield(i, fill) {
return
}
}
},
}
}
func (s SliceSeq[E]) Find(equal func(int, E) bool) (_ E) {
for i, v := range s.seq {
if equal(i, v) {
return v
}
}
return
}
func (s SliceSeq[E]) Some(match func(int, E) bool) bool {
for i, v := range s.seq {
if match(i, v) {
return true
}
}
return false
}
func (s SliceSeq[E]) Every(match func(int, E) bool) bool {
for i, v := range s.seq {
if !match(i, v) {
return false
}
}
return true
}
func (s SliceSeq[E]) Collect() []E {
var res []E
for _, v := range s.seq {
res = append(res, v)
}
return res
}
func (s SliceSeq[E]) Sort(cmp func(x, y E) int) []E {
collect := s.Collect()
slices.SortFunc(collect, cmp)
return collect
}
func (s SliceSeq[E]) SortStable(cmp func(x, y E) int) []E {
collect := s.Collect()
slices.SortStableFunc(collect, cmp)
return collect
}
func Slice[S ~[]E, E any](s S) SliceSeq[E] {
return SliceSeq[E]{seq: slices.All(s)}
}然後我們就可以通過鏈式調用來處理了,看幾個使用案例。
處理元素值
func main() {
s := []string{"apple", "banana", "cherry"}
all := iterx.Slice(s).Map(strings.ToUpper).All()
for i, v := range all {
fmt.Printf("index: %d, value: %s\n", i, v)
}
}輸出
index: 0, value: APPLE
index: 1, value: BANANA
index: 2, value: CHERRY尋找某一個指定值
func main() {
s := []int{1, 2, 3, 4, 5}
result := iterx.Slice(s).Find(func(i int, e int) bool {
return e == 3
})
fmt.Println(result)
}輸出
3填充切片
func main() {
s := []int{1, 2, 3, 4, 5}
result := iterx.Slice(s).Fill(6).Collect()
fmt.Println(result)
}輸出
[6 6 6 6 6]過濾元素
func main() {
s := []int{1, 2, 3, 4, 5}
filter := iterx.Slice(s).Filter(func(i int, e int) bool {
return e%2 == 0
}).All()
for i, v := range filter {
fmt.Printf("Index: %d, Value: %d\n", i, v)
}
}輸出
Index: 0, Value: 2
Index: 1, Value: 4比較可惜的是 Go 目前還不支持簡寫匿名函數,就像 js,rust,java 中的箭頭函數一樣,否則鏈式調用還可以更加簡潔和優雅一些。
性能
因為 Go 對迭代器做了許多的處理,它的性能肯定是不如原生 for range 循環的,我們拿最簡單的一個切片遍歷來測試下它們的性能區別,分為下面幾種
- 原生 for 循環
- 推送式迭代器
- 拉取式迭代器
測試代碼如下,測試切片長度為 1000。
package main
import (
"iter"
"slices"
"testing"
)
var s []int
const n = 10000
func init() {
for i := range n {
s = append(s, i)
}
}
func testNaiveFor(s []int) {
for i, n := range s {
_ = i
_ = n
}
}
func testPushing(s []int) {
for i, n := range slices.All(s) {
_ = i
_ = n
}
}
func testPulling(s []int) {
next, stop := iter.Pull2(slices.All(s))
for {
i, n, ok := next()
if !ok {
stop()
return
}
_ = i
_ = n
}
}
func BenchmarkNaive_10000(b *testing.B) {
for range b.N {
testNaiveFor(s)
}
}
func BenchmarkPushing_10000(b *testing.B) {
for range b.N {
testPushing(s)
}
}
func BenchmarkPulling_10000(b *testing.B) {
for range b.N {
testPulling(s)
}
}測試結果如下
goos: windows
goarch: amd64
pkg: golearn
cpu: 11th Gen Intel(R) Core(TM) i7-11800H @ 2.30GHz
BenchmarkNaive_10000
BenchmarkNaive_10000-16 492658 2398 ns/op 0 B/op 0 allocs/op
BenchmarkPushing_10000
BenchmarkPushing_10000-16 315889 3707 ns/op 0 B/op 0 allocs/op
BenchmarkPulling_10000
BenchmarkPulling_10000-16 2016 574509 ns/op 440 B/op 14 allocs/op
PASS
ok golearn 4.029s我們通過結果可以看到推送式迭代器與原生的for range循環相差不是特別大,但拉取式迭代器要比前面兩個慢了幾乎兩個數量級,在使用的時候各位可以根據自己的實際情況來進行考慮。
小結
與泛型的情況相似,Go 的迭代器同樣飽受爭議,部分人的觀點是迭代器引入了過多的復雜度,違背了 Go 的簡潔哲學,像這種迭代器的閉包代碼多了以後,調試起來怕是都有點困難,閱讀起來就就更加惱火了。
你可以在很多地方看到關於迭代器的激烈討論
- Why People are Angry over Go 1.23 Iterators,一個國外老哥關於迭代器的評價,值得一看
- golang/go · Discussion #56413,rsc 發起的社區討論,有很多人發表了自己的觀點
理性的看待 Go 迭代器,它確實使得編寫代碼更加方便,尤其是在處理切片類型的時候,但同時也會引入了些許復雜度,迭代器部分的代碼可讀性會降低,不過總的來說,我認為這確實是一個實用的特性。
