Golang - range 迭代器揭祕

簡介

我們正在使用 Go 語言編寫 Dolt[1] ,這是世界上第一個版本控制的 SQL 數據庫。像大多數大型 Go 代碼庫一樣,我們有很多需要迭代的集合類型。 Go 1.23 的新特性 [2] 中,你現在可以使用 range 關鍵字來迭代自定義集合類型。

這是如何工作的? 這是個好主意嗎? 讓我們深入探討一下。

如果你想運行本教程中的任何代碼,你需要 安裝 Go 1.23 發佈候選版 [3] ,或者在環境中運行 Go 1.22 並添加以下內容:

export GOEXPERIMENT = rangefunc

什麼是 range 迭代器?

我不知道你怎麼想,但我發現 實驗文檔 [4] 中的解釋和示例非常令人困惑。不過, 1.23 版本的發佈說明 [1] 對這個特性做了更好的總結 (儘管沒有提供示例)。

Range 迭代器是從 Go 1.23 開始可以與內置 range 關鍵字一起使用的函數類型。根據 發佈說明 [1] :

"for-range" 循環中的 "range" 子句現在接受以下類型的迭代器函數:

func(func() bool)
func(func(K) bool)
func(func(K, V) bool)

這些對應於你可以用 range 編寫的 3 種簡單循環 (忽略通道):

// New in Go 1.22, equivalent to a for loop counting 0..9
for i := range 10 { ... }
// Just the indexes (or just the keys for a map)
for i := range mySlice { ... }
// Indexes and values (or keys and values for a map)
for i, s := range mySlice { ... }

在 Go 1.23 之前, range 關鍵字只適用於切片或映射 (讓我們繼續忽略通道)。現在它也適用於這些特殊的函數類型。

這裏有一個簡單的例子:

func iter1(yield func(i int) bool) {
    for i := range 3 {
        if !yield(i) {
            return
        }
    }
}

iter1 是一個將運行三次的 range 迭代器。你可以這樣使用它:

func testFuncRange1() {
    for i := range iter1 {
        fmt.Println("iter1", i)
    }
}

當你運行這段代碼時,它會打印以下內容:

range 迭代器的類型

有三種類型的 range 迭代器函數,對應 range 循環的每種形式。所以它們接受 0、1 或 2 個參數。上面的例子接受 1 個參數。這裏有一個接受 0 個參數的例子:

func iter0(yield func() bool) {
    for range 3 {
        if !yield() {
            return
        }
    }
}
func testFuncRange0() {
    for range iter0 {
        fmt.Println("iter0")
    }
}

如果你運行這個,它會打印:

這裏有一個接受 2 個參數的例子:

func iter2(yield func(int, int) bool) {
    for i := range 3 {
        if !yield(i, i+1) {
            return
        }
    }
}
func testFuncRange2() {
    for i, e := range iter2 {
        fmt.Println("iter2", i, e)
    }
}

如果你運行這個,它會打印:

iter2 0 1
iter2 1 2
iter2 2 3

yield() 做什麼?

range 迭代器接受的 yield 函數是用來調用循環體的。當你用迭代器編寫 range 循環時,Go 編譯器會爲你將其轉換爲函數調用。所以這段代碼:

func testFuncRange2() {
    for i, e := range iter2 {
        fmt.Println("iter2", i, e)
    }
}

被編譯器隱式轉換爲類似這樣的代碼:

func testFuncRange2() {
    iter2(func(i int, e int) {
        fmt.Println("iter2", i, e)
        return true
    })
}

當你在 range 迭代器函數中調用 yield() 時,你就是在調用循環體。當你檢查 yield 的返回值時,你就是在檢查循環是否應該繼續 -- 可能有 breakreturn 語句。

如果你不檢查 yield() 的結果會怎樣? 那麼如果有 break 語句,你的程序就會發生 panic:

func brokenIter(yield func(i int) bool) {
    for i := range 3 {
        yield(i+1)
    }
}
func testBrokenIter() {
    for i := range brokenIter {
        fmt.Println("brokenIter", i)
        if i > 1 {
            break
        }
    }
}

當你運行這個時,你會得到這樣的 panic:

brokenIter 1
brokenIter 2
panic: runtime error: range function continued iteration after function for loop body returned false

如果你想不爲每個元素調用 yield(),或者多次調用它,或者用不同的參數調用它呢? 嗯,你可以這樣做,沒有什麼能阻止你,這會導致一些有趣的用例。

用例

那麼爲什麼要使用 range 迭代器呢? 基本上: 這樣你就可以使用 range 關鍵字來迭代不是 mapslice 的集合。如果你不想這樣做,就沒有理由使用它們。

話雖如此,你可以用它們做一些有趣的事情。讓我們看幾個例子。

對於所有這些例子,我們將定義一個基本的類型別名,這樣我們就可以在其上定義方法。由於 range 迭代器的主要用例是自定義集合類型,我們預計你主要會看到它們作爲在集合對象上調用的方法。

對於我們所有的示例, 我們將確保有條件地中斷迭代, 以測試我們的迭代器是否能正確處理這種情況。

使用 range 關鍵字與自定義集合

也許你只是想使用 range 關鍵字來迭代集合中的每個元素。這很簡單。

func (s Slice) All() func(yield func(i int) bool) {
    return func(yield func(i int) bool) {
        for i := range s {
            if !yield(s[i]) {
                return
            }
        }
    }
}

像這樣調用它:

func iterAll(slice Slice) {
    for i := range slice.All() {
        fmt.Println("all iter:", i)
        if i > 10 {
            break
        }
    }
}

是的, 對於我們的 Slice 來說這並不是嚴格必要的, 因爲它只是 []int 的別名。但它展示了對所有集合類型可能的操作。

過濾值

我有一個元素集合, 我想只迭代符合某些條件的元素。讓我們編寫一個 range 迭代器, 只迭代集合中的質數:

func (s Slice) Primes() func (yield func(i int) bool) {
    return func (yield func(i int) bool) {
        for i := range s {
            if big.NewInt(int64(s[i])).ProbablyPrime(0) {
                if !yield(s[i]) {
                    return
                }
            }
        }
    }
}

我可以這樣調用它:

func iterPrimes(slice Slice) {
    for i := range slice.Primes() {
        fmt.Println("prime number:", i)
        if i > 10 {
            break
        }
    }
}

我們可以通過創建一個接受謂詞函數的方法來推廣這種過濾方法:

func (s Slice) FilteredIter(predicate func(i int) bool) func (yield func(i int) bool) {
    return func (yield func(i int) bool) {
        for i := range s {
            if predicate(s[i]) {
                if !yield(s[i]) {
                    return
                }
            }
        }
    }
}

現在我可以用任何謂詞調用這個迭代器, 以選擇要迭代的集合中的元素。這裏有一個只迭代偶數的例子。

func iterEvens(slice Slice) {
    for i := range slice.FilteredIter(func(i int) bool {
        return i%2 == 0
    }) {
        fmt.Println("even number:", i)
        if i > 10 {
            break
        }
    }
}

迭代時處理錯誤

對於某些集合, 在迭代過程中你可能需要執行 I/O 或其他可能失敗的操作來產生下一個元素。Range 迭代器提供了一種非常簡潔的方式來表達這一點。只需這樣定義你的迭代器:

func (s Slice) ErrorIter() func(yield func(i int, e error) bool) {
    return func(yield func(i int, e error) bool) {
        for _, i := range s {
            // If there's an error getting the next element,
            // pass it into the yield function as the second parameter
            if !yield(i, nil) {
                return
            }
        }
    }
}

這使用了 range 循環的 2 參數變體, 對於切片或映射來說, 分別返回索引和元素或鍵和值。我們稍微濫用了這個約定, 使我們的迭代器返回元素 (如果能獲取到) 或錯誤。

func iterWithErr(slice Slice) error {
    for i, err := range slice.ErrorIter() {
        if err != nil {
            return err
        }
        fmt.Printf("error iter got value: %d\n", i)
        if i > 10 {
            break
        }
    }
    return nil
}

也許這讓你感到不舒服, 這是可以理解的。這可能不是 Go 作者對這些迭代器的預期用途, 因爲它打破了 range 中值的慣用含義。但因爲它既容易實現又有用, 我預測這種用法將成爲自定義集合類型的常見模式。

如果你的迭代可以返回錯誤, 但你不喜歡 2 參數版本, 你總是可以爲你的結果創建一個小的包裝結構體, 像這樣:

type IterResult struct {
    Result int
    Err error
}

處理哨兵錯誤

我們在幾個地方看到的一種迭代模式是傳統迭代器使用哨兵錯誤 (通常是 io.EOF) 來表示沒有更多的值。所以你有一個這樣的迭代循環:

for {
    val, err := iter.Next()
    if err == io.EOF {
        break
    } else if err != nil {
        return nil, err
    }
    // business logic here
}

如果我們不必通過檢查特定的哨兵錯誤來處理循環結束的控制流, 那不是很好嗎? 好吧, 使用 range 迭代器, 你可以轉換任何迭代器來實現這一點。

type iterEof struct {
    slice Slice
    i int
}
func (iter *iterEof) Next() (int, error) {
    defer func() {
        iter.i++
    }()
    if iter.i >= len(iter.slice) {
        return 0, io.EOF
    } else if rand.Float32() > .9 {
        return 0, fmt.Errorf("failed to fetch next element")
    }
    return iter.slice[iter.i], nil
}
func(s Slice) Iter() *iterEof {
    return &iterEof{slice: s}
}
func (s Slice) RangeCompatibleIter() func (yield func(int, error) bool) {
    iter := s.Iter()
    return func (yield func(i int, e error) bool) {
        for {
            next, err := iter.Next()
            if err == io.EOF {
                return
            }
            if !yield(next, err) {
                return 
            }
        }
    }
}

這個 range 迭代器包裝了底層的傳統迭代器, 併爲我們處理了 io.EOF 控制流。現在當我們使用它時, 我們得到的任何錯誤都是真正的錯誤, 而不是哨兵值。

func iterTraditionalWithRange(slice Slice) {
    for i, err := range slice.RangeCompatibleIter() {
        if err != nil {
            fmt.Printf("error: %s\n", err.Error())
        }
        fmt.Println("iter got value: ", i)
    }
}

這是一個小的生活質量改進, 但也許你會在意。你也可以使用相同的策略 (減去錯誤處理) 來將任何傳統迭代器對象轉換爲與 range 關鍵字兼容的對象。

Pull 和 Pull2

Go 作者還包含了兩個便利函數, PullPull2, 它們將 range 迭代器函數轉換爲傳統迭代器。這是爲那些使用 range 迭代器的庫工作, 但不想使用 range 關鍵字, 而更願意使用 Next() 方法進行迭代的人準備的。它們是這樣工作的。

func iterTraditionalWithRangeRoundTrip(slice Slice) {
    next, stop := iter.Pull2(slice.RangeCompatibleIter())
    defer stop()
    i := 0
    for {
        result, err, valid := next()
        if !valid {
            break
        }
        if i > 10 {
            break
        }
        i++
        if err != nil {
            fmt.Printf("error: %s\n", err.Error())
        } else {
            fmt.Println("iter got value: ", result)
        }
    }
}

所以 Pull2 函數返回一個迭代器函數 next。當你調用它時, 你會得到 range 迭代器的 2 個結果參數 (對於 Pull 函數只有 1 個結果), 以及一個布爾值告訴你迭代是否完成。你必須使用提供的 stop 函數來處理迭代器。

我們上面做的事情非常愚蠢: 我們有一個帶有 Next() 方法的傳統迭代器, 我們將其包裝成 range 迭代器, 然後使用 Pull2 函數將其轉回傳統迭代器。但這一切都能工作, 並且展示了這些技術可以以任意方式組合。有趣!

可讀性和約定考慮

Go"約定對於"range" 是,單變量版本返回索引或鍵,而雙變量版本返回這些加上它們的值。如果你只想要值,而不是鍵或索引,你可以這樣做:

for _ , val := range slice { ...  }

對於範圍迭代器,我們可以自由編寫打破這種約定的迭代器函數。因此在上面的一些例子中,我們有一個返回的單變量,它是值,而不是索引。

func iterPrimes(slice Slice) {
    for i := range slice.Primes() {
        fmt.Println("prime number:", i)
        if i > 10 {
            break
        }
    }
}

這是一件好事嗎?對於範圍迭代器來說,"range" 關鍵字的單變量版本可能與映射和切片完全不同,這是否令人困惑?好吧,這個特性仍然很新,使用它的約定還不真正存在,所以我會讓社區去爭論。然而,我要指出的是,在大多數情況下,當我使用 range 關鍵字與切片時,我只對值感興趣,而不是索引。對於某些集合類型,根本就沒有有意義的索引或鍵可以返回。我相信隨着時間的推移,我們會找到答案的。

結論

你可以在 這裏 [5] 找到上面的所有例子,還有一些我因篇幅原因刪掉的。

對 Go 範圍迭代器有疑問嗎?或者你對世界上第一個版本控制的 SQL 數據庫感到好奇? 加入我們的 Discord[6] 與我們的工程團隊和其他 Dolt 用戶交流。

參考鏈接

  1. Dolt: https://doltdb.com/
  2. Go 1.23 的新特性: https://tip.golang.org/doc/go1.23
  3. 安裝 Go 1.23 發佈候選版: https://groups.google.com/g/golang-announce/c/8ciOP5veCM/m/fg9BQpdFgA?pli=1
  4. 實驗文檔: https://tip.golang.org/wiki/RangefuncExperiment
  5. 這裏: https://gist.github.com/zachmu/350b47fbcbd0b0b08414ef4a58da8f80
  6. 加入我們的 Discord: https://discord.gg/gqr7K4VNKe
本文由 Readfog 進行 AMP 轉碼,版權歸原作者所有。
來源https://mp.weixin.qq.com/s/y_GJfl082Ny3diMjiBqiog