切片
切片是一种数据结构,这种数据结构便于使用和管理数据集合。切片是围绕动态数组的概念构建的,可以按需自动增长和缩小。切片的动态增长是通过内置函数 append 来实现的。
这个函数可以快速且高效地增长切片。还可以通过对切片再次切片来缩小一个切片的大小。因为切片的底层内存也是在连续块中分配的,所以切片还能获得索引、迭代以及为垃圾回收优化的好处。
内部实现
切片是一个很小的对象,对底层数组进行了抽象,并提供相关的操作方法。切片有 3 个字段的数据结构,这些数据结构包含 Go 语言需要操作底层数组的元数据.如下图
这 3 个字段分别是指向底层数组的指针、切片访问的元素的个数(即长度)和切片允许增长到的元素个数(即容量)。
创建和初始化
Go 语言中有几种方法可以创建和初始化切片。是否能提前知道切片需要的容量通常会决定要如何创建切片。
1.make和切片字面量
一种创建切片的方法是使用内置的 make 函数。当使用 make 时,需要传入一个参数,指定切片的长度
1.1使用长度声明一个字符串切片
// 创建一个字符串切片
// 其长度和容量都是 5 个元素
slice := make([]string, 5)
如果只指定长度,那么切片的容量和长度相等。也可以分别指定长度和容量,如1.2
1.2 使用长度和容量声明整型切片
// 创建一个整型切片
// 其长度为 3 个元素,容量为 5 个元素
slice := make([]int, 3, 5)
分别指定长度和容量时,创建的切片,底层数组的长度是指定的容量,但是初始化后并不能访问所有的数组元素。例如1.2创建的切片可以访问 3 个元素,
而底层数组拥有 5 个元素。剩余的 2 个元素可以在后期操作中合并到切片,可以通过切片访问这些元素。
1.3 通过切片字面量来声明切片
// 创建字符串切片
// 其长度和容量都是 5 个元素
slice := []string{"Red", "Blue", "Green", "Yellow", "Pink"}
// 创建一个整型切片
// 其长度和容量都是 3 个元素
slice := []int{10, 20, 30}
当使用切片字面量时,可以设置初始长度和容量。要做的就是在初始化时给出所需的长度和容量作为索引。如1.4创建长度和容量都是100 个元素的切片。
1.4 使用索引声明切片
// 创建字符串切片
// 使用空字符串初始化第 100 个元素
slice := []string{99: ""}
使用切片
2.1 使用切片字面量来声明切片
// 创建一个整型切片
// 其长度和容量都是 5 个元素
slice := []int{10, 20, 30, 40, 50}
// 创建一个新切片
// 其长度为 2 个元素,容量为 4 个元素
newSlice := slice[1:3]
两个索引计算长度和容量
对底层数组容量是 k 的切片 slice[i:j]来说
长度: j - i
容量: k - i
对底层数组容量是 5 的切片 slice[1:3]来说
长度: 3 - 1 = 2
容量: 5 - 1 = 4
执行完代码2.1 中的切片动作后,我们有了两个切片,它们共享同一段底层数组,但通过不同的切片会看到底层数组的不同部分, 如下图
第一个切片 slice 能够看到底层数组全部 5 个元素的容量,不过之后的 newSlice 就看不到。对于 newSlice,底层数组的容量只有 4 个元素。newSlice 无法访问到它所指向的底层数组的第一个元素之前的部分。
所以,对 newSlice 来说,之前的那些元素就是不存在的。现在两个切片共享同一个底层数组。如果一个切片修改了该底层数组的共享部分,另一个切片也能感知到, 如下
// 创建一个整型切片
// 其长度和容量都是 5 个元素
slice := []int{10, 20, 30, 40, 50}
创建一个新切片
其长度是 2 个元素,容量是 4 个元素
newSlice := slice[1:3]
// 修改 newSlice 索引为 1 的元素
// 同时也修改了原来的 slice 的索引为 2 的元素
newSlice[1] = 35
把 35 赋值给 newSlice 的第二个元素(索引为 1 的元素)的同时也是在修改原来的 slice的第 3 个元素(索引为 2 的元素)
slice变为[]int{10, 20, 35, 40, 50}, newSlice变为[]int{20, 35}
2.2 切片append扩容
// 其长度和容量都是 5 个元素
slice := []int{10, 20, 30, 40, 50}
// 创建一个新切片
// 其长度为 2 个元素,容量为 4 个元素
newSlice := slice[1:3]
// 使用原有的容量来分配一个新元素
// 将新元素赋值为 60
newSlice = append(newSlice, 60)
append 操作完成后,两个切片和底层数组的布局如图
因为 newSlice 在底层数组里还有额外的容量可用,append 操作将可用的元素合并到切片的长度,并对其进行赋值。由于和原始的 slice 共享同一个底层数组,slice 中索引为3的元素的值也被改动了。
如果切片的底层数组没有足够的可用容量,append 函数会创建一个新的底层数组,将被引用的现有的值复制到新数组里,再追加新值, 如下
// 创建一个整型切片
// 其长度和容量都是 4 个元素
slice := []int{10, 20, 30, 40}
// 向切片追加一个新元素
// 将新元素赋值为 50
newSlice := append(slice, 50)
当这个 append 操作完成后,newSlice 拥有一个全新的底层数组,这个数组的容量是原来的两倍, 如下图
函数 append 会智能地处理底层数组的容量增长。在切片的容量小于 1000 个元素时,总是会成倍地增加容量。一旦元素个数超过1000,容量的增长因子会设为 1.25,也就是会每次增加 25%的容量。
随着语言的演化,这种增长算法可能会有所改变。
2.3 使用 3 个索引创建切片
// 创建字符串切片
// 其长度和容量都是 5 个元素
source := []string{"Apple", "Orange", "Plum", "Banana", "Grape"}
// 将第三个元素切片,并限制容量
// 其长度为 1 个元素,容量为 2 个元素
slice := source[2:3:4]
这个切片操作执行后,新切片里从底层数组引用了 1 个元素,容量是 2 个元素。具体来说,新切片引用了 Plum 元素,并将容量扩展到 Banana 元素,如下图
三个索引计算长度和容量
对于 slice[i:j:k] 或 [2:3:4]
长度: j – i 或 3 - 2 = 1
容量: k – i 或 4 - 2 = 2
和之前一样,第一个值表示新切片开始的元素的索引位置,这个例子中是 2。第二个值表示开始的索引位置(2)加上希望包括的元素的个数(1),
2+1 的结果是 3,所以第二个值就是 3。为了设置容量,从索引位置 2 开始,加上希望容量中包含的元素的个数(2),就得到了第三个值 4。
设置容量大于已有容量的语言运行时错误
如果试图设置的容量比可用的容量还大,就会得到一个语言运行时错误,如下
// 这个切片操作试图设置容量为 4
// 这比可用的容量大
slice := source[2:3:6]
Runtime Error:
panic: runtime error: slice bounds out of range
因为内置函数 append 会首先使用可用容量。一旦没有可用容量,会分配一个新的底层数组。这导致很容易忘记切片间正在共享同一个底层数组。
一旦发生这种情况,对切片进行修改,很可能会导致随机且奇怪的问题。对切片内容的修改会影响多个切片,却很难找到问题的原因。
设置长度和容量一样的好处
如果在创建切片时设置切片的容量和长度一样,就可以强制让新切片的第一个 append 操作创建新的底层数组,与原有的底层数组分离。
新切片与原有的底层数组分离后,可以安全地进行后续修改,如下
// 创建字符串切片
// 其长度和容量都是 5 个元素
source := []string{"Apple", "Orange", "Plum", "Banana", "Grape"}
// 对第三个元素做切片,并限制容量
// 其长度和容量都是 1 个元素
slice := source[2:3:3]
// 向 slice 追加新字符串
slice = append(slice, "Kiwi")
如果不加第三个索引,由于剩余的所有容量都属于 slice,向 slice 追加 Kiwi 会改变原有底层数组索引为 3 的元素的值 Banana。不过在代码中我们限制了 slice 的容量为 1。
当我们第一次对 slice 调用 append 的时候,会创建一个新的底层数组,这个数组包括 2 个元素,并将水果 Plum 复制进来,再追加新水果 Kiwi,并返回一个引用了这个底层数组的新切片
因为新的切片 slice 拥有了自己的底层数组,所以杜绝了可能发生的问题。我们可以继向新切片里追加水果,而不用担心会不小心修改了其他切片里的水果。同时,也保持了为切片申请新的底层数组的简洁。
上面代码操作之后的新切片的表示如下图
Reference:《Go语言实战》
有疑问加站长微信联系(非本文作者)