Go 部落格

範圍之於函數類型

Ian Lance Taylor
2024 年 8 月 20 日

引言

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

範圍之於函數類型是 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{})}
}

自然地,一個集合類型會有新增元素和檢查是否有元素的方法。這裡的細節並不重要。

// 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 陳述式。這僅在集合套件中定義 Union 函數時才會運作。

但是有許多原因會讓某人想要迴圈所有集合元素。這個集合套件必須提供某些方法讓其使用者執行這項操作。

這該如何運作?

Push 集合元素

一種方法是提供一個會接收函式的 `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
    })
}

Pull 集合元素

迴圈遍歷 `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``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)
    }
}

標準化方法

我們現在已看到兩種用來對集合所有元素進行迴圈的不同方法。不同的 Go 套件會使用這些方法和其他幾種方法。這表示當您開始使用新的 Go 容器套件時,您可能必須學習新的迴圈機制。這也表示我們無法寫出一個可以與多種類型的容器配合使用的函數,因為容器類型會以不同的方式處理迴圈。

我們想透過開發針對容器進行迴圈的標準方法來改善 Go 生態系。

迭代器

這當然是一個在許多程式語言中會出現的問題。

廣受歡迎的 Design Patterns 書籍(於 1994 年首次出版)將其描述為迭代器模式。您使用迭代器「提供一種方法,可以順序存取集合物件的元素,而不會公開其底層表示形式。」這個引文所稱的集合物件正是我一直稱之為容器的東西。集合物件或容器只是一個包含其他值的值,例如我們一直討論的 Set 類型。

與程式設計中的許多想法一樣,迭代器的歷史可以追溯到 Barbara Liskov 的 CLU 語言,這是她在 1970 年代開發的。

如今,許多流行的語言以各種方式提供迭代器,其中包括 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 語句延伸,以支援取用函數類型。我們將在以下說明這如何協助對使用者定義的容器進行迴圈。

我們還增加了標準函式庫類型和函數,以支援使用函數類型作為迭代器。迭代器的標準定義使我們可以寫出能與不同的容器類型順利運作的函數。

取用(部分)函數類型

已改良的 for/range 語句並不支援任意函數類型。從 Go 1.23 版開始,它現在支援取用需要一個參數的函數。單一參數本身必須是一個需要 0 個到 2 個參數並傳回布林值的函數;根據慣例,我們將其稱為傳出函數。

func(yield func() bool)

func(yield func(V) bool)

func(yield func(K, V) bool)

當我們在 Go 中講到迭代器時,我們所指的是有其中三種類型的函式。正如我們在下面所探討的,標準函式庫中還有另一種迭代器:拉式迭代器。當有必要區分標準迭代器和拉式迭代器時,我們稱標準迭代器為推式迭代器。這是因為,正如我們將會看到的,它們會透過呼叫一個讓出的函式來推出一連串值。

標準(推式)迭代器

為了讓迭代器更易於使用,新的標準函式庫套件 iter 定義了兩個類型:SeqSeq2。這些是迭代器函式類型的名稱,這些類型可用於 for/range 陳述式。Seq 的名稱是序列的簡寫,因為迭代器會迴圈執行一系列的值。

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 是一系列的配對,例如來自映射的鍵和值。在這篇文章中,為了簡潔我們將重點放在 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
            }
        }
    }
}

迭代器函式本身取用另一個函式,讓出的函式,作為一個引數。迭代器會對集合中的每個值呼叫讓出的函式。在這個情況中,迭代器,由 Set.All 傳回的函式,很類似於我們先前所看到的 Set.Push 函式。

這顯示了迭代器的運作方式:對於某些一系列的值,它們會呼叫具有序列中每個值的讓出函式。如果讓出函式傳回 false,就不再需要更多值,而迭代器可以只傳回,執行可能必要的任何清理作業。如果讓出函式從未傳回 false,則迭代器可以在使用讓出函式呼叫所有序列中的值後只傳回。

這就是它們的運作方式,但讓我們承認,當你第一次看到這些代碼時,你的第一個反應可能是「這裡有很多函式在到處亂飛」。這沒有錯。讓我們專注於兩件事。

第一件事是,一旦你過了這個函式程式碼的第一行,迭代器的實際實作非常簡單:對集合的每個元素呼叫讓出,如果讓出傳回 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 來取得一個可以遍歷的值。他們永遠可以採用後者。

如果你仔細想想,就會發現編譯器必須調整迴圈才能建立一個傳遞給 s.All 傳回的迭代器的產出函式。Go 編譯器和執行階段有蠻大量的複雜性,才能讓這變得有效率,並正確處理迴圈中的內容(例如 breakpanic)。我們在這篇網誌文章中不會介紹任何這些內容。幸運的是在實際使用這個功能時,實作細節並不重要。

拉取迭代器

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

答案是,我們用不同類型的迭代器:拉取迭代器。我們已經看過標準迭代器(也稱為推入迭代器)是一個會以產出函式作為參數的函式,並透過呼叫產出函式來傳入序列中的各個值。

拉取迭代器運作方式相反,它是一個寫作時讓每次呼叫它時,它都會傳回序列中下一個值的函式。

我們會重述這兩種迭代器類型的差異,幫助你記憶:

  • 推入迭代器會將序列中的每個值推到產出函式。推入迭代器是 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 函式的程式碼。

在指標上反覆運算

現在您已知道關於 range over 函式類型和指標的所有資訊。我們希望您使用它們時能樂在其中!

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

轉接器

指標標準定義的優點是,能夠撰寫使用它们的標準轉接器函式。

例如,以下是會過濾一連串值並傳回新一連串的函式。這個 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,直到堆疊的頂端。否則,它會在反覆運算完成後傳回一次。

這顯示了使用此反覆子方法來反覆運算複雜資料結構是多麼容易。不必維護一個個別的堆疊來記錄樹狀結構中的位置;我們可以使用常式程式呼叫堆疊來為我們執行此操作。

新的反覆子函式。

在 Go 1.23 中,slice 和 map 套件中亦有使用反覆子的新函式。

以下是 slice 套件中的新函式。AllValues 是傳回在 slice 元素中反覆運算的函式。Collect 從反覆運算中擷取值,並傳回一個包含這些值的 slice。請參閱其他功能的文件。

以下是 map 套件中的新函式。All, Keys, and Values 傳回在 map 內容中反覆運算。Collect 從反覆運算中擷取鍵和值,並傳回一個新的 map。

標準函式庫反覆子範例

以下是一個您可能如何使用這些新函式,以及我們之前看到的 Filter 函式的範例。此函式從 int 到字串的 map 中擷取一個 slice,並只包含 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 從該反覆運算讀取到一個新的 slice 中。

當然,你可以輕鬆地寫一個迴圈來執行此操作,而且在許多情況下,迴圈會更清楚。我們不想鼓勵大家時時刻刻都這樣編寫程式碼。但話說回來,使用反覆子的好處是,這種函式與任何序列都能以相同的方式工作。在此範例中,請注意 Filter 如何使用 map 作為輸入,並使用 slice 作為輸出,而完全不需要變更 Filter 中的程式碼。

遍歷檔案中的行

雖然我們看過的大多數範例都涉及容器,但反覆子是靈活的。

考量以下這個簡單的程式碼,它不使用反覆子來遍歷位元組 slice 中的行。這種程式碼很容易撰寫,而且相當有效率。

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

這和早先的程式碼一樣容易寫,而且更有效率,因為它不必配置列切片。

將函式傳給推疊反覆運算子

對我們的最後一個範例,我們會看到您不必在範圍語句中使用推疊反覆運算子。

早先我們看到了會列印出集合每個元素的 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 建置標籤,以允許使用函式類型範圍。

下一篇: 新的唯一套件
前一篇: Go 1.23 發布
部落格索引