Go Wiki: Rangefunc 實驗

本頁面最初描述了一個實驗性的“range-over-function”語言特性。該特性已 新增到 Go 1.23。有一篇 部落格文章 對其進行了描述。

本頁面回答了一些關於此更改的常見問題。

range-over-function 是如何執行的簡單示例是什麼?

考慮這個用於反向迭代切片的函式

package slices

func Backward[E any](s []E) func(func(int, E) bool) {
    return func(yield func(int, E) bool) {
        for i := len(s)-1; i >= 0; i-- {
            if !yield(i, s[i]) {
                return
            }
        }
    }
}

它可以這樣呼叫

s := []string{"hello", "world"}
for i, x := range slices.Backward(s) {
    fmt.Println(i, x)
}

這個程式在編譯器內部會轉換為一個更像這樣的程式

slices.Backward(s)(func(i int, x string) bool {
    fmt.Println(i, x)
    return true
})

主體末尾的 return true 是迴圈主體末尾的隱式 continue。顯式的 continue 也會轉換為 return true。而 break 語句則會轉換為 return false。其他控制結構更復雜但仍然可能實現。

使用 range 函式的慣用 API 會是什麼樣子?

我們還不知道,這實際上是最終標準庫提案的一部分。我們採用的一個約定是,容器的 All 方法應該返回一個迭代器

func (t *Tree[V]) All() iter.Seq[V]

特定容器也可能提供其他迭代器方法。也許一個列表也會提供反向迭代

func (l *List[V]) All() iter.Seq[V]
func (l *List[V]) Backward() iter.Seq[V]

這些示例旨在表明庫可以以一種使這些函式可讀且易於理解的方式編寫。

更復雜的迴圈是如何實現的?

除了簡單的 break 和 continue,其他控制流(帶標籤的 break、continue、跳轉出迴圈、return)需要設定一個變數,以便迴圈外部的程式碼在迴圈中斷時可以查詢。例如,一個 return 可能會變成類似 doReturn = true; return false 的形式,其中 return falsebreak 的實現,然後當迴圈結束時,其他生成的程式碼會執行 if (doReturn) return

完整的重寫解釋在實現中的 cmd/compile/internal/rangefunc/rewrite.go 的頂部。

如果迭代器函式忽略 yield 返回 false 會怎樣?

對於 range-over-function 迴圈,為迴圈體生成的 yield 函式會檢查它是否在返回 false 後或迴圈本身退出後被呼叫。在任何一種情況下,它都會發生 panic。

為什麼 yield 函式最多隻能有兩個引數?

必須有一個限制;否則人們會在編譯器拒絕荒謬程式時提交錯誤報告。如果我們在一個真空環境中設計,也許我們會說它是無限的,但實現只需要允許最多 1000 個,或者類似的東西。

然而,我們並非在真空環境中設計:go/astgo/parser 存在,它們只能表示和解析最多兩個 range 值。我們顯然需要支援兩個值來模擬現有的 range 用法。如果支援三個或更多值很重要,我們可以更改這些包,但似乎沒有一個非常強烈的理由來支援三個或更多,因此最簡單的選擇是停在兩個並保持這些包不變。如果將來我們發現有強烈的理由需要更多,我們可以重新審視這個限制。

停止在兩個的另一個原因是,為了讓通用程式碼可以定義的函式簽名數量更有限。今天,iter 包可以輕鬆地為迭代器定義名稱

package iter

type Seq[V any] func(yield func(V) bool) bool
type Seq2[K, V any] func(yield func(K, V) bool) bool

迴圈體中的堆疊跟蹤會是什麼樣子?

迴圈體從迭代器函式呼叫,迭代器函式又從迴圈體所在的函式呼叫。堆疊跟蹤將顯示這種實際情況。這對於除錯迭代器、與偵錯程式中的堆疊跟蹤對齊等都很重要。

如果迴圈體延遲呼叫會發生什麼?或者如果迭代器函式延遲呼叫會發生什麼?

如果 range-over-func 迴圈體延遲呼叫,它會在包含迴圈的外部函式返回時執行,就像任何其他型別的 range 迴圈一樣。也就是說,defer 的語義不取決於被 range 的值型別。如果它們取決於,那將非常令人困惑。從設計角度來看,這種依賴關係似乎不可行。有些人建議禁止在 range-over-func 迴圈體中使用 defer,但這將是基於被 range 的值型別進行的語義更改,似乎同樣不可行。

迴圈體的 defer 執行時機與你不知道 range-over-func 發生了什麼特殊情況時看起來完全一樣。

如果迭代器函式延遲呼叫,該呼叫會在迭代器函式返回時執行。迭代器函式在耗盡值或被迴圈體告知停止時(因為迴圈體遇到了轉換為 return falsebreak 語句)返回。這正是大多數迭代器函式所期望的。例如,一個從檔案中返回行的迭代器可以開啟檔案,延遲關閉檔案,然後生成行。

迭代器函式的 defer 執行時機與你根本不知道該函式被用於 range 迴圈時看起來完全一樣。

這組答案可能意味著呼叫的執行時間順序與 defer 語句執行的順序不同,這裡 goroutine 類比很有用。可以將主函式執行在一個 goroutine 中,迭代器執行在另一個 goroutine 中,並透過通道傳送值。在這種情況下,defer 可能會以與建立時不同的順序執行,因為迭代器在外部函式之前返回,即使外部函式迴圈體在迭代器之後延遲呼叫。

如果迴圈體發生 panic 會怎樣?或者如果迭代器函式發生 panic 會怎樣?

延遲呼叫在 panic 時以與普通返回相同的順序執行:首先是迭代器延遲的呼叫,然後是迴圈體延遲並附加到外部函式的呼叫。如果普通返回和 panic 以不同的順序執行延遲呼叫,那將非常令人驚訝。

同樣,這裡有一個將迭代器放在其自己的 goroutine 中的類比。如果在迴圈開始之前主函式延遲了迭代器的清理,那麼迴圈體中的 panic 將執行延遲的清理呼叫,這將切換到迭代器,執行其延遲呼叫,然後切換回來繼續主 goroutine 上的 panic。這與普通迭代器中延遲呼叫的執行順序相同,即使沒有額外的 goroutine。

有關這些 defer 和 panic 語義的更詳細理由,請參閱 此評論

如果迭代器函式恢復迴圈體中的 panic 會發生什麼?

編譯器和執行時將檢測到這種情況並觸發 執行時 panic

range over function 能否與手寫的迴圈一樣高效?

原則上,是的。

再次考慮 slices.Backward 示例,它首先轉換為

slices.Backward(s)(func(i int, x string) bool {
    fmt.Println(i, x)
    return true
})

編譯器可以識別 slices.Backward 是微不足道的並將其內聯,生成

func(yield func(int, E) bool) bool {
    for i := len(s)-1; i >= 0; i-- {
        if !yield(i, s[i]) {
            return false
        }
    }
    return true
}(func(i int, x string) bool {
    fmt.Println(i, x)
    return true
})

然後它可以識別一個函式字面量被立即呼叫並將其內聯

{
    yield := func(i int, x string) bool {
        fmt.Println(i, x)
        return true
    }
    for i := len(s)-1; i >= 0; i-- {
        if !yield(i, s[i]) {
            goto End
        }
    }
End:
}

然後它可以去虛擬化 yield

{
    for i := len(s)-1; i >= 0; i-- {
        if !(func(i int, x string) bool {
            fmt.Println(i, x)
            return true
        })(i, s[i]) {
            goto End
        }
    }
End:
}

然後它可以內聯那個函式字面量

{
    for i := len(s)-1; i >= 0; i-- {
        var ret bool
        {
            i := i
            x := s[i]
            fmt.Println(i, x)
            ret = true
        }
        if !ret {
            goto End
        }
    }
End:
}

從那時起,SSA 後端可以看穿所有不必要的變數,並將該程式碼視為與以下相同

for i := len(s)-1; i >= 0; i-- {
    fmt.Println(i, s[i])
}

這看起來需要大量工作,但它只適用於簡單的主體和簡單的迭代器,低於內聯閾值,所以涉及的工作量很小。對於更復雜的主體或迭代器,函式呼叫的開銷微不足道。

在任何給定版本中,編譯器都可能實現或不實現這一系列最佳化。我們會在每個版本中不斷改進編譯器。

你能提供更多關於 range over functions 的動機嗎?

最近的動機是泛型的加入,我們期望這將導致自定義容器,例如有序對映,並且這些自定義容器能夠與 range 迴圈良好協作將是一件好事。

另一個同樣好的動機是為標準庫中許多收集一系列結果並將其作為一個切片返回的函式提供更好的解決方案。如果結果可以一次生成一個,那麼允許迭代它們的表示方式比返回整個切片具有更好的可伸縮性。我們沒有表示這種迭代的函式的標準簽名。在 range 中新增對函式的支援將既定義一個標準簽名,又提供一個真正的益處,鼓勵其使用。

例如,以下是標準庫中一些返回切片但可能更適合返回迭代器形式的函式

  • strings.Split
  • strings.Fields
  • 上述的 bytes 變體
  • regexp.Regexp.FindAll 及其相關函式

還有一些我們不願以切片形式提供的函式,可能應該以迭代器形式新增。例如,應該有一個 strings.Lines(text) 用於迭代文字中的行。

同樣,迭代 bufio.Reader 或 bufio.Scanner 中的行是可能的,但你必須知道模式,而且這兩種模式不同,並且對於每種型別都傾向於不同。建立表達迭代的標準方式將有助於統一目前存在的許多不同方法。

有關迭代器的更多動機,請參閱 #54245。有關 range over functions 的特定動機,請參閱 #56413

使用 range over functions 的 Go 程式是否可讀?

我們認為它們是可讀的。例如,使用 slices.Backward 而不是顯式的倒計數迴圈應該更容易理解,特別是對於那些不經常看到倒計數迴圈,並且必須仔細思考邊界條件以確保其正確性的開發人員。

確實,range over function 的可能性意味著當你看到 `range x` 時,如果你不知道 x 是什麼,你就無法確切地知道它將執行什麼程式碼或其效率如何。但是切片和對映迭代在執行程式碼和速度方面已經相當不同,更不用說通道了。普通函式呼叫也有這個問題——通常我們不知道被呼叫函式會做什麼——但我們仍然能找到編寫可讀、易懂程式碼的方法,甚至能建立對效能的直覺。

range over functions 也會發生同樣的事情。我們將隨著時間的推移建立有用的模式,人們會識別出最常見的迭代器並知道它們的功能。

為什麼語義不完全像迭代器函式在協程或 Goroutine 中執行一樣?

讓迭代器在單獨的協程或 Goroutine 中執行比將所有內容放在一個堆疊上更昂貴且更難除錯。既然我們將所有內容放在一個堆疊上,這個事實將改變某些可見的細節。我們上面看到了第一個:堆疊跟蹤顯示呼叫函式和迭代器函式交錯,以及顯示程式頁面中不存在的顯式 yield 函式。

將迭代器函式想象成在其自己的協程或 Goroutine 中執行作為類比或心智模型可能很有幫助,但在某些情況下,心智模型並不能給出最佳答案,因為它使用了兩個堆疊,而真實實現被定義為使用一個。


此內容是 Go Wiki 的一部分。