Go 部落格
函式型別的 Range 迴圈
引言
這是我在 GopherCon 2024 演講的部落格文章版本。
函式型別的 Range 迴圈是 Go 1.23 版本中的一項新語言特性。這篇部落格文章將解釋我們為什麼要新增這項新特性,它到底是什麼,以及如何使用它。
為什麼?
自 Go 1.18 以來,我們已經能夠在 Go 中編寫新的泛型容器型別。例如,我們來考慮這個非常簡單的 `Set` 型別,它是一個基於對映實現的泛型型別。
// Set holds a set of elements.
type Set[E comparable] struct {
m map[E]struct{}
}
// New returns a new [Set].
func New[E comparable]() *Set[E] {
return &Set[E]{m: make(map[E]struct{})}
}
一個 Set 型別自然有新增元素和檢查元素是否存在的方法。這裡的細節並不重要。
// Add adds an element to a set.
func (s *Set[E]) Add(v E) {
s.m[v] = struct{}{}
}
// Contains reports whether an element is in a set.
func (s *Set[E]) Contains(v E) bool {
_, ok := s.m[v]
return ok
}
除此之外,我們還需要一個函式來返回兩個 Set 的並集。
// Union returns the union of two sets.
func Union[E comparable](s1, s2 *Set[E]) *Set[E] {
r := New[E]()
// Note for/range over internal Set field m.
// We are looping over the maps in s1 and s2.
for v := range s1.m {
r.Add(v)
}
for v := range s2.m {
r.Add(v)
}
return r
}
我們來看一下這個 `Union` 函式的實現。為了計算兩個 Set 的並集,我們需要一種方法來獲取每個 Set 中的所有元素。在這段程式碼中,我們使用 for/range 語句遍歷 Set 型別的一個未匯出欄位。這隻有在 `Union` 函式定義在 Set 包中時才有效。
但是,人們可能有很多理由需要遍歷 Set 中的所有元素。這個 Set 包必須為其使用者提供一種方法來做到這一點。
那該如何實現呢?
推送 Set 元素
一種方法是提供一個 `Set` 方法,該方法接受一個函式,並用 Set 中的每個元素呼叫該函式。我們稱之為 `Push`,因為 `Set` 將每個值推送到該函式。如果該函式返回 false,我們就停止呼叫它。
func (s *Set[E]) Push(f func(E) bool) {
for v := range s.m {
if !f(v) {
return
}
}
}
在 Go 標準庫中,我們看到這種通用模式用於 `sync.Map.Range` 方法、`flag.Visit` 函式和 `filepath.Walk` 函式等情況。這是一種通用模式,而不是精確模式;碰巧的是,這三個示例的工作方式都不完全相同。
這是使用 `Push` 方法列印 Set 中所有元素的樣子:你用一個函式呼叫 `Push`,該函式對元素執行你想要的操作。
func PrintAllElementsPush[E comparable](s *Set[E]) {
s.Push(func(v E) bool {
fmt.Println(v)
return true
})
}
拉取 Set 元素
另一種遍歷 `Set` 元素的方法是返回一個函式。每次呼叫該函式時,它都會從 `Set` 返回一個值,以及一個報告該值是否有效的布林值。當迴圈遍歷所有元素後,布林結果將為 false。在這種情況下,我們還需要一個在不再需要值時可以呼叫的停止函式。
此實現使用一對通道,一個用於 Set 中的值,另一個用於停止返回值。我們使用 goroutine 在通道上傳送值。`next` 函式透過從元素通道讀取來返回 Set 中的一個元素,而 `stop` 函式透過關閉停止通道來告訴 goroutine 退出。我們需要 `stop` 函式來確保在不再需要值時 goroutine 退出。
// Pull returns a next function that returns each
// element of s with a bool for whether the value
// is valid. The stop function should be called
// when finished calling the next function.
func (s *Set[E]) Pull() (func() (E, bool), func()) {
ch := make(chan E)
stopCh := make(chan bool)
go func() {
defer close(ch)
for v := range s.m {
select {
case ch <- v:
case <-stopCh:
return
}
}
}()
next := func() (E, bool) {
v, ok := <-ch
return v, ok
}
stop := func() {
close(stopCh)
}
return next, stop
}
標準庫中沒有任何東西完全以這種方式工作。`runtime.CallersFrames` 和 `reflect.Value.MapRange` 都類似,儘管它們返回帶有方法的值,而不是直接返回函式。
這是使用 `Pull` 方法列印 `Set` 中所有元素的樣子。你呼叫 `Pull` 來獲取一個函式,然後你在 for 迴圈中重複呼叫該函式。
func PrintAllElementsPull[E comparable](s *Set[E]) {
next, stop := s.Pull()
defer stop()
for v, ok := next(); ok; v, ok = next() {
fmt.Println(v)
}
}
標準化方法
我們現在已經看到了兩種遍歷 Set 中所有元素的不同方法。不同的 Go 包使用這些方法和其他幾種方法。這意味著當你開始使用一個新的 Go 容器包時,你可能需要學習一種新的迴圈機制。這也意味著我們無法編寫一個適用於幾種不同型別容器的函式,因為容器型別將以不同的方式處理迴圈。
我們希望透過開發遍歷容器的標準方法來改進 Go 生態系統。
迭代器
當然,這是許多程式語言中都會出現的問題。
流行於 1994 年首次出版的 《設計模式》一書將此描述為迭代器模式。你使用迭代器“提供一種按順序訪問聚合物件的元素而不暴露其底層表示的方法。”引文中所謂的聚合物件就是我一直稱之為容器的東西。聚合物件或容器只是一個持有其他值的實體,就像我們一直在討論的 `Set` 型別一樣。
和程式設計中的許多想法一樣,迭代器可以追溯到 Barbara Liskov 在 20 世紀 70 年代開發的 CLU 語言。
如今,許多流行語言都以某種方式提供了迭代器,其中包括 C++、Java、Javascript、Python 和 Rust。
然而,Go 在 1.23 版本之前沒有。
For/range
眾所周知,Go 擁有內建的容器型別:切片、陣列和對映。它還有一種不暴露底層表示即可訪問這些值元素的方法:for/range 語句。for/range 語句適用於 Go 的內建容器型別(以及字串、通道,從 Go 1.22 開始,還有 int)。
for/range 語句是迭代,但它不是當今流行語言中出現的迭代器。儘管如此,如果能夠使用 for/range 迭代使用者定義的容器(如 `Set` 型別),那將是非常好的。
然而,Go 在 1.23 版本之前不支援此功能。
此版本中的改進
對於 Go 1.23,我們決定支援對使用者定義容器型別的 for/range 迴圈以及迭代器的標準化形式。
我們擴充套件了 for/range 語句以支援對函式型別的迴圈。我們將在下面看到這如何有助於遍歷使用者定義的容器。
我們還添加了標準庫型別和函式來支援將函式型別用作迭代器。迭代器的標準定義使我們能夠編寫與不同容器型別平穩協作的函式。
函式型別的 Range 迴圈(部分)
改進後的 for/range 語句不支援任意函式型別。從 Go 1.23 開始,它現在支援對接受單個引數的函式進行迴圈。該單個引數本身必須是一個接受零到兩個引數並返回布林值的函式;按照慣例,我們稱之為 yield 函式。
func(yield func() bool)
func(yield func(V) bool)
func(yield func(K, V) bool)
當我們在 Go 中談論迭代器時,我們指的是具有這三種類型之一的函式。正如我們將在下面討論的,標準庫中還有另一種迭代器:拉取迭代器。當需要區分標準迭代器和拉取迭代器時,我們將標準迭代器稱為推送迭代器。這是因為,正如我們將看到的,它們透過呼叫 yield 函式來推出一系列值。
標準(推送)迭代器
為了使迭代器更易於使用,新的標準庫包 `iter` 定義了兩種型別:`Seq` 和 `Seq2`。這些是迭代器函式型別的名稱,即可用於 for/range 語句的型別。名稱 `Seq` 是 sequence 的縮寫,因為迭代器迴圈遍歷一系列值。
package iter
type Seq[V any] func(yield func(V) bool)
type Seq2[K, V any] func(yield func(K, V) bool)
// for now, no Seq0
`Seq` 和 `Seq2` 之間的區別僅在於 `Seq2` 是一個對序列,例如對映中的鍵和值。在這篇文章中,為簡化起見,我們將重點關注 `Seq`,但我們所說的大部分內容也適用於 `Seq2`。
用例子解釋迭代器的工作原理最簡單。這裡 `Set` 方法 `All` 返回一個函式。`All` 的返回型別是 `iter.Seq[E]`,所以我們知道它返回一個迭代器。
// All is an iterator over the elements of s.
func (s *Set[E]) All() iter.Seq[E] {
return func(yield func(E) bool) {
for v := range s.m {
if !yield(v) {
return
}
}
}
}
迭代器函式本身接受另一個函式(yield 函式)作為引數。迭代器用 Set 中的每個值呼叫 yield 函式。在這種情況下,迭代器,即 `Set.All` 返回的函式,與我們之前看到的 `Set.Push` 函式非常相似。
這展示了迭代器的工作方式:對於某些值序列,它們用序列中的每個值呼叫 yield 函式。如果 yield 函式返回 false,則不再需要值,迭代器可以返回,並執行任何可能需要的清理。如果 yield 函式從不返回 false,迭代器可以在用序列中的所有值呼叫 yield 後返回。
這就是它們的工作方式,但我們承認,當你第一次看到它們時,你的第一反應可能是“這裡有很多函式在飛來飛去”。你沒有說錯。我們關注兩件事。
首先,一旦你越過此函式程式碼的第一行,迭代器的實際實現就非常簡單:用 Set 的每個元素呼叫 yield,如果 yield 返回 false 則停止。
for v := range s.m {
if !yield(v) {
return
}
}
其次,使用它真的很容易。你呼叫 `s.All` 獲取一個迭代器,然後使用 for/range 遍歷 `s` 中的所有元素。for/range 語句支援任何迭代器,這顯示了它有多麼容易使用。
func PrintAllElements[E comparable](s *Set[E]) {
for v := range s.All() {
fmt.Println(v)
}
}
在這種程式碼中,`s.All` 是一個返回函式的的方法。我們呼叫 `s.All`,然後使用 for/range 遍歷它返回的函式。在這種情況下,我們可以讓 `Set.All` 本身就是一個迭代器函式,而不是讓它返回一個迭代器函式。然而,在某些情況下這不起作用,例如如果返回迭代器的函式需要接受一個引數,或者需要做一些設定工作。按照慣例,我們鼓勵所有容器型別提供一個返回迭代器的 `All` 方法,這樣程式設計師就不必記住是直接遍歷 `All` 還是呼叫 `All` 來獲取可以遍歷的值。他們總是可以做後者。
如果你仔細想想,你會發現編譯器必須調整迴圈以建立一個 yield 函式來傳遞給 `s.All` 返回的迭代器。Go 編譯器和執行時中存在相當多的複雜性,以使其高效執行,並正確處理迴圈中的 `break` 或 `panic` 等情況。我們不會在這篇部落格文章中討論這些。幸運的是,在實際使用此功能時,實現細節並不重要。
拉取迭代器
我們現在已經瞭解瞭如何在 for/range 迴圈中使用迭代器。但簡單的迴圈並不是使用迭代器的唯一方式。例如,有時我們可能需要並行遍歷兩個容器。我們該如何做到這一點呢?
答案是使用另一種迭代器:拉取迭代器。我們已經看到,標準迭代器(也稱為推送迭代器)是一個接受 yield 函式作為引數並透過呼叫 yield 函式推送序列中每個值的函式。
拉取迭代器的工作方式則相反:它是一個每次呼叫它都會返回序列中下一個值的函式。
我們將重複兩種迭代器之間的區別以幫助您記憶
- 推送迭代器將序列中的每個值推送到一個 yield 函式。推送迭代器是 Go 標準庫中的標準迭代器,並直接由 for/range 語句支援。
- 拉取迭代器的工作方式則相反。每次呼叫拉取迭代器時,它都會從序列中拉取另一個值並返回。拉取迭代器不直接受 for/range 語句支援;但是,編寫一個遍歷拉取迭代器的普通 for 語句是直截了當的。實際上,我們之前在檢視使用 `Set.Pull` 方法時看到了一個例子。
你可以自己編寫一個拉取迭代器,但通常不必這樣做。新的標準庫函式 `iter.Pull` 接受一個標準迭代器,也就是說一個推送迭代器函式,並返回一對函式。第一個是拉取迭代器:一個每次呼叫都會返回序列中下一個值的函式。第二個是停止函式,當我們完成拉取迭代器時應該呼叫它。這與我們之前看到的 `Set.Pull` 方法類似。
`iter.Pull` 返回的第一個函式,即拉取迭代器,返回一個值和一個布林值,用於報告該值是否有效。在序列的末尾,布林值將為 false。
`iter.Pull` 返回一個停止函式,以防我們沒有遍歷到序列的末尾。在一般情況下,推送迭代器(`iter.Pull` 的引數)可能會啟動 goroutine,或者構建需要在迭代完成後清理的新資料結構。當 yield 函式返回 false(表示不再需要值)時,推送迭代器將執行任何清理。當與 for/range 語句一起使用時,for/range 語句將確保如果迴圈提前退出(透過 `break` 語句或任何其他原因),則 yield 函式將返回 false。另一方面,對於拉取迭代器,無法強制 yield 函式返回 false,因此需要停止函式。
另一種說法是,呼叫停止函式將導致 yield 函式在被推送迭代器呼叫時返回 false。
嚴格來說,如果拉取迭代器返回 false 以指示它已到達序列末尾,則無需呼叫停止函式,但通常始終呼叫它更簡單。
這是一個使用拉取迭代器並行遍歷兩個序列的示例。此函式報告兩個任意序列是否包含相同順序的相同元素。
// EqSeq reports whether two iterators contain the same
// elements in the same order.
func EqSeq[E comparable](s1, s2 iter.Seq[E]) bool {
next1, stop1 := iter.Pull(s1)
defer stop1()
next2, stop2 := iter.Pull(s2)
defer stop2()
for {
v1, ok1 := next1()
v2, ok2 := next2()
if !ok1 {
return !ok2
}
if ok1 != ok2 || v1 != v2 {
return false
}
}
}
該函式使用 `iter.Pull` 將兩個推送迭代器 `s1` 和 `s2` 轉換為拉取迭代器。它使用 `defer` 語句確保在我們完成拉取迭代器時它們被停止。
然後程式碼迴圈,呼叫拉取迭代器檢索值。如果第一個序列已完成,則如果第二個序列也已完成,則返回 true,否則返回 false。如果值不同,則返回 false。然後它迴圈以拉取接下來的兩個值。
與推送迭代器一樣,Go 執行時在使拉取迭代器高效方面存在一些複雜性,但這不影響實際使用 `iter.Pull` 函式的程式碼。
迭代器上的迭代
現在你已經瞭解了關於函式型別的 range 迴圈和迭代器的所有知識。我們希望你喜歡使用它們!
然而,還有一些事情值得一提。
介面卡
迭代器標準定義的一個優點是能夠編寫使用它們的標準介面卡函式。
例如,這是一個過濾值序列並返回新序列的函式。這個 `Filter` 函式接受一個迭代器作為引數並返回一個新的迭代器。另一個引數是一個過濾函式,它決定哪些值應該包含在 `Filter` 返回的新迭代器中。
// Filter returns a sequence that contains the elements
// of s for which f returns true.
func Filter[V any](f func(V) bool, s iter.Seq[V]) iter.Seq[V] {
return func(yield func(V) bool) {
for v := range s {
if f(v) {
if !yield(v) {
return
}
}
}
}
}
與之前的示例一樣,當你第一次看到函式簽名時,它們看起來很複雜。一旦你越過簽名,實現就非常簡單了。
for v := range s {
if f(v) {
if !yield(v) {
return
}
}
}
程式碼遍歷輸入迭代器,檢查過濾函式,並用應該進入輸出迭代器的值呼叫 yield。
我們將在下面展示一個使用 `Filter` 的示例。
(Go 標準庫中目前沒有 `Filter` 版本,但未來版本可能會新增。)
二叉樹
作為一個例子,說明推送迭代器在遍歷容器型別時有多麼方便,讓我們考慮這個簡單的二叉樹型別。
// Tree is a binary tree.
type Tree[E any] struct {
val E
left, right *Tree[E]
}
我們不會展示將值插入樹中的程式碼,但自然地應該有一種方法來遍歷樹中的所有值。
事實證明,如果迭代器程式碼返回一個布林值,編寫起來會更容易。由於 for/range 支援的函式型別不返回任何東西,所以這裡的 `All` 方法返回一個小的函式字面量,它呼叫迭代器本身(這裡稱為 `push`),並忽略布林結果。
// All returns an iterator over the values in t.
func (t *Tree[E]) All() iter.Seq[E] {
return func(yield func(E) bool) {
t.push(yield)
}
}
// push pushes all elements to the yield function.
func (t *Tree[E]) push(yield func(E) bool) bool {
if t == nil {
return true
}
return t.left.push(yield) &&
yield(t.val) &&
t.right.push(yield)
}
`push` 方法使用遞迴遍歷整個樹,對每個元素呼叫 yield。如果 yield 函式返回 false,則該方法一路返回 false。否則,一旦迭代完成,它就返回。
這展示了使用這種迭代器方法遍歷複雜資料結構是多麼簡單。無需維護單獨的堆疊來記錄樹中的位置;我們可以直接使用 goroutine 呼叫堆疊來完成此操作。
新的迭代器函式。
Go 1.23 中新增的還有切片和對映包中與迭代器一起使用的函式。
以下是切片包中的新函式。`All` 和 `Values` 是返回切片元素迭代器的函式。`Collect` 從迭代器中獲取值並返回一個包含這些值的切片。其他函式請參閱文件。
All([]E) iter.Seq2[int, E]
Values([]E) iter.Seq[E]
Collect(iter.Seq[E]) []E
AppendSeq([]E, iter.Seq[E]) []E
Backward([]E) iter.Seq2[int, E]
Sorted(iter.Seq[E]) []E
SortedFunc(iter.Seq[E], func(E, E) int) []E
SortedStableFunc(iter.Seq[E], func(E, E) int) []E
Repeat([]E, int) []E
Chunk([]E, int) iter.Seq([]E)
以下是 maps 包中的新函式。`All`、`Keys` 和 `Values` 返回對映內容的迭代器。`Collect` 從迭代器中獲取鍵和值並返回一個新的對映。
All(map[K]V) iter.Seq2[K, V]
Keys(map[K]V) iter.Seq[K]
Values(map[K]V) iter.Seq[V]
Collect(iter.Seq2[K, V]) map[K, V]
Insert(map[K, V], iter.Seq2[K, V])
標準庫迭代器示例
這裡是一個如何將這些新函式與我們之前看到的 `Filter` 函式一起使用的示例。此函式接受一個 int 到 string 的對映,並返回一個切片,該切片僅包含對映中長度大於引數 `n` 的值。
// LongStrings returns a slice of just the values
// in m whose length is n or more.
func LongStrings(m map[int]string, n int) []string {
isLong := func(s string) bool {
return len(s) >= n
}
return slices.Collect(Filter(isLong, maps.Values(m)))
}
`maps.Values` 函式返回 `m` 中值的迭代器。`Filter` 讀取該迭代器並返回一個只包含長字串的新迭代器。`slices.Collect` 從該迭代器讀取到新切片中。
當然,你可以很容易地編寫一個迴圈來完成此操作,並且在許多情況下,迴圈會更清晰。我們不希望鼓勵每個人總是以這種風格編寫程式碼。也就是說,使用迭代器的好處是這種函式以相同的方式與任何序列一起工作。在這個例子中,請注意 Filter 如何使用對映作為輸入和切片作為輸出,而無需更改 Filter 中的程式碼。
遍歷檔案中的行
儘管我們看到的大多數示例都涉及容器,但迭代器是靈活的。
考慮這段簡單的程式碼,它不使用迭代器,用於遍歷位元組切片中的行。這很容易編寫,並且效率相當高。
nl := []byte{'\n'}
// Trim a trailing newline to avoid a final empty blank line.
for _, line := range bytes.Split(bytes.TrimSuffix(data, nl), nl) {
handleLine(line)
}
然而,`bytes.Split` 確實分配並返回一個位元組切片切片來儲存行。垃圾回收器將不得不做一些工作才能最終釋放該切片。
這是一個返回位元組切片行迭代器的函式。除了通常的迭代器簽名,這個函式相當簡單。我們不斷從資料中選擇行,直到沒有剩餘,然後我們將每一行傳遞給 yield 函式。
// Lines returns an iterator over lines in data.
func Lines(data []byte) iter.Seq[[]byte] {
return func(yield func([]byte) bool) {
for len(data) > 0 {
line, rest, _ := bytes.Cut(data, []byte{'\n'})
if !yield(line) {
return
}
data = rest
}
}
}
現在我們遍歷位元組切片行的程式碼看起來像這樣。
for line := range Lines(data) {
handleLine(line)
}
這和之前的程式碼一樣容易編寫,而且效率更高,因為它不需要分配一個行切片。
將函式傳遞給推送迭代器
對於我們的最後一個例子,我們將看到你不需要在 range 語句中使用推送迭代器。
之前我們看到了一個 `PrintAllElements` 函式,它打印出集合的每個元素。這是列印集合所有元素的另一種方法:呼叫 `s.All` 獲取一個迭代器,然後傳入一個手寫的 yield 函式。這個 yield 函式只是列印一個值並返回 true。請注意,這裡有兩個函式呼叫:我們呼叫 `s.All` 獲取一個迭代器,該迭代器本身就是一個函式,然後我們用我們手寫的 yield 函式呼叫該函式。
func PrintAllElements[E comparable](s *Set[E]) {
s.All()(func(v E) bool {
fmt.Println(v)
return true
})
}
沒有特別的理由以這種方式編寫此程式碼。這只是一個例子,表明 yield 函式並非神奇。它可以是任何你喜歡的函式。
更新 go.mod
最後一點:每個 Go 模組都指定了它使用的語言版本。這意味著為了在現有模組中使用新的語言特性,你可能需要更新該版本。這適用於所有新的語言特性;它不是函式型別迴圈特有的。由於 Go 1.23 版本中新增了函式型別迴圈,因此使用它需要至少指定 Go 語言版本 1.23。
有(至少)四種方法可以設定語言版本
- 在命令列上,執行 `go get go@1.23`(或 `go mod edit -go=1.23` 僅編輯 `go` 指令)。
- 手動編輯 `go.mod` 檔案並更改 `go` 行。
- 保留模組整體的舊語言版本,但使用 `//go:build go1.23` 構建標籤允許在特定檔案中使用函式型別迴圈。
下一篇文章:新的 unique 包
上一篇文章:Go 1.23 已釋出
部落格索引