Go 部落格
切片上的健壯泛型函式
slices
包提供了適用於任何型別切片的函式。在這篇部落格文章中,我們將討論如何透過理解切片在記憶體中的表示方式以及這對垃圾回收器有何影響,來更有效地使用這些函式,並介紹我們最近如何調整這些函式以使其不那麼令人意外。
藉助型別引數,我們可以為所有可比較元素的切片型別編寫一次slices.Index 這樣的函式。
// Index returns the index of the first occurrence of v in s,
// or -1 if not present.
func Index[S ~[]E, E comparable](s S, v E) int {
for i := range s {
if v == s[i] {
return i
}
}
return -1
}
不再需要為每種不同的元素型別重新實現 Index
。
slices
包包含許多此類助手,用於對切片執行常見操作。
s := []string{"Bat", "Fox", "Owl", "Fox"}
s2 := slices.Clone(s)
slices.Sort(s2)
fmt.Println(s2) // [Bat Fox Fox Owl]
s2 = slices.Compact(s2)
fmt.Println(s2) // [Bat Fox Owl]
fmt.Println(slices.Equal(s, s2)) // false
幾個新函式(Insert
、Replace
、Delete
等)會修改切片。為了理解它們的工作原理以及如何正確使用它們,我們需要檢查切片的底層結構。
切片是對陣列一部分的檢視。在內部,切片包含一個指標、一個長度和一個容量。兩個切片可以共享同一個底層陣列,並可以檢視重疊的部分。
例如,這個切片 s
是一個大小為 6 的陣列的 4 個元素的檢視。
如果一個函式更改了作為引數傳遞的切片的長度,那麼它需要將一個新切片返回給呼叫者。如果底層陣列不必增長,它可能保持不變。這解釋了為什麼 append 和 slices.Compact
返回一個值,而僅僅重新排序元素的 slices.Sort
則不返回。
考慮刪除切片一部分的任務。在泛型出現之前,刪除切片 s
中 s[2:5]
部分的標準方法是呼叫 append 函式,將末尾部分複製到中間部分。
s = append(s[:2], s[5:]...)
這種語法很複雜且容易出錯,涉及子切片和可變引數。我們添加了 slices.Delete 來簡化刪除元素的操作。
func Delete[S ~[]E, E any](s S, i, j int) S {
return append(s[:i], s[j:]...)
}
單行函式 Delete
更清晰地表達了程式設計師的意圖。讓我們考慮一個長度為 6、容量為 8、包含指標的切片 s
。
此呼叫刪除切片 s
中索引為 s[2]
、s[3]
、s[4]
的元素。
s = slices.Delete(s, 2, 5)
索引 2、3、4 處的間隙透過將元素 s[5]
向左移動來填充,並將新長度設定為 3
。
Delete
不需要分配新陣列,因為它會就地移動元素。像 append
一樣,它返回一個新切片。slices
包中的許多其他函式也遵循此模式,包括 Compact
、CompactFunc
、DeleteFunc
、Grow
、Insert
和 Replace
。
呼叫這些函式時,我們必須認為原始切片已失效,因為底層陣列已被修改。忽略返回值而只調用函式是一個錯誤。
slices.Delete(s, 2, 5) // incorrect!
// s still has the same length, but modified contents
意外的生命週期問題
在 Go 1.22 之前,slices.Delete
不會修改切片新舊長度之間的元素。雖然返回的切片不包含這些元素,但原始(現已失效)切片末尾建立的“間隙”仍然持有它們。這些元素可能包含指向大型物件的指標(例如一個 20MB 的影像),而垃圾回收器無法釋放與這些物件關聯的記憶體。這導致了記憶體洩漏,可能導致嚴重的效能問題。
在上面的示例中,我們透過將一個元素向左移動,成功地從 s[2:5]
中刪除了指標 p2
、p3
、p4
。但是 p3
和 p4
仍然存在於底層陣列中,位於 s
的新長度之外。垃圾回收器不會回收它們。不那麼明顯的是,p5
不是被刪除的元素之一,但由於陣列的灰色部分保留了 p5
指標,它的記憶體仍可能洩漏。
這可能會讓開發人員感到困惑,如果他們不知道“不可見”的元素仍在佔用記憶體。
所以我們有兩個選擇:
- 要麼保持
Delete
的高效實現。如果使用者想確保被指向的值可以被釋放,則讓他們自己將過時的指標設定為nil
。 - 或者更改
Delete
以始終將過時的元素設定為零值。這是一項額外的工作,使Delete
的效率略低。將指標清零(設定為nil
)可以使垃圾回收器在物件變得無法訪問時回收它們。
哪個選項更好並不明顯。第一個選項預設提供效能,第二個選項預設提供記憶體節約。
修復
一個關鍵的觀察是,“設定過時的指標為 nil
”並不像看起來那麼容易。事實上,這項任務非常容易出錯,以至於我們不應該將編寫它的負擔留給使用者。出於務實的考慮,我們選擇修改 Compact
、CompactFunc
、Delete
、DeleteFunc
、Replace
這五個函式的實現,以“清空尾部”。一個很好的副作用是,認知負擔降低了,使用者現在無需擔心這些記憶體洩漏。
在 Go 1.22 中,呼叫 Delete 後記憶體看起來是這樣的:
在五個函式中更改的程式碼使用了新的內建函式 clear (Go 1.21) 來將過時的元素設定為 s
元素型別的零值。

當 E
是指標、切片、對映、通道或介面型別時,E
的零值是 nil
。
測試失敗
這個變化導致一些在 Go 1.21 中透過的測試在 Go 1.22 中失敗,當 slices 函式被不正確使用時。這是個好訊息。當你有一個 bug 時,測試應該讓你知道。
如果你忽略 Delete
的返回值
slices.Delete(s, 2, 3) // !! INCORRECT !!
那麼你可能會錯誤地認為 s
不包含任何 nil 指標。Go Playground 中的示例。
如果你忽略 Compact
的返回值
slices.Sort(s) // correct
slices.Compact(s) // !! INCORRECT !!
那麼你可能會錯誤地認為 s
已正確排序和壓縮。示例。
如果你將 Delete
的返回值賦給另一個變數,並繼續使用原始切片
u := slices.Delete(s, 2, 3) // !! INCORRECT, if you keep using s !!
那麼你可能會錯誤地認為 s
不包含任何 nil 指標。示例。
如果你不小心覆蓋了切片變數,並繼續使用原始切片
s := slices.Delete(s, 2, 3) // !! INCORRECT, using := instead of = !!
那麼你可能會錯誤地認為 s
不包含任何 nil 指標。示例。
結論
slices
包的 API 在刪除或插入元素方面,比傳統的泛型前語法有了顯著的改進。
我們鼓勵開發人員使用新函式,同時避免上述“陷阱”。
由於最近對實現的更改,一類記憶體洩漏得到了自動避免,無需更改 API,也無需開發人員付出額外的努力。
延伸閱讀
slices
包中函式的簽名在很大程度上受到切片在記憶體中表示方式的特定細節的影響。我們建議閱讀:
- Go 切片:用法和內部原理
- 陣列、切片:‘append’ 的機制
- 動態陣列資料結構
slices
包的文件。
關於清零過時元素的原始提案包含許多細節和評論。
下一篇文章: 更強大的 Go 執行跟蹤
上一篇文章: Go 1.22 的路由增強
部落格索引