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 则不返回。

考虑删除切片一部分的任务。在泛型出现之前,删除切片 ss[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 是指针、切片、映射、通道或接口类型时,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 执行跟踪
上一篇文章: Go 1.22 的路由增强
博客索引