声明、类型、语句与控制结构
13 了解切片实现原理并高效使用
切片之于数组就像是文件描述符之于文件。切片是数组的“描述符”,之所以能在函数参数传递时避免较大性能损耗,是因为它是“描述符”的特性,切片这个描述符是固定大小的,无论底层的数组元素类型有多大,切片打开的窗口有多长。
下面是切片在 Go 运行时(runtime)层面的内部表示:
type slice struct {
array unsafe.Pointer // 指向下层数组某元素的指针,该元素也是切片的初始元素
len int // 切片的长度,即切片中当前元素的个数
cap int // 切片的最大容量,cap >= len
}
如果没有在 make 中指定 cap 参数,那么 cap = len,即编译器建立的数组长度为 len。
我们可以通过语法 u[low:high] 创建对已存在数组进行操作的切片,称为数组的切片化(slicing)。如果一个数组有多个切片,无论通过哪个切片对数组进行的修改操作都会反映到其他切片中。
还可以语法 s[low:high] 基于已有切片创建新的切片,称为切片的 reslicing。新创建的切片与原切片同样是共享底层数组的,并且通过新切片对数组的修改也会反映到原切片中。切片可以提供比指针更为强大的功能,比如下标访问、边界溢出校验、动态扩容等,但不支持指针算术运算。
动态扩容:append 会根据切片的需要,在当前底层数组容量无法满足的情况下,动态分配新的数组,新数组长度会按一定算法扩展。新数组建立后,append 会把旧数组中的数据复制到新数组中,之后新数组便成为切片的底层数组,旧数组后续会被垃圾回收掉。
u := [...]int{0, 1, 2, 3, 4, 5, 6}
var s = u[4:5]
fmt.Println(len(s), cap(s), u) // 1 3 [0 1 2 3 4 5 6]
s = append(s, 7)
fmt.Println(len(s), cap(s), u) // 2 3 [0 1 2 3 4 7 6]
s = append(s, 8)
fmt.Println(len(s), cap(s), u) // 3 3 [0 1 2 3 4 7 8]
// 此时切片 s 与 数组 u 共享底层数组,同步改动
s[0] = 20
fmt.Println(len(s), cap(s), u) // 3 3 [0 1 2 3 20 7 8]
// 底层数组剩余空间不满足添加新元素,创建了新的底层数组(长度为原 2 倍)
s = append(s, 9)
fmt.Println(len(s), cap(s), u) // 4 6 [0 1 2 3 20 7 8]
// 此时与原数组 u 解除绑定,再修改切片 s 不会影响原数组 u
s[0] = 21
fmt.Println(len(s), cap(s), u) // 4 6 [0 1 2 3 20 7 8]
s = append(s, 10)
fmt.Println(len(s), cap(s), u) // 5 6 [0 1 2 3 20 7 8]
s = append(s, 11)
fmt.Println(len(s), cap(s), u) // 6 6 [0 1 2 3 20 7 8]
// 又创建了新的底层数组
s = append(s, 12)
fmt.Println(len(s), cap(s), u) // 7 12 [0 1 2 3 20 7 8]
// 查看切片
fmt.Println(s) // [21 7 8 9 10 11 12]
尽量使用 cap 参数创建切片:append 让切片类型部分满足了“零值可用”的理念,但从其原理能看到重新分配底层数组并复制元素的操作代价还是挺大的,尤其是当元素较多的情况下,如何减少或避免为过多内存分配和复制付出的代价?一种有效的方法是根据切片的使用场景对切片的容量规模进行预估,并在创建新切片时将预估出的切片容量数据以 cap 参数的形式传递给内置函数 make:s := make([]T, len, cap),这样可以提升 append 的平均操作性能,减少或消除因动态扩容带来的性能损耗。
往期回顾
关注我
参考
《Go 语言精进之路:从新手到高手的编程思想、方法和技巧》——白明