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 是指针、切片、映射、通道或接口类型时,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 进行任何更改,并且开发者无需额外工作,就可以自动避免一类内存泄漏。

进一步阅读

slices 包中函数的签名很大程度上受切片在内存中的表示方式的具体影响。我们建议您阅读

原始提案 关于归零过时元素包含许多详细信息和注释。

下一篇文章: 更强大的 Go 执行跟踪
上一篇文章: Go 1.22 的路由增强功能
博客索引