原文在这里

由 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
}

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个元素的视图:

1_sample_slice_4_6

如果一个函数改变了作为参数传递的切片的长度,那么它需要向调用者返回一个新的切片。如果底层数组不需要增长,那么它可能仍然保持相同。这解释了为什么appendslices.Compact返回一个值,但是仅重新排序元素的slices.Sort不返回值。

要删除切片s中的一部分元素。在泛型之前,从切片s中删除部分s[2:5]的标准方式是调用append函数将结束部分复制到中间部分:

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

这种语法复杂且容易出错,因为涉及到子切片和可变参数。现在我们添加了slice.Delete来更轻松地删除元素:

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

这一行的Delete函数更清晰地表达了程序员的意图。现在我们假设有一个长度为6、容量为8的切片s,其中包含指针:

2_sample_slice_6_8

现在从切片s中删除s[2]s[3]s[4]

s = slices.Delete(s, 2, 5)
3_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后内存的情况如下:

4_delete_s_2_5_nil

体现在代码中,就是这五个函数中使用了新的内置函数clear(Go 1.21),将废弃的元素设置s的元素类型的零值:

5_Delete_diff

E是指针、切片、映射、通道或接口类型时,E的零值是nil

测试验证

当切片函数被错误使用时,这一更改导致了一些在Go 1.21中通过的测试在Go 1.22中失败。这是个好消息。当你有一个 bug 时,测试应该能够提醒你。

如果忽略Delete的返回值:

slices.Delete(s, 2, 3)  // !! INCORRECT !!

那么你可能会错误地假设s不包含任何nil指针,可以在Go Playground中查看具体示例。

如果你忽略 Compact 的返回值:

slices.Sort(s)       // 正确
slices.Compact(s)    // !! 不正确 !!

那么你可能错误地假设s已经正确排序和压缩。示例

如果你将Delete的返回值赋给另一个变量,并继续使用原始切片:

u := slices.Delete(s, 2, 3)  // !! 不正确,如果继续使用 s !!

那么你可能错误地假设s不包含任何nil指针。示例

如果你意外地遮蔽了切片变量,并继续使用原始切片:

s := slices.Delete(s, 2, 3)  // !! 不正确,使用 := 而不是 = !!

那么你可能错误地假设s不包含任何nil指针。示例

最后

slices包的API比传统的泛型前语法在删除或插入元素方面有了很大的改善。

我们鼓励开发者使用新的函数,同时避免上面列出的一些“陷阱”。

得益于最近的实现更改,在没有任何API更改且开发人员无需进行额外工作的情况下,可以自动避免一类内存泄漏。

扩展阅读

slices包中函数的签名深受内存中表示切片的具体细节的影响。我们建议您阅读以下文档:

  • 「Go Slices: usage and internals](https://go.dev/blog/slices-intro)(Go Slices:用法和内部机制)
  • 「Arrays, slices: The mechanics of ‘append’](https://go.dev/blog/slices)(数组、切片:“append”的机制)
  • The dynamic array data structure(动态数组数据结构)
  • slices包的文档

关于将废弃元素清零的原始提案包含许多细节和注释。


孟斯特

声明:本作品采用署名-非商业性使用-相同方式共享 4.0 国际 (CC BY-NC-SA 4.0)进行许可,使用时请注明出处。

Author: mengbin

blog: mengbin

Github: mengbin92

cnblogs: 恋水无意

腾讯云开发者社区:孟斯特