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
的返回值時,你就是在檢查循環是否應該繼續 -- 可能有 break
或 return
語句。
如果你不檢查 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
關鍵字來迭代不是 map
或 slice
的集合。如果你不想這樣做,就沒有理由使用它們。
話雖如此,你可以用它們做一些有趣的事情。讓我們看幾個例子。
對於所有這些例子,我們將定義一個基本的類型別名,這樣我們就可以在其上定義方法。由於 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 作者還包含了兩個便利函數, Pull
和 Pull2
, 它們將 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 用戶交流。
參考鏈接
- Dolt: https://doltdb.com/
- Go 1.23 的新特性: https://tip.golang.org/doc/go1.23
- 安裝 Go 1.23 發佈候選版: https://groups.google.com/g/golang-announce/c/8ciOP5veCM/m/fg9BQpdFgA?pli=1
- 實驗文檔: https://tip.golang.org/wiki/RangefuncExperiment
- 這裏: https://gist.github.com/zachmu/350b47fbcbd0b0b08414ef4a58da8f80
- 加入我們的 Discord: https://discord.gg/gqr7K4VNKe
本文由 Readfog 進行 AMP 轉碼,版權歸原作者所有。
來源:https://mp.weixin.qq.com/s/y_GJfl082Ny3diMjiBqiog