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 個元素的檢視

如果函式改變了作為引數傳遞的切片的長度,那麼它需要向呼叫者返回一個新的切片。如果底層陣列不必增長,它可能保持不變。這解釋了為什麼 appendslices.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 包中的許多其他函式也遵循此模式,包括 CompactCompactFuncDeleteFuncGrowInsertReplace

呼叫這些函式時,我們必須認為原始切片無效,因為底層陣列已被修改。呼叫函式卻忽略返回值是錯誤的

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

不需要的活躍性問題

在 Go 1.22 之前,slices.Delete 不會修改切片新長度和原始長度之間的元素。雖然返回的切片不包含這些元素,但在原始(現已失效的)切片末尾建立的“空隙”會繼續持有這些元素。這些元素可能包含指向大型物件(如 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 是指標、切片、map、chan 或介面型別時,E 的零值是 nil

測試失敗

此更改導致一些在 Go 1.21 中透過的測試在 Go 1.22 中失敗,原因在於切片函式使用不正確。這是個好訊息。當你有一個 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執行跟蹤
上一篇文章:Go 1.22的路由增強功能
部落格索引