Go 官方部落格

範圍函式型別

伊恩·蘭斯·泰勒
2024 年 8 月 20 日

引言

這是我在 GopherCon 2024 上演講的部落格文章版本。

範圍函式型別是 Go 1.23 版本中新增的語言特性。這篇部落格文章將解釋我們為何新增此新特性、它具體是什麼以及如何使用它。

為何?

自 Go 1.18 以來,我們已經能夠在 Go 中編寫新的泛型容器型別。例如,考慮這個非常簡單的 Set 型別,它是一個基於 map 實現的泛型型別。

// 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
}

此外,我們還需要一個函式來返回兩個集合的並集。

// 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 函式的這個實現。為了計算兩個集合的並集,我們需要一種方法來獲取每個集合中的所有元素。在這段程式碼中,我們使用了 for/range 語句遍歷 set 型別的未匯出欄位。這僅當 Union 函式在 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 方法列印集合中所有元素的程式碼如下所示:您呼叫 Push 並傳入一個函式,該函式對元素執行您想要的操作。

func PrintAllElementsPush[E comparable](s *Set[E]) {
    s.Push(func(v E) bool {
        fmt.Println(v)
        return true
    })
}

拉取集合元素

遍歷 Set 元素的另一種方法是返回一個函式。每次呼叫該函式時,它會返回 Set 中的一個值,以及一個布林值,指示該值是否有效。當迴圈遍歷完所有元素時,布林結果將為 false。在這種情況下,我們還需要一個停止函式,以便在不再需要更多值時呼叫。

這個實現使用一對通道,一個用於集合中的值,一個用於停止返回值。我們使用一個 goroutine 在通道上傳送值。next 函式透過從元素通道讀取來返回集合中的一個元素,而 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標準化方法

我們現在已經看到了兩種不同的遍歷集合所有元素的方法。不同的 Go 包使用這些方法以及其他一些方法。這意味著當您開始使用一個新的 Go 容器包時,您可能需要學習一種新的迴圈機制。這也意味著我們無法編寫一個可以處理多種不同型別容器的函式,因為容器型別處理迴圈的方式不同。

我們希望透過開發遍歷容器的標準方法來改進 Go 生態系統。

迭代器

當然,這是許多程式語言中都會出現的問題。

流行的《設計模式》一書,於 1994 年首次出版,將此描述為迭代器模式。您使用迭代器來“提供一種按順序訪問聚合物件的元素而不暴露其底層表示的方法”。這段引文所稱的聚合物件正是我所說的容器。聚合物件或容器,就是一個持有其他值的值,就像我們一直在討論的 Set 型別。

與程式設計中的許多想法一樣,迭代器可以追溯到 Barbara Liskov 在 20 世紀 70 年代開發的 CLU 語言

如今,許多流行語言都以這樣或那樣的方式提供了迭代器,包括 C++、Java、Javascript、Python 和 Rust 等。

然而,Go 1.23 版本之前沒有提供。

for/range 語句

眾所周知,Go 內建了容器型別:切片(slices)、陣列(arrays)和 map。並且它有一種在不暴露底層表示的情況下訪問這些值元素的方法:for/range 語句。for/range 語句適用於 Go 的內建容器型別(也適用於字串、通道,以及 Go 1.22 版本開始支援的 int)。

for/range 語句是一種迭代方式,但它不是如今流行語言中出現的迭代器。儘管如此,如果能夠使用 for/range 遍歷像 Set 型別這樣的使用者自定義容器,那將是一件不錯的事情。

然而,Go 1.23 版本之前不支援此特性。

此版本中的改進

對於 Go 1.23,我們決定同時支援對使用者自定義容器型別的 for/range 遍歷以及標準化形式的迭代器。

我們擴充套件了 for/range 語句以支援對函式型別進行範圍遍歷。我們將在下面看到這如何幫助遍歷使用者自定義容器。

我們還添加了標準庫型別和函式來支援使用函式型別作為迭代器。迭代器的標準定義使我們能夠編寫與不同容器型別流暢協作的函式。

範圍遍歷(部分)函式型別

改進後的 for/range 語句不支援任意函式型別。從 Go 1.23 開始,它現在支援範圍遍歷接受單個引數的函式。該單個引數本身必須是一個接受零到兩個引數並返回布林值的函式;按照慣例,我們稱之為 yield 函式。

func(yield func() bool)

func(yield func(V) bool)

func(yield func(K, V) bool)

當我們在 Go 中提到迭代器時,我們指的是具有這三種類型之一的函式。我們將在下面討論,標準庫中還有另一種迭代器:拉取迭代器(pull iterator)。當需要區分標準迭代器和拉取迭代器時,我們將標準迭代器稱為推送迭代器(push iterators)。這是因為,正如我們將看到的那樣,它們透過呼叫 yield 函式來推送一系列值。

標準(推送)迭代器

為了使迭代器更易於使用,新的標準庫包 iter 定義了兩種型別:SeqSeq2。這些是迭代器函式型別的名稱,即可以與 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

SeqSeq2 的區別僅在於 Seq2 是一個對序列,例如 map 中的鍵值對。為了簡單起見,本文將重點關注 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 函式。迭代器會用集合中的每個值呼叫 yield 函式。在這種情況下,由 Set.All 返回的迭代器函式,與我們之前看到的 Set.Push 函式非常相似。

這展示了迭代器如何工作:對於某個值序列,它們會用序列中的每個值呼叫一個 yield 函式。如果 yield 函式返回 false,表示不再需要更多值,迭代器就可以直接返回,執行可能需要的任何清理工作。如果 yield 函式從未返回 false,迭代器在用序列中的所有值呼叫 yield 後就可以直接返回。

這就是它們的工作原理,但我們得承認,當你第一次看到這些時,你的第一反應可能是“這裡有很多函式在飛來飛去”。你沒說錯。讓我們關注兩件事。

第一點是,一旦你理解了函式程式碼的第一行,迭代器的實際實現就非常簡單:用集合中的每個元素呼叫 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 編譯器和執行時中存在相當多的複雜性來實現這一高效,並正確處理迴圈中的 breakpanic 等情況。我們不會在這篇部落格文章中介紹這些內容。幸運的是,在實際使用此特性時,實現細節並不重要。

拉取迭代器

我們現在已經看到了如何在 for/range 迴圈中使用迭代器。但是簡單的迴圈並不是使用迭代器的唯一方式。例如,有時我們可能需要並行遍歷兩個容器。我們該如何實現呢?

答案是我們使用另一種迭代器:拉取迭代器(pull iterator)。我們已經知道,標準迭代器,也稱為推送迭代器(push iterator),是一個接受 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 將兩個推送迭代器 s1s2 轉換為拉取迭代器。它使用 defer 語句確保在使用完拉取迭代器後將其停止。

然後程式碼迴圈,呼叫拉取迭代器來檢索值。如果第一個序列完成,則在第二個序列也完成時返回 true,否則返回 false。如果值不同,則返回 false。然後迴圈繼續拉取接下來的兩個值。

與推送迭代器一樣,Go 執行時中存在一些複雜性以使拉取迭代器高效,但這不會影響實際使用 iter.Pull 函式的程式碼。

迭代器上的迭代

現在您已經瞭解了關於範圍函式型別和迭代器的所有知識。希望您喜歡使用它們!

不過,還有一些值得提及的事情。

介面卡

迭代器標準定義的一個優點是能夠編寫使用它們的標準介面卡函式。

例如,這是一個過濾值序列並返回新序列的函式。這個 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]
}

我們不會展示將值插入樹中的程式碼,但自然應該有某種方式可以遍歷樹中的所有值。

事實證明,如果迭代器程式碼返回一個 bool 值會更容易編寫。由於 for/range 支援的函式型別不返回任何值,這裡的 All 方法返回一個小的函式字面量,它呼叫迭代器本身(這裡稱為 push),並忽略 bool 結果。

// 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 中新增的還有 slices 和 maps 包中與迭代器一起使用的函式。

以下是 slices 包中新增的函式。AllValues 是返回切片元素迭代器的函式。Collect 從迭代器中獲取值並返回一個包含這些值的切片。其他函式的詳細資訊請參閱文件。

以下是 maps 包中新增的函式。AllKeysValues 返回 map 內容的迭代器。Collect 從迭代器中獲取鍵和值並返回一個新的 map。

標準庫迭代器示例

這裡是一個示例,展示瞭如何將這些新函式與我們之前看到的 Filter 函式一起使用。這個函式接受一個從 int 到 string 的 map,並返回一個切片,其中只包含 map 中長度大於給定引數 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 如何使用 map 作為輸入,使用 slice 作為輸出,而無需對 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 已釋出
部落格索引