Day1. Go语言精进之路：切片的原理并高效使用

由于近期忙于编写公司的项目，导致懈怠了 golang 的学习，故而开启 Go语言精进之路 系列文章，记录学习过程中的理解和笔记。

关于切片

Slice，中文译作 切片，是 Go 语言中基于数组提供的一个抽象数据类型，在 Go 语言中，大部分需要使用数组的场景，皆可用切片代替。
在 Go 语言总，切片对数组实现了完美代替，并提供了更灵活、更高效的数据序列访问接口。

切片究竟是什么？

在了解 Go 语言的 slice 之前，我们需要先了解数组。

切片是什么？

在 Go 中，数组是一个固定长度、容纳相同类型元素的持续序列，因此，Go 数组有两个属性：元素类型 和 数组长度，这两个类型都相同的数组是等价的。也就是：

Copy
var a [8]int
var b [8]int

这里 a 的数组长度为 8，元素类型则为 int，与 b 相同，所以可以说，a 与 b 是等价的。

Go 是值语义的，那么就意味着，一个数组变量表示的是整个数组，而不是数组的第一个元素的指针 (这是 C 语言的做法)。而在 Go 语言中，传递数组使用的是值拷贝，那么就导致如果需要传递元素类型长度较大，或者元素较多的数组时，会有不小的性能损耗。

在 C 语言中，这样的场景可以使用数组指针类型来处理，但在 Go 语言中，一般使用切片处理。

切片与数组的关系

切片与数组的关系，相当于是文件描述符 于 文件 一样。在 Go 语言中，数组一般退居 "幕后"，作为数据存储的 “底层”；而切片则走向 “前台”，为底层的存储提供了对外访问的接口。

所以，可以将切片称为数组的 “描述符”。而切片之所以能在不同函数之间任意传递，就是因为 “描述符” 的特性，不管底层的数据存储有多大，描述符的大小总是固定的。

切片的创建方式和 runtime 中的表示

切片在 Go runtime 中的表示：

Copy
//$GOROOT/src/runtime/slice.go
type slice struct {
array unsafe.Pointer
len int
cap int
}

可以看到，slice 中有三个元素，分别是：

array：指向下层数组某元素的指针，这个元素也是切片的第一个元素。

len：切片的长度， 也就是当前切片的元素个数

cap：切片的最大容量，cap >= len

在运行时中，每一个切片都是一个 runtime.slice 结构体的实例，可以用下面的语句创建一个切片：

Copy
s := make([]byte, 5)

我们可以看到，在我们创建切片时，编译器会默认创建一个底层数组，在我们没有指定 cap 时，默认 cap = len。

我们也可以通过对已有数组的操作，来创建一个切片，我们将其称为 数组的切片化：

Copy
u := [10]byte{11, 12, 13, 14, 15, 16, 17, 18, 19, 20}
s := u[3:7]

可以看到，切片 s 打开了一个数组 u 的操作窗口，s 的第一个元素为 u[3]，通过 s 可以看到并操作的数组元素有 4 个。切片的 cap 取决于底层数组的长度，从 u[3] 到末尾一共有 7 个存储元素的槽位，所以 s 的 cap 为 7。

当然，我们也可以为一个数组创建多个操作数组的切片，但由于创建的多个切片都是同一个数组的描述符，所以对任意一个切片的修改都会反应到其他切片中。

我们也可以通过对切片的操作，来创建新的切片，这个操作称为 reslicing。同样的，基于老切片创建的新切片共享同一个底层数组，所以对新切片的修改也会反应到老切片中。

所以，当切片作为参数传递时，传递的是 runtime.slice，因此不管底层数组有多大，切片带来的性能损耗都是很小且恒定的，甚至可以忽略不计，这就是在函数中通常使用切片而不是数组的原因之一。另一个原因是切片可以提供比指针数组更加强大的功能，如下标访问、边界溢出校验、动态扩容。

切片的高级特性

动态扩容

切片是满足零值可用概念的，因为切片拥有一个高级特性：动态扩容，即便是零值切片，也可以通过 append 预定义函数进行元素赋值操作。

Copy
var k []int
k.append(k, 1)

k 被初始化为零值，所以 k 并没有绑定对应的底层数组。但是，在经过 append 操作后，k 显然已经绑定了属于它的底层数组。

下面，我将通过一系列实操，来展示每次 append 后的 len 和 cap：

Copy
var s []int
s = append(s, 1)
fmt.Println(len(s), cap(s)) // 1 1
s = append(s, 2)
fmt.Println(len(s), cap(s)) // 2 2
s = append(s, 3)
fmt.Println(len(s), cap(s)) // 3 4
s = append(s, 4)
fmt.Println(len(s), cap(s)) // 4 4
s = append(s, 5)
fmt.Println(len(s), cap(s)) // 5 8

可以看出，len 的值是线性增长的，但是 cap 则是不规则增长。这张图则形象的展示出了 append 操作是如何让切片动态扩容的：

我们可以看到，最初 s 为零值，此时 s 还没有绑定底层数组

通过 append 后，向切片 s 添加了一个元素 11，此时会分配一个底层数组 u1，数组长度为 1，然后将 s 内部的 array 指向 u1，所以此时，len = 1, cap = 1

然后再次 append，将 12 插入切片 s，但是此时切片显然已经没有空间再存储 12 了。所以就需要再次扩容。所以就创建了底层数组 u2，长度为 2（u1 数组长度 * 2），并将 u1 中的所有元素复制到 u2 中，最后将 array 指向 u2，并设置 len = 2, cap = 2

通过 append 向 s 中再次插入一个元素 13，此时切片空间再次不足，需要再次扩容。所以，创建了新的底层数组 u3，长度为 4（u2 数组长度 * 2），并将 u2 的所有元素复制到 u3 中，然后将 array 指向 u3，并设置 len = 3, cap = 4

通过 append 向 s 中插入元素 14，此时切片 cap = 4，空间充足。所以将 14 放在下一个元素的位置，也就是 u3 的末尾，并将 s 内部的 len 加 1，变为 4

最后再次通过 append 向 s 插入 15，此时切片 len = 4, cap = 4，空间已经不足，再次扩容。所以，创建新的底层数组 u4，数组长度为 8（u3 数组长度 * 2），并将 u3 的所有元素复制到 u4 中，然后将 array 指向 u4，并设置 len = 5, cap = 8

可以发现，当切片的底层数组空间不足时，append 会根据需要去自动的将切片分配到新的底层数组，新数组长度会按照一定算法进行拓展（参见 $GOROOT/src/runtime/slice.go 中的 growslice 函数）。在新数组建立后，会将旧数组中的所有数据复制到新数组中，然后将 array 指向新数组，这样，新数组就是切片的底层数组，旧数组则会被 gc 回收掉。

但是这样，如果我们使用 数组的切片化 来创建的切片，一旦切片的 cap 触碰到上边界，再对切片进行 append 操作，那么该切片就和原数组解除绑定。

Copy
u := [6]int{1, 2, 3, 4, 5, 6}
s := u[2:4]
fmt.Printf("u = %v, s = %v len(s) = %d, cap(s) = %d\n", u, s, len(s), cap(s))
s = append(s, 7)
fmt.Printf("append 7: u = %v, s = %v len(s) = %d, cap(s) = %d\n", u, s, len(s), cap(s))
s = append(s, 8)
fmt.Printf("append 8: u = %v, s = %v len(s) = %d, cap(s) = %d\n", u, s, len(s), cap(s))
s = append(s, 9)
fmt.Printf("append 9: u = %v, s = %v len(s) = %d, cap(s) = %d\n", u, s, len(s), cap(s))

// output:
// u = [1 2 3 4 5 6], s = [3 4] len(s) = 2, cap(s) = 4
// append 7: u = [1 2 3 4 7 6], s = [3 4 7] len(s) = 3, cap(s) = 4
// append 8: u = [1 2 3 4 7 8], s = [3 4 7 8] len(s) = 4, cap(s) = 4
// append 9: u = [1 2 3 4 7 8], s = [3 4 7 8 9] len(s) = 5, cap(s) = 8
// append 10: u = [1 2 3 4 7 8], s = [3 4 7 8 9 10] len(s) = 6, cap(s) = 8

从输入结果可以看出，在添加元素 9 之后，cap(s) 就等于 8 了，也就是触发了切片的动态扩容，而此时的切片 s，就已经与数组 u 解绑了，也就是 s 不再是 u 的描述符了。

尽量使用 cap 参数创建切片

append 操作是一个利器，它让切片支持了零值可用的理念，并让切片变得更加易用。

但是从原理我们可以看出，每次扩容，都需要从老底层数组中将全部元素复制到新的底层数组，这一步所需要消耗的资源其实还是不小的，尤其是元素很多的情况下。那么如何避免过多内存分配和复制元素所付出的代价呢？

有一个有效的方案是，在编写代码时，有意识的去估算切片的所需容量，并在切片创建时，传递 cap 参数给内置函数 make。

Copy
s := make([]T, len, cap)

下面对两种情况进行性能测试：

Copy
const sliceSize = 10000

func BenchmarkSliceInitWithoutCap(b *testing.B) {
for n := 0; n < b.N; n++ {
sl := make([]int, 0)
for i := 0; i < sliceSize; i++ {
sl = append(sl, i)
}
}
}

func BenchmarkSliceInitWithCap(b *testing.B) {
for n := 0; n < b.N; n++ {
sl := make([]int, 0, sliceSize)
for i := 0; i < sliceSize; i++ {
sl = append(sl, i)
}
}
}

得出的结果是：

Copy
goos: windows
goarch: amd64
pkg: prometheus_for_go
cpu: AMD Ryzen 7 5800H with Radeon Graphics
BenchmarkSliceInitWithoutCap
BenchmarkSliceInitWithoutCap-16 34340 34443 ns/op
BenchmarkSliceInitWithCap
BenchmarkSliceInitWithCap-16 106251 11122 ns/op
PASS

可以看出，使用 cap 参数的切片在 append 时的平均性能为 11122 ns/op，大概是不使用 cap 参数的三倍。

所以，在创建切片时，建议携带 cap 参数。