本文已参与「新人创作礼活动,一起开启掘金创作之路。
背景
容器是用来存储一组相关事物,Go语言里slice是非常有用的内置的数据结构,我们在日常的代码编写不可能绕过它们,而想要用好slice,必须要理解它的特性,今天我先说说我对切片(slice)的理解
WHAT
什么是切片(slice)? 切片([slice]是 Golang 中一种比较特殊的数据结构,这种数据结构更便于使用和管理数据集合。切片是围绕动态数组的概念构建的,可以按需自动增长和缩小。切片的动态增长是通过内置函数 append() 来实现的,这个函数可以快速且高效地增长切片,也可以通过对切片再次切割,缩小一个切片的大小。因为切片的底层也是在连续的内存块中分配的,所以切片还能获得索引、迭代以及为垃圾回收优化的好处。
WHY
Go语言中的切片(sice)结构的本质是对数组的封装,它描述一个数组的片段。 无论是数组还是切片,都可以通过下标来访问单个元素。数组是定长的,长度定义好之后,不能再更改。在Go语言中,数组是不常见的,因为其长度是类型的一部分,限制了它的表达能力,比如Bjm和FJin就是不同的类型。而切片则非常灵话,它可以动态地扩客,且切片的类型和长度无关。例如:
package main
import "fmt"
func main() {
no1 := [1]int{1}
no2 := [2]int{2,3}
if no1 == no2 {
fmt.Println("类型一致")
}
}
如果你的编辑器有错误提示,相信这几行代码都不用写完就有错误提示了,更不会通过编译器编译,因为no1和no2的长度不同,根本不是同一类型,因此不能比较
数组是一片连续的内存,而切片实际上是一个结构体,包含三个字段,长度,容量及一个底层数组,源码是这样的
type slice struct {
array unsafe.Pointer
len int
cap int
}
HOW
1. 切片的成员操作
package main
import "fmt"
func main() {
slice := []int{0, 1, 2, 3, 4, 5, 6, 7, 8, 9}
//slice的容量获取
fmt.Println("容量的获取", cap(slice)) //容量的获取 10
fmt.Println("长度的获取", len(slice)) //长度的获取 10
//slice的截取 截取的是半包含半不包含
d1 := slice[2:5]
fmt.Println("截取1", d1) //截取1 [2 3 4]
d2 := d1[2:6:7]
fmt.Println("截取2", d2) //截取2 [4 5 6 7] s2从s1的索引2 (闭区间)到索引6 (开区间,元素真正取到索引5), 容量到索引7 (开区间, 真正到索引 6),为5。
//向slice中添加元素 使用append函数
d2 = append(d2, 100)
d2 = append(d2, 200)
//重新赋值
d1[2] = 20
fmt.Println("新增1", d2) //新增1 [4 5 6 7 100]
fmt.Println("新增2", d2) //新增2 [4 5 6 7 100 200]
fmt.Println("赋值", slice) //赋值 [0 1 2 3 20 5 6 7 100 9]
}
从最后一次的打印结果来看肯定会有人为为什么没有200这个数据,因为slice,d1,d2三者的元素都指向同一个一个底层数组,接着向d2中添加一个元素100,此时的d2的容量刚好够用,可以直接追加,不过要修改原数组中对应位置的值,此时,d2的容量不够用,需要进行扩容。于是,d2“另起炉灶”, 将原来的元素复制到新的位置扩大自己的容量。并且为了应对未来可能的append带来的再一次扩容, d2会在此次扩容的时候多留一些buffer,将新的容量扩大到原来的2倍。注意,d2此时的底层数组元素和slice. d1已经没有关系了。最后,修改d1索引为2位的元素:
d1[2] = 20
这次操作只会影响原始数组相应位置的元素,影响不到d2了,它已经“远走高飞”了,
最后执行打印d1时,只会打印d1长度内的元素,所以只打印出了3个元素,尽管底层数组不止3个
2. 切片的扩容
一般都是在向切片追加了无素之后,由于容量不足,才会引起扩容。向切片追加元素调用的是append 函数。append 函数的原型如下:
// The append built-in function appends elements to the end of a slice. If
// it has sufficient capacity, the destination is resliced to accommodate the
// new elements. If it does not, a new underlying array will be allocated.
// Append returns the updated slice. It is therefore necessary to store the
// result of append, often in the variable holding the slice itself:
// slice = append(slice, elem1, elem2)
// slice = append(slice, anotherSlice...)
// As a special case, it is legal to append a string to a byte slice, like this:
// slice = append([]byte("hello "), "world"...)
func append(slice []Type, elems ...Type) []Type
Append函数的参数长度可变,因此可以追加多个值到slice 中,还可以在切片后面追加“...
符号直接传入slice, 即追加切片里所有的元素。
Append函数的返回值是一个新的切片,Go语言的编译器不允许调用了append函数后不使用返回值。所以下面的用法是错的,不能通过编译:
append(slice, elemI, elem2)
append(slice, anotherSlice..)
使用append函数可以向slice 追加元素,实际上是往底层数组相应的位置放置要追加的元素。 但是底层数组的长度是固定的,如果索引len-1 所指向的元素已经是底层数组的最后一个元素, 那就不能再继续放置新的元素了。
这时,slice 会整体迁移到新的位置,并且新底层数组的长度也会增加,使得可以继续放 置新增的元素。同时,为了应对未来可能再次发生的append 操作,新的底层数组的长度,也 就是新slice 的容量需要预留一定的 buffer. 否则, 每次添加元素的时候,都会发生迁移,成本太高。
新slice预留的buffer 大小是有一定规律的。 注意,下面这些说法是不准确的:
说法1:当原slice 容量小于1024 的时候,新slice 容量变成原来的2倍:
说法2:当原slice容量超过1024, 新slice 容量变成原来的1.25倍。
为了说明切片的扩容规律,首先通过下面的程序来验证一下扩容的行为:
package main
import "fmt"
func main() {
s := make([]int, 0)
oldCap := cap(s)
for i := 0; i < 2048; i++ {
s = append(s, i)
newCap := cap(s)
if newCap != oldCap {
fmt.Printf("[%d->%4d] cap=%-4d | after append %-4d cap = %-4d\n", 0, i-1, oldCap, i, newCap)
oldCap = newCap
}
}
}
首先创建一个空的slice: s, 接着,在一个循环里不断地向它append 新的元素。同时,记录容量的变化,并且每当容量发生变化的时候,记录下老的容量,以及添加完元素之后新的容量,并且记下此时向s添加的元素。这样就可以观察,新老S的容量变化情况,从而找出规律。代码的运行结果如下:
[0-> -1] cap=0 | after append 0 cap = 1
[0-> 0] cap=1 | after append 1 cap = 2
[0-> 1] cap=2 | after append 2 cap = 4
[0-> 3] cap=4 | after append 4 cap = 8
[0-> 7] cap=8 | after append 8 cap = 16
[0-> 15] cap=16 | after append 16 cap = 32
[0-> 31] cap=32 | after append 32 cap = 64
[0-> 63] cap=64 | after append 64 cap = 128
[0-> 127] cap=128 | after append 128 cap = 256
[0-> 255] cap=256 | after append 256 cap = 512
[0-> 511] cap=512 | after append 512 cap = 1024
[0->1023] cap=1024 | after append 1024 cap = 1280
[0->1279] cap=1280 | after append 1280 cap = 1696
[0->1695] cap=1696 | after append 1696 cap = 2304
在老s容量小于1024 的时候,新s的容量的确是老s的2倍,目前还算正确。但当老s容量大于等于1024的时候,情况就有变化了。例如,向s中添加元素1280 的容量为1280, 新s的容量则变成了1696, 两者并不是1.25 倍的关系( 1696/1280=1.32 加完1696后,新的容量2304当然也不是1696 的1.25倍(2304/1696=1.358)。
要想弄清真实的扩容规律是怎样的,需要深入Go源码,研究一下扩容函数的具体逻辑切片的本质上是一个运行时特性,因此Go语言的编译器在针对扩容行为发生时会将其跳转到扩容函数
源码解析
Slice.go文件中Growslice()函数
newcap := old.cap
doublecap := newcap + newcap
if cap > doublecap {
newcap = cap
} else {
if old.cap < 1024 {
newcap = doublecap
} else {
// Check 0 < newcap to detect overflow
// and prevent an infinite loop.
for 0 < newcap && newcap < cap {
newcap += newcap / 4
}
// Set newcap to the requested cap when
// the newcap calculation overflowed.
if newcap <= 0 {
newcap = cap
}
}
}
代码的后半部分还对neweap 进行了内存对齐,而这个和内存分配策略相关。进行内存对齐之后新s的容量要大于等于老s容量的2倍或者1.25 倍。
之后,向Go内存管理器申请内存,将老s中的数据复制过去,并且将append的元素添加到新的底层数组中。最后,向gorwslice函数调用者返回一个新的切片,这个切片的长度并没有变化,而容量却增大了。