购买
下载掌阅APP,畅读海量书库
立即打开
畅读海量书库
扫码下载掌阅APP

第13条
了解切片实现原理并高效使用

每当你花费大量时间使用某种特定工具时,深入了解它并了解如何高效地使用它是很值得的。

——佚名

slice,中文多译为 切片 ,是Go语言在数组之上提供的一个重要的抽象数据类型。在Go语言中,对于绝大多数需要使用数组的场合,切片实现了完美替代。并且和数组相比,切片提供了更灵活、更高效的数据序列访问接口。

13.1 切片究竟是什么

在对切片一探究竟之前,我们先来简单了解一下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语言中,数组更多是“退居幕后”,承担的是底层存储空间的角色;而切片则走向“前台”,为底层的存储(数组)打开了一个访问的“窗口”(见图13-1)。

图13-1 切片打开了访问底层数组的“窗口”

因此,我们可以称 切片是数组的“描述符” 。切片之所以能在函数参数传递时避免较大性能损耗,是因为它是“描述符”的特性,切片这个描述符是固定大小的,无论底层的数组元素类型有多大,切片打开的窗口有多长。

下面是切片在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)

图13-2展示了切片s在运行时层面的内部表示。

图13-2 切片运行时表示(新切片)

我们看到通过上述语句创建的切片,编译器会自动为切片建立一个底层数组,如果没有在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]

图13-3展示了切片s的内部。

图13-3 切片运行时表示(以已有数组为底层存储的切片)

我们看到切片s打开了一个操作数组u的窗口,我们通过s看到的第一个元素是u[3],通过s能看到并操作的数组元素个数为4个(high-low)。切片的容量值(cap)取决于底层数组的长度。从切片s的第一个元素s[0],即u[3]到数组末尾一共有7个存储元素的槽位,因此切片s的cap为7。也可以为一个已存在数组建立多个操作数组的切片,如图13-4所示。

图13-4中的三个切片s1、s2、s3都是数组u的描述符,因此无论通过哪个切片对数组进行的修改操作都会反映到其他切片中。比如:将s3[0]置为24,那么s1[2]也会变成24,因为s3[0]直接操作的是底层数组u的第四个元素u[3]。

图13-4 切片运行时表示(基于同一数组建立多个切片)

还可以通过语法s[low: high]基于已有切片创建新的切片,这被称为切片的 reslicing ,如图13-5所示。新创建的切片与原切片同样是共享底层数组的,并且通过新切片对数组的修改也会反映到原切片中。

图13-5 切片运行时表示(基于切片s1建立新切片s2)

当切片作为函数参数传递给函数时,实际传递的是切片的内部表示,也就是上面的runtime.slice结构体实例,因此无论切片描述的底层数组有多大,切片作为参数传递带来的性能损耗都是很小且恒定的,甚至小到可以忽略不计,这就是函数在参数中多使用切片而不用数组指针的原因之一。而另一个原因就是切片可以提供比指针更为强大的功能,比如下标访问、边界溢出校验、动态扩容等。而C程序员最喜爱的指针本身在Go语言中的功能受到了限制,比如不支持指针算术运算等。

13.2 切片的高级特性:动态扩容

如果仅仅是提供通过下标值来操作元素的类数组操作接口,那么切片也不会在Go中占据重要的位置。Go切片还支持一个重要的高级特性: 动态扩容 。在第11条中我们提到过切片类型是部分满足零值可用理念的,即零值切片也可以通过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值却呈现出不规则的变化。通过图13-6我们更容易看清楚多次append操作究竟是如何让切片进行动态扩容的。

图13-6 切片的动态扩容

我们看到append会根据切片对底层数组容量的需求对底层数组进行动态调整。

1)最初s初值为零值(nil),此时s没有绑定底层数组。

2)通过append操作向切片s添加一个元素11,此时append会首先分配底层数组u1(数组长度1),然后将s内部表示中的array指向u1,并设置len = 1,cap = 1。

3)通过append操作向切片s再添加一个元素12,此时len(s) = 1,cap(s) = 1,append判断底层数组剩余空间不满足添加新元素的要求,于是创建了一个新的底层数组u2,长度为2(u1数组长度的2倍),并将u1中的元素复制到u2中,最后将s内部表示中的array指向u2,并设置len = 2,cap = 2。

4)通过append操作向切片s再添加一个元素13,此时len(s) = 2,cap(s) = 2,append判断底层数组剩余空间不满足添加新元素的要求,于是创建了一个新的底层数组u3,长度为4(u2数组长度的2倍),并将u2中的元素复制到u3中,最后将s内部表示中的array指向u3,并设置len = 3,cap为u3数组长度,即4。

5)通过append操作向切片s再添加一个元素14,此时len(s) = 3,cap(s) = 4,append判断底层数组剩余空间满足添加新元素的要求,于是将14放在下一个元素的位置(数组u3末尾),并将s内部表示中的len加1,变为4。

6)通过append操作向切片s添加最后一个元素15,此时len(s) = 4,cap(s) = 4,append判断底层数组剩余空间不满足添加新元素的要求,于是创建了一个新的底层数组u4,长度为8(u3数组长度的2倍),并将u3中的元素复制到u4中,最后将s内部表示中的array指向u4,并设置len = 5,cap为u4数组长度,即8。

我们看到append会根据切片的需要,在当前底层数组容量无法满足的情况下,动态分配新的数组,新数组长度会按一定算法扩展(参见$GOROOT/src/runtime/slice.go中的growslice函数)。新数组建立后,append会把旧数组中的数据复制到新数组中,之后新数组便成为切片的底层数组,旧数组后续会被垃圾回收掉。这样的append操作有时会给Gopher带来一些困惑,比如通过语法u[low: high]形式进行数组切片化而创建的切片,一旦切片cap触碰到数组的上界,再对切片进行append操作,切片就会和原数组解除绑定:


// chapter3/sources/slice_unbind_orig_array.go

func main() {
u := []int{11, 12, 13, 14, 15}
fmt.Println("array:", u) // [11, 12, 13, 14, 15]
s := u[1:3]
fmt.Printf("slice(len=%d, cap=%d): %v\n", len(s), cap(s), s) // [12, 13]
s = append(s, 24)
fmt.Println("after append 24, array:", u)
fmt.Printf("after append 24, slice(len=%d, cap=%d): %v\n", len(s), cap(s), s)
s = append(s, 25)
fmt.Println("after append 25, array:", u)
fmt.Printf("after append 25, slice(len=%d, cap=%d): %v\n", len(s), cap(s), s)
s = append(s, 26)
fmt.Println("after append 26, array:", u)
fmt.Printf("after append 26, slice(len=%d, cap=%d): %v\n", len(s), cap(s), s)

s[0] = 22
fmt.Println("after reassign 1st elem of slice, array:", u)
fmt.Printf("after reassign 1st elem of slice, slice(len=%d, cap=%d): %v\n", len(s), cap(s), s)
}

运行这段代码,得到如下结果:


$go run slice_unbind_orig_array.go
array: [11 12 13 14 15]
slice(len=2, cap=4): [12 13]
after append 24, array: [11 12 13 24 15]
after append 24, slice(len=3, cap=4): [12 13 24]
after append 25, array: [11 12 13 24 25]
after append 25, slice(len=4, cap=4): [12 13 24 25]
after append 26, array: [11 12 13 24 25]
after append 26, slice(len=5, cap=8): [12 13 24 25 26]
after reassign 1st elem of slice, array: [11 12 13 24 25]
after reassign 1st elem of slice, slice(len=5, cap=8): [22 13 24 25 26]

我们看到在添加元素25之后,切片的元素已经触碰到底层数组u的边界;此后再添加元素26,append发现底层数组已经无法满足添加新元素的要求,于是新创建了一个底层数组(数组长度为cap(s)的2倍,即8),并将原切片的元素复制到新数组中。在这之后,即便再修改切片中的元素值,原数组u的元素也没有发生任何改变,因为此时切片s与数组u已经解除了绑定关系,s已经不再是数组u的描述符了。

13.3 尽量使用cap参数创建切片

append操作是一件利器,它让切片类型部分满足了“零值可用”的理念。但从append的原理中我们也能看到重新分配底层数组并复制元素的操作代价还是挺大的,尤其是当元素较多的情况下。那么如何减少或避免为过多内存分配和复制付出的代价呢?一种有效的方法是根据切片的使用场景对切片的容量规模进行预估,并在创建新切片时将预估出的切片容量数据以cap参数的形式传递给内置函数make:


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

下面是一个使用cap参数和不使用cap参数的切片的性能基准测试:


// chapter3/sources/slice_benchmark_test.go
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倍左右,并且每操作平均仅需一次内存分配。

因此,如果可以预估出切片底层数组需要承载的元素数量,强烈建议在创建切片时带上cap参数。

小结

切片是Go语言提供的重要数据类型,也是Gopher日常编码中最常使用的类型之一。切片是数组的描述符,在大多数场合替代了数组,并减少了数组指针作为函数参数的使用。

append在切片上的运用让切片类型部分支持了“零值可用”的理念,并且append对切片的动态扩容将Gopher从手工管理底层存储的工作中解放了出来。

在可以预估出元素容量的前提下,使用cap参数创建切片可以提升append的平均操作性能,减少或消除因动态扩容带来的性能损耗。 cfg2iYLvwQACL3K3qX7tbJQSpoylU66GOOcHWqsoZR6OYAXLRUO5Ap/Tvxq1Pfc3

点击中间区域
呼出菜单
上一章
目录
下一章
×