函數類型的 Range - Go 編程語言
簡介
這是我在 2024 年 GopherCon 大會上演講的博客文章版本。
函數類型的 range 是 Go 1.23 版本中的一個新語言特性。這篇博文將解釋爲什麼我們要添加這個新特性, 它究竟是什麼, 以及如何使用它。
爲什麼?
自 Go 1.18 以來, 我們已經能夠在 Go 中編寫新的泛型容器類型。例如, 讓我們考慮這個非常簡單的Set
類型, 一個基於 map 實現的泛型類型。
type Set[E comparable] struct {
m map[E]struct{}
}
自然, set 類型有一種添加元素的方法和一種檢查元素是否存在的方法。這裏的細節並不重要。
func (s *Set[E]) Add(v E) {
s.m[v] = struct{}{}
}
func (s *Set[E]) Contains(v E) bool {
_, ok := s.m[v]
return ok
}
除此之外, 我們還需要一個函數來返回兩個集合的並集。
func Union[E comparable](s1, s2 *Set[E]) *Set[E] {
r := &Set[E]{m: make(map[E]struct{})}
for v := range s1.m {
r.m[v] = struct{}{}
}
for v := range s2.m {
r.m[v] = struct{}{}
}
return r
}
讓我們仔細看看這個Union
函數的實現。爲了計算兩個集合的並集, 我們需要一種方法來獲取每個集合中的所有元素。在這段代碼中, 我們使用 for/range 語句遍歷 set 類型的一個未導出字段。這隻有在Union
函數定義在 set 包內時纔有效。
但是有很多原因可能導致某人想要遍歷 set 中的所有元素。這個 set 包必須爲其用戶提供某種方式來做到這一點。
這應該如何工作呢?
推送 Set 元素
一種方法是提供一個接受函數作爲參數的Set
方法, 並用 Set 中的每個元素調用該函數。我們將其稱爲Push
, 因爲Set
將每個值推送給函數。在這裏, 如果函數返回 false, 我們就停止調用它。
func (s *Set[E]) Push(yield func(E) bool) {
for v := range s.m {
if !yield(v) {
break
}
}
}
在 Go 標準庫中, 我們看到這種通用模式被用於 sync.Map.Range
[2] 方法、 flag.Visit
[3] 函數和 filepath.Walk
[4] 函數等情況。這是一種通用模式, 而不是完全相同的模式; 事實上, 這三個例子的工作方式都不完全相同。
這就是使用Push
方法打印 set 中所有元素的樣子: 你用一個對元素進行所需操作的函數調用Push
。
s.Push(func(v string) bool {
fmt.Println(v)
return true
})
拉取 Set 元素
遍歷Set
元素的另一種方法是返回一個函數。每次調用該函數時, 它都會返回Set
中的一個值, 以及一個報告該值是否有效的布爾值。當循環遍歷完所有元素時, 布爾結果將爲 false。在這種情況下, 我們還需要一個 stop 函數, 可以在不再需要值時調用它。
這個實現使用了一對通道, 一個用於 set 中的值, 另一個用於停止返回值。我們使用一個 goroutine 在通道上發送值。next
函數通過從元素通道讀取來返回 set 中的一個元素,stop
函數通過關閉 stop 通道來告訴 goroutine 退出。我們需要stop
函數來確保在不再需要值時 goroutine 退出。
func (s *Set[E]) Pull() (next func() (E, bool), stop func()) {
ch := make(chan E)
done := make(chan struct{})
go func() {
defer close(ch)
for v := range s.m {
select {
case ch <- v:
case <-done:
return
}
}
}()
return func() (E, bool) {
v, ok := <-ch
return v, ok
},
func() {
close(done)
// Drain channel to let the goroutine exit.
for range ch {
}
}
}
標準庫中沒有完全以這種方式工作的內容。 runtime.CallersFrames
[5] 和 reflect.Value.MapRange
[6] 都類似, 儘管它們返回帶有方法的值, 而不是直接返回函數。
這就是使用Pull
方法打印Set
中所有元素的樣子。你調用Pull
來獲取一個函數, 然後在 for 循環中重複調用該函數。
next, stop := s.Pull()
defer stop()
for {
v, ok := next()
if !ok {
break
}
fmt.Println(v)
}
標準化方法
我們現在已經看到了遍歷 set 中所有元素的兩種不同方法。不同的 Go 包使用這些方法和其他幾種方法。這意味着當你開始使用一個新的 Go 容器包時, 你可能需要學習一種新的循環機制。這也意味着我們無法編寫一個適用於多種不同類型容器的函數, 因爲容器類型會以不同的方式處理循環。
我們希望通過開發標準的容器遍歷方法來改善 Go 生態系統。
迭代器
當然, 這是許多編程語言中出現的問題。
1994 年首次出版的流行 設計模式書 [7] 將其描述爲迭代器模式。你使用迭代器來 "提供一種方法, 以順序訪問聚合對象的元素, 而不暴露其底層表示"。這個引用中所說的聚合對象就是我一直稱之爲容器的東西。聚合對象, 或容器, 只是一個包含其他值的值, 就像我們一直在討論的Set
類型。
像編程中的許多想法一樣, 迭代器可以追溯到 Barbara Liskov 在 20 世紀 70 年代開發的 CLU 語言 [8] 。
如今, 許多流行的語言以這樣或那樣的方式提供迭代器, 包括 C++、Java、Javascript、Python 和 Rust 等。
然而, 1.23 版本之前的 Go 並沒有提供迭代器。
For/range
衆所周知, Go 有內置於語言中的容器類型: 切片、數組和映射。它還有一種訪問這些值的元素而不暴露底層表示的方法: for/range 語句。for/range 語句適用於 Go 的內置容器類型 (也適用於字符串、通道, 從 Go 1.22 開始還適用於 int)。
for/range 語句是迭代, 但它不是今天流行語言中出現的迭代器。儘管如此, 能夠使用 for/range 來迭代像Set
類型這樣的用戶定義容器還是很好的。
然而, 1.23 版本之前的 Go 並不支持這一點。
本次發佈的改進
對於 Go 1.23, 我們決定同時支持對用戶定義容器類型的 for/range, 以及標準化形式的迭代器。
我們擴展了 for/range 語句以支持對函數類型進行遍歷。我們將在下面看到這如何幫助遍歷用戶定義的容器。
我們還在標準庫中添加了類型和函數, 以支持使用函數類型作爲迭代器。迭代器的標準定義讓我們能夠編寫與不同容器類型順利配合的函數。
對 (某些) 函數類型進行 Range
改進後的 for/range 語句並不支持任意函數類型。從 Go 1.23 開始, 它現在支持對接受單個參數的函數進行遍歷。這個單一參數本身必須是一個接受零到兩個參數並返回 bool 的函數; 按照慣例, 我們稱之爲 yield 函數。
func(yield func(T) bool)
func(yield func(T) bool) bool
func(yield func(T1, T2) bool)
func(yield func(T1, T2) bool) bool
當我們在 Go 中談到迭代器時, 我們指的是具有這三種類型之一的函數。正如我們將在下面討論的, 標準庫中還有另一種迭代器: 拉取迭代器。當需要區分標準迭代器和拉取迭代器時, 我們稱標準迭代器爲推送迭代器。這是因爲, 正如我們將看到的, 它們通過調用 yield 函數來推送一系列值。
標準 (推送) 迭代器
爲了使迭代器更易於使用, 新的標準庫包 iter 定義了兩種類型:Seq
和Seq2
。這些是迭代器函數類型的名稱, 可以與 for/range 語句一起使用的類型。Seq
是 sequence 的縮寫, 因爲迭代器遍歷一系列值。
type Seq[V any] func(yield func(V) bool)
type Seq2[K, V any] func(yield func(K, V) bool)
Seq
和Seq2
的區別僅在於Seq2
是一對值的序列, 例如來自映射的鍵和值。在這篇文章中, 爲了簡單起見, 我們將重點關注Seq
, 但我們所說的大部分內容也適用於Seq2
。
用一個例子來解釋迭代器如何工作最容易。這裏Set
方法All
返回一個函數。All
的返回類型是iter.Seq[E]
, 所以我們知道它返回一個迭代器。
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 語句支持任何迭代器, 這顯示了它的使用有多麼容易。
for v := range s.All() {
fmt.Println(v)
}
在這種代碼中,s.All
是一個返回函數的方法。我們正在調用s.All
, 然後使用 for/range 來遍歷它返回的函數。在這種情況下, 我們本可以讓Set.All
本身就是一個迭代器函數, 而不是讓它返回一個迭代器函數。然而, 在某些情況下這不會起作用, 例如如果返回迭代器的函數需要接受一個參數, 或者需要做一些設置工作。作爲一種慣例, 我們鼓勵所有容器類型提供一個返回迭代器的All
方法, 這樣程序員就不必記住是直接對All
進行 range 還是調用All
來獲取一個可以 range 的值。他們總是可以做後者。
如果你仔細想想, 你會發現編譯器必須調整循環以創建一個 yield 函數傳遞給s.All
返回的迭代器。Go 編譯器和運行時中有相當多的複雜性來使這個高效, 並正確處理循環中的break
或panic
等情況。我們不會在這篇博文中涉及任何這些內容。幸運的是, 在實際使用這個特性時, 實現細節並不重要。
拉取迭代器
我們現在已經看到了如何在 for/range 循環中使用迭代器。但簡單的循環並不是使用迭代器的唯一方式。例如, 有時我們可能需要並行迭代兩個容器。我們該如何做到這一點?
答案是我們使用另一種迭代器: 拉取迭代器。我們已經看到, 標準迭代器 (也稱爲推送迭代器) 是一個接受 yield 函數作爲參數並通過調用 yield 函數來推送序列中每個值的函數。
拉取迭代器的工作方式相反: 它是一個函數, 每次調用它時, 它都會返回序列中的下一個值。
我們將重複兩種迭代器之間的區別, 以幫助你記住:
- 推送迭代器將序列中的每個值推送給 yield 函數。推送迭代器是 Go 標準庫中的標準迭代器, 並且直接受 for/range 語句支持。
- 拉取迭代器的工作方式相反。每次調用拉取迭代器時, 它都會從序列中拉取另一個值並返回它。拉取迭代器不直接受 for/range 語句支持; 然而, 編寫一個遍歷拉取迭代器的普通 for 語句很簡單。事實上, 我們在前面查看
Set.Pull
方法的使用時已經看到了一個例子。
你可以自己編寫一個拉取迭代器, 但通常你不必這樣做。新的標準庫函數 iter.Pull
[9] 接受一個標準迭代器, 也就是說一個推送迭代器函數, 並返回一對函數。第一個是拉取迭代器: 一個函數, 每次調用時都返回序列中的下一個值。第二個是 stop 函數, 應在我們完成拉取迭代器時調用。這類似於我們之前看到的Set.Pull
方法。
iter.Pull
返回的第一個函數, 即拉取迭代器, 返回一個值和一個報告該值是否有效的布爾值。在序列結束時, 布爾值將爲 false。
iter.Pull
返回一個 stop 函數, 以防我們沒有讀完整個序列。在一般情況下, 推送迭代器 (即iter.Pull
的參數) 可能會啓動 goroutine, 或構建需要在迭代完成時清理的新數據結構。當 yield 函數返回 false 時, 表示不再需要更多值, 推送迭代器將進行任何清理。當與 for/range 語句一起使用時, for/range 語句將確保如果循環提前退出 (通過break
語句或任何其他原因), 則 yield 函數將返回 false。另一方面, 對於拉取迭代器, 沒有辦法強制 yield 函數返回 false, 所以需要 stop 函數。
另一種說法是, 調用 stop 函數會導致 push 迭代器調用 yield 函數時返回 false。
嚴格來說, 如果 pull 迭代器返回 false 表示已到達序列末尾, 就不需要調用 stop 函數, 但通常更簡單的做法是總是調用它。
下面是一個使用 pull 迭代器並行遍歷兩個序列的例子。這個函數報告兩個任意序列是否包含相同順序的相同元素。
// 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
將兩個 push 迭代器s1
和s2
轉換爲 pull 迭代器。它使用defer
語句確保在使用完畢後停止 pull 迭代器。
然後代碼循環調用 pull 迭代器來檢索值。如果第一個序列結束, 則在第二個序列也結束時返回 true, 否則返回 false。如果值不同, 則返回 false。然後循環拉取下兩個值。
與 push 迭代器一樣, Go 運行時中有一些複雜性來使 pull 迭代器高效, 但這不會影響實際使用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
版本, 但未來的版本可能會添加。)
二叉樹
作爲 push 迭代器在遍歷容器類型時多麼方便的一個例子, 讓我們考慮這個簡單的二叉樹類型。
// 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 包中的新函數。All
和Values
是返回切片元素迭代器的函數。Collect
從迭代器中獲取值並返回包含這些值的切片。其他函數請參閱文檔。
All([]E) iter.Seq2[int, E]
[10]Values([]E) iter.Seq[E]
[11]Collect(iter.Seq[E]) []E
[12]AppendSeq([]E, iter.Seq[E]) []E
[13]Backward([]E) iter.Seq2[int, E]
[14]Sorted(iter.Seq[E]) []E
[15]SortedFunc(iter.Seq[E], func(E, E) int) []E
[16]SortedStableFunc(iter.Seq[E], func(E, E) int) []E
[17]Repeat([]E, int) []E
[18]Chunk([]E, int) iter.Seq([]E)
[19]
以下是 maps 包中的新函數。All
、Keys
和Values
返回 map 內容的迭代器。Collect
從迭代器中獲取鍵和值並返回一個新的 map。
All(map[K]V) iter.Seq2[K, V]
[20]Keys(map[K]V) iter.Seq[K]
[21]Values(map[K]V) iter.Seq[V]
[22]Collect(iter.Seq2[K, V]) map[K, V]
[23]Insert(map[K, V], iter.Seq2[K, V])
[24]
標準庫迭代器示例
這裏是一個如何使用這些新函數以及我們之前看到的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 作爲輸入和切片作爲輸出, 而完全不需要更改 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)
}
這和之前的代碼一樣容易編寫, 而且效率更高, 因爲它不需要分配行的切片。
將函數傳遞給 push 迭代器
對於我們的最後一個例子, 我們將看到你不必在 range 語句中使用 push 迭代器。
早些時候我們看到了一個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 模塊都指定了它使用的語言版本。這意味着爲了在現有模塊中使用新的語言特性, 你可能需要更新該版本。這對所有新的語言特性都是如此; 這不是函數類型 range 特有的。由於函數類型 range 是 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
構建標記允許在特定文件中使用函數類型 range。
參考鏈接
- Go 博客: https://go.dev/blog/
sync.Map.Range
: https://pkg.go.dev/sync#Map.Rangeflag.Visit
: https://pkg.go.dev/flag#Visitfilepath.Walk
: https://pkg.go.dev/path/filepath#Walkruntime.CallersFrames
: https://pkg.go.dev/runtime#CallersFramesreflect.Value.MapRange
: https://pkg.go.dev/reflect#Value.MapRange- 設計模式書: https://en.wikipedia.org/wiki/Design_Patterns
- CLU 語言: https://en.wikipedia.org/wiki/CLU_(programming_language)
iter.Pull
: https://pkg.go.dev/iter#PullAll([]E) iter.Seq2[int, E]
: https://pkg.go.dev/slices#AllValues([]E) iter.Seq[E]
: https://pkg.go.dev/slices#ValuesCollect(iter.Seq[E]) []E
: https://pkg.go.dev/slices#CollectAppendSeq([]E, iter.Seq[E]) []E
: https://pkg.go.dev/slices#AppendSeqBackward([]E) iter.Seq2[int, E]
: https://pkg.go.dev/slices#BackwardSorted(iter.Seq[E]) []E
: https://pkg.go.dev/slices#SortedSortedFunc(iter.Seq[E], func(E, E) int) []E
: https://pkg.go.dev/slices#SortedFuncSortedStableFunc(iter.Seq[E], func(E, E) int) []E
: https://pkg.go.dev/slices#SortedStableFuncRepeat([]E, int) []E
: https://pkg.go.dev/slices#RepeatChunk([]E, int) iter.Seq([]E)
: https://pkg.go.dev/slices#ChunkAll(map[K]V) iter.Seq2[K, V]
: https://pkg.go.dev/maps#AllKeys(map[K]V) iter.Seq[K]
: https://pkg.go.dev/maps#KeysValues(map[K]V) iter.Seq[V]
: https://pkg.go.dev/maps#ValuesCollect(iter.Seq2[K, V]) map[K, V]
: https://pkg.go.dev/maps#CollectInsert(map[K, V], iter.Seq2[K, V])
: https://pkg.go.dev/maps#Insert
本文由 Readfog 進行 AMP 轉碼,版權歸原作者所有。
來源:https://mp.weixin.qq.com/s/uTYN8VSnkcJVsKbkIlM8Kg