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
是指针、切片、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切片:使用和内部机制
- 数组、切片:'append' 的机制
- 动态数组数据结构
- slices 包的文档
关于将废弃元素清零的原始提案包含许多细节和评论。
下一篇文章:更强大的Go执行跟踪
上一篇文章:Go 1.22的路由增强功能
博客索引