我们正在准备 Go 1.22 的第二个候选版本,它应该很快就会发布。在我的上一篇博文中,我写了关于 Go 1.22 的 reflect.TypeFor
的工作。这次,我将写一下我是如何提出和实现 slices.Concat 的。
这是 slices.Concat
的签名:
// Concat returns a new slice concatenating the passed in slices.
func Concat[S ~[]E, E any](slices ...S) S
这是通用切片库中一个非常简单的函数,事实上,我早在 2021 年 5 月就提出了它。在随后讨论要添加到切片包中的内容时,遭到了拒绝,
已解决:决定不在初始集中添加 Concat。如果有重要证据,我们总是可以重新考虑。
就上下文而言,slices 包本身直到 Go 1.21 才添加到 Go 标准库中。在此之前,它仅以预览形式作为外部 golang.org/x/exp/slices 包提供。slices 包故意保持小而集中,重点是在将其永久添加到标准库之前获得现实世界的经验。不过,最终我为 slices.Concat
写了一个新提案,并得到了提案审核组的批准,所以我也实现了它。
让我对当前 slices 包感到紧张的一件事是,通过在循环中滥用 O(N) 函数来制作意外二次的软件太容易了。在添加 slices.DeleteFunc 之前,我快速浏览了 SourceGraph 上列出的 Go 代码,发现大约四分之一的 slices.Delete 的使用都是在循环中完成的,这可能会导致性能二次下降,因为切片一遍又一遍地扫描和复印。
这种性能问题很隐蔽,因为对于小切片,性能影响可能可以忽略不计,但仅大十倍的切片可能需要花费一百倍的时间来处理,这导致程序在测试中运行良好,但在生产中却卡住了。我喜欢 slices.Concat
的一点是,代码阅读器对性能的影响应该相对清楚。想要在循环中使用 append
或 slices.Insert
将一堆切片连接在一起的程序员将有望注意到 slices.Concat
的存在并使用它。
slices.Concat
的实现中有一些奇怪的地方,如果您还不知道它为什么在那里,可能需要解释一下。为了有效地将切片连接在一起, slices.Concat
会对一个新切片进行一次分配,其长度足以容纳所有连接的部分,而不是随着切片的进行而追加,并冒着在构建新切片时进行多次分配的风险。但是获取所有部分长度的代码中有一个看似多余的检查:
size := 0
for _, s := range slices {
size += len(s)
if size < 0 {
panic("len out of range")
}
}
在每个循环上检查大小是否小于零的原因是为了测试切片长度是否溢出。如果连接切片中的项目数量太大,它可能会回绕并变为负数。
溢出错误可能相当隐蔽。早在 2006 年,Josh Bloch 就写了一篇有关二分搜索常见实现中的溢出错误的文章,该错误在大约二十年里没有被注意到:
对于长度(以元素为单位)为 2 30 或更大(大约十亿个元素)的数组,此错误可能会显现出来。这在 80 年代编写《Programming Pearls》时是不可想象的,但现在在 Google 和其他地方很常见。Bentley 在《Programming Pearls》中表示,“虽然第一个二分搜索于 1946 年发布,但第一个对所有 n 值都正确工作的二分搜索直到 1962 年才出现。”事实是,很少有正确的版本发布过,至少在主流编程语言中是如此。
希望 slices.Concat
永远不会遇到这个特定的溢出问题,但测试此代码提出了一个有趣的挑战。如何在不使用 EB 级 RAM 进行测试的情况下,在 64 位机器上创建一个接近溢出所需长度的切片?答案是使用 struct{}
的切片,它除了切片头之外不占用额外的内存。
值得注意的是,如果您只想连接两个切片,则无需使用 slices.Concat
。您可以将内置附加函数与 ...
扩展运算符结合使用:
concat := append(s1, s2...)
Concat
提案的早期版本包含目标切片参数,例如 append
。( Concat(dest []T, ss ...[]T) []T
。)为什么 slices.Concat
的最终版本没有目标参数来允许用户重用现有切片作为支持?
这个问题又回到了所谓的混叠问题。如果您连接到一个 nil 切片或某个容量不足以容纳所有连接片段的切片,那么不会有任何问题,因为必须创建一个新切片,并将所有部分复制到其上。但是,如果您将切片的各个部分连接到其自身上该怎么办?举个例子:
s := []int{1, 2, 3, 4}
_ = slices.ConcatWithDestination(s[:0], s[3:4], s[2:3], s[1:2], s[0:1])
// What is s now?
slices.ConcatWithDestination
的简单实现会用 4 破坏切片开头的 1,然后将其复制到切片的末尾,这样最终得到的是 4, 3, 3, 4 而不是 4, 3、2、1 按预期。
碰巧,slices.Insert 和 slices.Replace 也有这个问题。修复效果不太好。该代码最终使用 unsafe 包来检查切片是否重叠。如果这样做,它会通过将值轮换到位来避免分配:
// The hard case - v overlaps c or d. We can't just shift up
// the data because we'd move or clobber the values we're trying
// to insert.
// So instead, write v on top of d, then rotate.
copy(s[n:], v)
// Now we have
// s: aaaaaaaabbbbccccccccvvvv
// ^ ^ ^ ^
// i i+m n n+m
rotateRight(s[i:], m)
// Now we have
// s: aaaaaaaavvvvbbbbcccccccc
// ^ ^ ^ ^
// i i+m n n+m
// That's the result we want.
return s
Keith Randall 编写了 slices.Concat
的一个版本,可以进行别名检查,并且由于切片的所有方式,它添加了比 slices.Insert
或 slices.Replace
更多的代码连接时可能会重叠。
最后,我们决定始终返回一个新切片将使 slices.Concat
的实现更加简单,并有助于防止意外别名切片和获得意外结果的任何问题,因此删除了目标切片参数。
David Chase 在 Go Time 的过去一集中很好地解释了为什么防止切片锯齿很重要。
我对 Fortran 情有独钟。使 Fortran 变得更快的因素就像一件很小的事情,这在程序中通常是正确的。Fortran 说,这与你何时将一对切片传递给一个函数有关——你可以假装它们[不]重叠。如果它们重叠,则不是 Fortran。 […]
但它的作用是让你可以进行矢量化,无论你愿意与否;各种矢量化变换和并行化变换以及重新排序。这有点像 Fortran 如此快的关键。
如果切片没有别名,那么编译器和CPU就不必担心写入切片的一部分会影响另一切片,因此可以按任意顺序进行读取和写入,这可以使其速度更快。不幸的是,Go 无法在其类型系统中表示别名,因此编译器不能假设特定切片没有别名。如果有一种 Go 2 语言具有更强大的类型系统,那么这是值得考虑改变的事情。