Go 部落格

切片上的強健泛型函式

Valentin Deleplace
2024 年 2 月 22 日

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

有許多新的函式 (InsertReplaceDelete 等) 可修改切片。瞭解這些函式的運作原理,以及如何正確地使用它們,我們需要研究切片的底層結構。

切片是陣列中某個部分的檢視。 在內部,切片包含指標、長度和容量。兩個切片可以擁有相同的底層陣列,並且可以檢視重疊的部分。

例如,這個切片 s 是大小為 6 的陣列中的 4 個元素的檢視

如果函數改變傳入參數 slice 的長度,則它需要回傳一個新的 slice 給呼叫者。底層陣列可能會保持相同,如果不需要擴充長度的話。這就是為什麼 appendslices.Compact 會回傳值,但是僅重新排列元素順序的 slices.Sort 卻不會。

設想刪除一個 slice 的部分。在泛型之前,從 slice s 中刪除部分 s[2:5] 的標準方法是呼叫 append 函數,將結尾部分覆蓋中間部分

s = append(s[:2], s[5:]...)

語法複雜且容易出錯,涉及子 slice 和可變參數。我們新增了 slices.Delete,讓刪除元素變得更簡單

func Delete[S ~[]E, E any](s S, i, j int) S {
       return append(s[:i], s[j:]...)
}

單行程式碼 Delete 更清楚表達了程式設計師的意圖。考慮一個長度為 6、容量為 8 的 slice s,包含指標

此呼叫會從 slice s 中刪除元素 s[2]s[3]s[4]

s = slices.Delete(s, 2, 5)

索引 2、3、4 的間隙會透過向左平移元素 s[5] 來填補,並將新長度設定為 3

由於 Delete 會就地位移元素,因此不需要配置新的陣列。與 append 相同,它會回傳一個新的 slice。slices 函式庫中的許多其他函數遵循此模式,包括 CompactCompactFuncDeleteFuncGrowInsertReplace

呼叫這些函數時,我們必須將原始 slice 視為無效,因為底層陣列已修改。呼叫函數卻忽略回傳值會是一項錯誤

    slices.Delete(s, 2, 5) // incorrect!
    // s still has the same length, but modified contents

意外的生命期問題

在 Go 1.22 之前,slices.Delete 沒有修改 slice 新舊長度之間的元素。即使回傳的 slice 沒有包含這些元素,原始 slice(現在已失效)結尾處產生的「間隙」仍持續持有這些元素。這些元素可能包含大型物件(20MB 的影像)的指標,而垃圾收集器不會釋放與這些物件關聯的記憶體。這會導致記憶體外洩,可能帶來嚴重的效能問題。

在上方的範例中,我們成功刪除 s[2:5] 中的指標 p2p3p4,透過將一個元素向左平移。但是,p3p4 仍然存在於底層陣列中,超過 s 的新長度。垃圾收集器不會回收它們。比較不顯而易見的是,p5 並不是已刪除的元素之一,但是由於陣列的灰色部分中保留了 p5 指標,因此它的記憶體仍然可能會外洩。

這可能會讓開發人員感到困惑,如果他們不知道「看不見」的元素仍然在使用記憶體的話。

因此我們有兩個選項

  • 保留 Delete 的高效能實作。讓使用者自行將過時指標設定為 nil,如果他們想要確保可以釋放所指的值的話。
  • 或將 Delete 變更為永遠將過時元素設為零。這多了一項工作,讓 Delete 稍慢了一些。將指標設為零(設定為 nil)會讓垃圾回收機制可以在不會引用這些物件時,回收這些物件。

目前不清楚哪個選項較佳。第一個選項預設提供效能,而第二個選項預設提供記憶體省用性。

修正方式

一項重要的觀察結果是:「將過時指標設定為 nil」並不如想像中簡單。事實上,這項任務很可能會導致錯誤,我們不應該要求使用者自行撰寫。基於實務考量,我們選擇修改 CompactCompactFuncDeleteDeleteFuncReplace 五個函式的實作方式,以「清空尾部」。作為一個不錯的副作用,這減輕了認知負擔,而使用者現在不用再擔心這些記憶體外洩。

在 Go 1.22,這就是呼叫 Delete 後的記憶體樣貌

這五個函式內變更的程式碼使用內建新函式 clear (Go 1.21) 將過時元素設定為 s 元素類型的零值

E 是指標、切片、映射、頻道或介面類型時,E 的零值為 nil

測試失敗

這個變更導致原本在 Go 1.21 中通過的某些測試在 Go 1.22 中失敗,其中錯誤使用切片函式。這是好消息。在發現錯誤時,測試可以讓您知道問題所在。

如果您忽略 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,也不會讓開發人員增加額外的工作。

其他閱讀資源

切片套件中的函數簽章深受由記憶體中切片的表示方式規範影響。我們推薦閱讀

有關歸零過時元件的原始提案包含許多詳細資訊和意見。

下一篇文章:更多強大的 Go 執行緒追蹤
上一篇文章:Go 1.22 的路由增強功能
部落格索引