slice,中文多译为切片,是Go语言在数组之上提供的一个重要的抽象数据类型。在Go语言中,对于绝大多数需要使用数组的场合,切片实现了完美替代。并且和数组相比,切片提供了更灵活、更高效的数据序列访问接口。
切片究竟是什么?
在对切片一探究竟之前,我们先来简单了解一下Go语言中的数组。
Go语言数组是一个固定长度的、容纳同构类型元素的连续序列,因此Go数组类型具有两个属性:元素类型和数组长度。
这两个属性都相同的数组类型是等价的。比如以下变量
a、b、c对应的数组类型是三个不同的数组类型:
var a [8]int
var b [8]byte
var c [9]int
数组 a、b对应的数组类型长度属性相同,但元素类型不同(一个是int,另一个是byte);数组 a、c对应的数组类型的元素类型相同,都是int,但数组类型的长度不同(一个是8,另一个是9)。
Go数组是值语义的,这意味着一个数组变量表示的是整个数组,这点与C语言完全不同。在C语言中,数组变量可视为指向数组第一个元素的指针。而在Go语言中传递数组是纯粹的值拷贝,对于元素类型长度较大或元素个数较多的数组,如果直接以数组类型参数传递到函数中会有不小的性能损耗。这时很多人会使用数组指针类型来定义函数参数,然后
将数组地址传进函数,这样做的确可以避免性能损耗,但这是C语言的惯用法,在Go语言中,更地道的方式是使用切片。
切片之于数组就像是文件描述符之于文件。在Go语言中,数组更多是“退居幕后”,承担的是底层存储空间的角色;而切片则走向“前台”,为底层的存储(数组)打开了一个访问的“窗口”。
因此,我们可以称切片是数组的“描述符”。切片之所以能在函数参数传递时避免较大性能损耗,是因为它是“描述符”的特性,切片这个描述符是固定大小的,无论底层的数组元素类型有多大,切片打开的窗口有多长。
下面是切片在Go运行时(runtime)层面的内部表示:
//$GOROOT/src/runtime/slice.go
type slice struct {
array unsafe.Pointer
len int
cap int
}
我们看到每个切片包含以下三个字段。
● array:指向下层数组某元素的指针,该元素也是切片的起始元素。
● len:切片的长度,即切片中当前元素的个数。
● cap:切片的最大容量,cap >= len
在运行时中,每个切片变量都是一个runtime.slice结构体类型的实例,我们可以用下面的语句创建一个切片实例s
s := make([]byte, 5)
下图展示了切片s在运行时层面的内部表示:
我们看到通过上述语句创建的切片,编译器会自动为切片建立一个底层数组,如果没有在make中指定cap参数,那么cap = len,即编译器建立的数组长度为len
我们可以通过语法u[low: high]创建对已存在数组进行操作的切片,这被称为数组的切片化(slicing):
u := [10]byte{11, 12, 13, 14, 15, 16, 17, 18, 19, 20}
s := u[3:7]
下图展示了切片s的内部。
还可以通过语法s[low: high]基于已有切片创建新的切片,这被称为切片的reslicing。
如下图所示:新创建的切片与原切片同样是共享底层数组的,并且通过新切片对数组的修改也会反映到原切片中。
当切片作为函数参数传递给函数时,实际传递的是切片的内部表示,也就是上面的runtime.slice结构体实例,因此无论切片描述的底层数组有多大,切片作为参数传递带来的性能损耗都是很小且恒定的,甚至小到可以忽略不计,这就是函数在参数中多使用切片而不用数组指针的原因之一。
切片的高级特性:动态扩容
如果仅仅是提供通过下标值来操作元素的类数组操作接口,那么切片也不会在Go中占据重要的位置。Go切片还支持一个重要的高级特性:动态扩容。
零值切片也可以通过append预定义函数进行元素赋
值操作:
var s []byte // s被赋予零值nil
s = append(s, 1)
由于初值为零值,s这个描述符并没有绑定对应的底层数组。而经过append操作后,s显然已经绑定了属于它的底层数组。为了方便查看切片是如何动态扩容的,我们打印出每次append操作后切片s的len和cap值:
// chapter3/sources/slice_append.go
var s []int // s被赋予零值nil
s = append(s, 11)
fmt.Println(len(s), cap(s)) //1 1
s = append(s, 12)
fmt.Println(len(s), cap(s)) //2 2
s = append(s, 13)
fmt.Println(len(s), cap(s)) //3 4
s = append(s, 14)
fmt.Println(len(s), cap(s)) //4 4
s = append(s, 15)
fmt.Println(len(s), cap(s)) //5 8
我们看到切片s的len值是线性增长的,但cap值却呈现出不规则的变化。通过下图我们更容易看清楚多次append操作究竟是如何让切片进行动态扩容的。
我们看到append会根据切片对底层数组容量的需求对底层数组进行动态调整。
尽量使用cap参数创建切片
append操作是一件利器,它让切片类型部分满足了“零值可用”的理念。但从append的原理中我们也能看到重新分配底层数组并复制元素的操作代价还是挺大的,尤其是当元素较多的情况下。那么如何减少或避免为过多内存分配和复制付出的代价呢?
一种有效的方法是根据切片的使用场景对切片的容量规模进行预估,并在创建新切片时将预估出的切片容量数据以cap参数的形式传递给内置函数make:
s := make([]T, len, cap)
下面是一个使用cap参数和不使用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)
}
}
}
下面是性能基本测试运行的结果(Go 1.12.7;MacBook Pro:8核i5,16GB内存):
$go test -benchmem -bench=. slice_benchmark_test.go
goos: darwin
goarch: amd64
BenchmarkSliceInitWithoutCap-8 50000 36484 ns/op 386297 B/op 20 allocs/op
BenchmarkSliceInitWithCap-8 200000 9250 ns/op 81920 B/op 1 allocs/op
PASS
ok command-line-arguments 4.163s
由结果可知,使用带cap参数创建的切片进行append操作的平均性能(9250ns)是不带cap参数的切片(36 484ns)的4倍左右,并且每操作平均仅需一次内存分配。
切片是Go语言提供的重要数据类型,也是Gopher日常编码中最常使用的类型之一。切 片是数组的描述符,在大多数场合替代了数组,并减少了数组指针作为函数参数的使用。
append在切片上的运用让切片类型部分支持了“零值可用”的理念,并且append对切 片的动态扩容将Gopher从手工管理底层存储的工作中解放了出来。在可以预估出元素容量的前提下,使用cap参数创建切片可以提升append的平均操作性 能,减少或消除因动态扩容带来的性能损耗。