由于近期忙于编写公司的项目,导致懈怠了 golang
的学习,故而开启 Go语言精进之路
系列文章,记录学习过程中的理解和笔记。
关于切片#
Slice,中文译作 切片
,是 Go 语言中基于数组提供的一个抽象数据类型,在 Go 语言中,大部分需要使用数组的场景,皆可用切片代替。
在 Go 语言总,切片对数组实现了完美代替,并提供了更灵活、更高效的数据序列访问接口。
切片究竟是什么?#
在了解 Go 语言的 slice 之前,我们需要先了解数组。
切片是什么?#
在 Go 中,数组是一个固定长度、容纳相同类型元素的持续序列,因此,Go 数组有两个属性:元素类型
和 数组长度
,这两个类型都相同的数组是等价的。也就是:
var a [8]int
var b [8]int
这里 a
的数组长度为 8,元素类型则为 int,与 b
相同,所以可以说,a
与 b
是等价的。
Go 是值语义的,那么就意味着,一个数组变量表示的是整个数组,而不是数组的第一个元素的指针 (这是 C 语言的做法)。而在 Go 语言中,传递数组使用的是值拷贝,那么就导致如果需要传递元素类型长度较大,或者元素较多的数组时,会有不小的性能损耗。
在 C 语言中,这样的场景可以使用数组指针类型来处理,但在 Go 语言中,一般使用切片处理。
切片与数组的关系#
切片与数组的关系,相当于是文件描述符 于 文件 一样。在 Go 语言中,数组一般退居 "幕后",作为数据存储的 “底层”;而切片则走向 “前台”,为底层的存储提供了对外访问的接口。
所以,可以将切片称为数组的 “描述符”。而切片之所以能在不同函数之间任意传递,就是因为 “描述符” 的特性,不管底层的数据存储有多大,描述符的大小总是固定的。
切片的创建方式和 runtime 中的表示#
切片在 Go runtime
中的表示:
//$GOROOT/src/runtime/slice.go
type slice struct {
array unsafe.Pointer
len int
cap int
}
可以看到,slice 中有三个元素,分别是:
array
:指向下层数组某元素的指针,这个元素也是切片的第一个元素。len
:切片的长度, 也就是当前切片的元素个数cap
:切片的最大容量,cap >= len
在运行时中,每一个切片都是一个 runtime.slice
结构体的实例,可以用下面的语句创建一个切片:
s := make([]byte, 5)
我们可以看到,在我们创建切片时,编译器会默认创建一个底层数组,在我们没有指定 cap
时,默认 cap = len
。
我们也可以通过对已有数组的操作,来创建一个切片,我们将其称为 数组的切片化
:
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
预定义函数进行元素赋值操作。
var k []int
k.append(k, 1)
k 被初始化为零值,所以 k 并没有绑定对应的底层数组。但是,在经过 append
操作后,k 显然已经绑定了属于它的底层数组。
下面,我将通过一系列实操,来展示每次 append
后的 len
和 cap
:
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
操作,那么该切片就和原数组解除绑定。
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
。
s := make([]T, len, cap)
下面对两种情况进行性能测试:
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)
}
}
}
得出的结果是:
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
参数。