Go Wiki:Rangefunc 實驗
此頁面原本說明一種實驗性的範圍遍歷函式的語言功能。此功能已 加入 Go 1.23。有一個 部落格文章說明此功能。
此頁面回答一些關於此變更的常見問題。
如何使用範圍遍歷函式運作的簡易範例是什麼?
考慮使用此函式反向迭代區塊:
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
。其他控制結構較為複雜,但仍然可行。
具有範圍函式的慣用 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]
這些範例表示程式庫可被寫入以使這些類型的函數具備可讀與易懂性
如何實作更複雜的迴圈?
除了基本中斷與繼續之外,其他控制流程(已標籤的中斷、繼續、離開迴圈的 goto、回傳)都需要設定一個變數,當迴圈中斷時迴圈外的程式碼可以參照。例如,return
可能會變成類似下列的內容:doReturn = true; return false
其中,return false
是中斷
實作,然後當迴圈完成時,其他產生程式碼將會執行if (doReturn) return
。
在實作說明中,cmd/compile/internal/rangefunc/rewrite.go的頂端說明了完整的改寫內容。
如果反覆運算器函數忽略傳回 false 的讓度怎麼辦?
對於 range-over-function 迴圈,針對主體產生之讓渡函數會檢查它是在回傳 false 或迴圈本身已離開之後被呼叫。任一情況下,它將會引發錯誤
為何讓渡函數僅限於最多兩個參數?
必須設限;否則當編譯器拒絕荒謬程式時,人們將會向編譯器提出錯誤報告。如果我們在一個真空環境中設計,也許我們會說它是沒有限制的,不過實作只需允許最多 1000 個,或類似的東西即可。
不過,我們並非在一個真空環境中設計:go/ast和go/parser存在,而且它們只能表示和解析最多兩個範圍值。我們顯然需要支援兩個值來模擬現有範圍用法。如果支援三個或更多值很重要,我們可以更改那些套件,但支援三或更多值看來並沒有特別強烈的理由,因此最簡單的選擇是停在兩個值且保持那些套件不變。如果我們在未來找到支援更多值的強烈理由,我們可以重新檢討那個限制。
停在兩個值的另一個理由是,讓通用程式碼定義的函數簽章數量更加有限。現在 (iter)[/pkg/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
迴圈主體中的堆疊追蹤長什麼樣子?
迴圈主體是由呼叫迴圈函式的迭代器函式呼叫的,而迴圈函式是由迴圈主體出現的函式呼叫的。堆疊追蹤將顯示實際狀況。這對除錯迭代器、對齊除錯器中的堆疊追蹤等作業相當重要。
如果迴圈主體延後呼叫,會發生什麼事?或者,如果迭代器函式延後呼叫,會發生什麼事?
如果範圍多過函式的迴圈主體延後呼叫,它會在外層包含迴圈的函式傳回時執行,就像其他各種範圍迴圈一樣。亦即,延後語意並不依賴所範圍的值種類。如果它們依賴的話,可能會令人很困惑。從設計角度來看,這種類型的依賴性似乎無法執行。有人建議在範圍多過函式的迴圈主體中禁止延後,但這將是一項語意變更,基準在所範圍的值種類,並且看似也無法執行。
迴圈主體的延後執行時機與在範圍多過函式中發生任何特別情況時完全相同。
如果迭代器函式延後呼叫,呼叫會在迭代器函式傳回時執行。當迭代器函式用盡值或迴圈主體命令其停止時(因為迴圈主體觸發已轉換為「return false」的「break」陳述式),迭代器函式會傳回。這正是您對大部分迭代器函數所需要的。舉例來說,從檔案傳回行的迭代器可以開啟檔案、延後關閉檔案,然後產生行。
迭代器函式的延後執行時機與在您完全不知道函式用在範圍迴圈中的情況完全相同。
這對回答的意思可能是「呼叫執行時間順序與延後陳述式執行不同」,coroutine 類比在這裡很有用。將執行中的主函式想成執行在一個 coroutine 中,而迭代器執行在另一個 coroutine 中,透過通道傳送值。在這種情況下,延後的執行順序可能會與它們的建立順序不同,因為迭代器在外部函式之前傳回,即使外部函式迴圈主體在迭代器之後延後呼叫。
如果迴圈主體產生例外,會發生什麼事?或者,如果迭代器函式產生例外,會發生什麼事?
延後呼叫在例外中執行的順序與在一般傳回中的順序相同:先執行由迭代器延後的呼叫,接著執行由迴圈主體延後並附加到外部函式的呼叫。如果一般傳回和例外以不同的順序執行延後呼叫,會令人非常驚訝。
在迭代器有自己的 coroutine 的情況下,仍然有一個類比。如果在迴圈啟動前,主函式延後清理迭代器,則迴圈主體中的例外將執行延後的清理呼叫,這會切換至迭代器,執行其延後呼叫,然後切換回主 coroutine 繼續產生例外。這與在一般迭代器中延後呼叫執行的順序相同,即使沒有多餘的 coroutine。
請參閱 此評論,瞭解這些 defer 和 panic 語意的更詳細依據。
如果迭代函式在迴圈主體中復原了一個 panic 會怎麼樣呢?
編譯器和執行時間會偵測此情況並觸發 執行時間 panic。
使用函式進行 range 能像手寫迴圈一樣執行嗎?
理論上可以。
再次考慮 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:
}
然後它可以內聯那個 func 文字
{
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 的動機嗎?
最近的動機是加入泛型,我們預期這將產生自訂容器,例如順序式對應,而對於這些自訂容器,能順利使用 range 迴圈會是一件好事。
另一個同樣好的動機是要為標準函式庫中的許多函式提供更好的答案,這些函式會收集結果序列並將其作為切片回傳。如果可以逐一產生結果,那麼允許對這些結果進行迭代的表示會比回傳整個切片更具擴充性。我們沒有代表此迭代的標準簽章。加入對範圍中函式的支援,將定義一個標準簽章,並提供一個實際利益,這將鼓勵其使用。
例如,以下是標準函式庫中的一些函式,它們會回傳切片,但可能值得使用返回迭代器的形式
- strings.Split
- strings.Fields
- 上面的 bytes 變體
- regexp.Regexp.FindAll 等類函式
此外,還有我們不願意以切片形式提供的函式,它們可能值得以迭代器形式加入。例如,應該有一個 strings.Lines(text) 在文本中迭代各行。
同樣地,能對比中行文中逐行迭代,但必須知道樣式,而這兩個樣式不同,並且這種情況通常對每種不同的類型都不同。建立一個標準化的迭代表達方式,有助於彙整現今已有的多種不同手法。
需要更多有關疊代的動態解說,請見 #54245;需要更多有關 range over functions 的動態解說,請見 #56413。
使用 range over functions 的 Go 程式碼是否可讀取?
我們認為可。舉例而言,使用 slices.Backward 來代替明示的倒數迴圈,應更容易理解,這對不是天天處理倒數迴圈,且必須仔細考慮邊界條件,以確保無誤的開發人員來說,尤其如此。
確實,range over function 的可能性意味著,當你看到 range x 時,如果不了解 x,就不知道會執行什麼樣的程式碼,也不知道程式碼的效率如何。但就執行的程式碼及其運作速度而言,切片和地圖疊代已經相當不同了,更不用提通道了。一般的函式呼叫也會有這個問題,因為我們通常不知道被呼叫的函式會做什麼,但我們還是找到撰寫可讀、易於理解的程式碼的方法,甚至建立起對效能的直覺。
range over functions 的情況肯定也是如此。我們會慢慢建立起有助益的模式,而人們也會認出最常見的疊代,並明白它們的功能。
為什麼語意並非完全像疊代函式在 coroutine 或 goroutine 中執行時那樣?
在個別的 coroutine 或 goroutine 中執行疊代要比將所有內容都放在一個堆疊中更花錢且較難偵錯。由於會將所有內容都放在一個堆疊中,這個行為將會改變某些可見的詳細資訊。上文我們看到第一個:堆疊追蹤顯示呼叫函式與疊代函式的交錯,並顯示程式中網頁上不存在的明示 yield 函式。
將執行中的疊代函式視為自身 coroutine 或 goroutine 的一個類比或心智模式可能會有幫助,但有些時候這種心智模式並無法提供最佳解,因為它使用兩個堆疊,而實際的實作定義只使用一個。
此內容是 Go Wiki 的一部分。