切片 Slice
为什么要有切片呢?
Go 语言中有两个长得很像的数据结构,数组和切片:
// 1、长度为 5 的 int 数组
var array [5]int = [5]int{1, 2, 3}
// 2、长度为 3 的 int 切片
var slice []int = []int{1, 2, 3}
array[2] = 10
slice[2] = 10
从外形上来说,[]
内有数字的是数组,[]
内没有数字是切片。不认真看,还真容易把它们当做是一个东西;从使用上来说,它们都能根据 index
进行快速访问,使用方式都是 [index]
。
因为数组拥有快速访问的特点,各语言基本都会内置这一基础的数据结构。这里不对数组做过多的介绍。可是有了数组,为什么还需要弄一个看起来那么像的切片呢?我认为最主要有两个原因。
- 数组在创建时会分配一段连续且固定大小的内存空间,以确保能进行快速访问。然而,在许多情况下,我们并不知道需要多大的内存空间来存储数据,更希望能够在使用过程中动态地扩容。
- 在 Go 语言中,数组是值类型,赋值或传递时会进行拷贝,对大数组来说,拷贝的开销很大。
假如需要你要设计一个数据结构,用于解决上述两个问题,你会如何设计呢?跟着我来思考。如果无法根据需求动态扩容,我们是否可以封装一种动态扩缩容的容器呢?如果直接传递数组会有拷贝开销,那么是否可以传递指针来实现类似引用传递的效果呢?
基于这些背景,切片应运而生,可以把它看作一个弱化的动态数组,看做是 Java 中减少了一些方法的 ArrayList
。但由于 Go 语言本身的特性,使用切片时需要注意一些事项,在此之前先来了解 Go 中是如何表示 Slice 的。
底层是如何表示的
切片的底层表示非常简单,只有三个字段:指向底层数组的指针(array)、切片的长度(len)、切片的容量(cap)。
长度表示当前存储的元素数量,容量表示可以容纳的总元素数量,但长度不一定等于底层数组的容量,它们的关系是 len <= cap
,可以通过 len() 和 cap()
两个函数获取长度和容量:
还有一个用于引用底层数组的指针。为什么要用指针表示底层数组呢?一方面是为了能够处理任意类型的数据,另一方面是为了方便扩容和实现类似引用传递的效果。因此,对底层数组的引用是一个 unsafe.Pointer
指针类型。
上面就是切片的底层表示,下面来看看如何对切片进行操作,进一步学习切片。
切片的操作
先创建一个切片
在 Go 语言中,可以使用字面量和 make()
函数两种方式来创建切片。
使用字面量的方式创建切片具体说来也有两种方式:一是直接使用字面量创建一个长度和初始容量相等的切片,另一种是在已有数组和切片上切分出一个新的切片。
var array [5]int = [5]int{1, 2, 3} // 长度为 5 的 int 数组
// 方式一:直接使用字面量创建,len = cap = 3
var slice []int = []int{1, 2, 3}
// 方式二:从现有数组 or 切片上切分出一个新的切片
var slice2 []int = array[:] // 在数组上切分,[0, len)
var slice3 []int = slice[1:2] // 在切片上切分,[1, 2)
创建了一个 array
数组,然后创建了三个切片 slice slice2 slice3
。其中 slice 是直接使用字面量创建的,slice2 是在现有数组 array 上切分出来的,slice3 是在现有切片 slice 上切分出来的。将上面的四个变量用图像描述出来:
从上图可以看到,使用字面量从已有的数组或切片中切分出一个新切片时,初始阶段会共享底层数组,操作新切片可能会影响到旧的数据。不熟悉底层可能会导致隐藏的 bug,比如我们操作 slice3
:
// 在 slice3 的末尾添加一个元素
slice3 = append(slice3, 20)
便于理解,将这个过程用图片表达:
可以看到,我们本身是想操作 slice3
的,但是影响到了它的旧切片 slice
,这种操作很危险。因此,推荐使用 make()
函数或直接使用字面量的方式来创建切片:
// 推荐方式一:直接使用字面量创建,len = cap = 3
var slice1 []int = []int{1, 2, 3}
// 推荐方式二:使用 make 函数创建切片,len = 2 cap = 10
var slice2 []int = make([]int, 2, 10)
使用这两种方式创建的切片,编译器在创建时会根据容量和长度信息,分配并初始化相应大小的数组,底层引用的数组是新创建的,不容易引发错误。有了切片,那么如何向切片中添加元素呢?
再来添加元素
添加元素通常有两种方式。一种类似于数组,直接使用索引赋值,但要确保索引范围在 [0, len)
内,否则会发生越界 panic。这种方式不会触发扩容,本质上是修改底层数组对应索引的值。
另一种是使用 append()
方法追加元素,这会在切片底层数组的末尾添加一个或多个元素,可能导致容量不足而触发扩容。
那么容量不够了,怎么扩容勒?它的底层引用的也是一个数组,能否直接申请一段空间,拼接在旧数组上面吗?不用想了,这肯定不行。来看看具体的扩容思路:
扩容的大致思路是:当需要扩容时,申请一块更大的内存数组,将旧底层数组的值逐个拷贝到新数组中,然后切片切换到新的底层数组的引用。具体要申请多大的内存取决于不同版本的策略。
比如这样创建一个这样的切片:
slice := make([]int, 0, 2)
// 挨个添加元素,添加 1 ~ 6
for i := 1; i <= 6; i++ {
slice = append(slice, i)
}
直接看图:
在上面的图片中,可以看到这段操作有两次扩容,每次扩容的时候,是重新申请一段内存空间,然后将旧数组的值拷贝到新数组,最后将 Slice 的 array
引用指向新的数组。
但在 Go 语言中,如果切片是通过数组或旧切片切分出来,切片引用的底层数组与旧数组是同一个。那么会尽最大努力利用旧数组的空间。
比如这样的切分slice := array[begin : end]
,那么 slice 初始的长度和容量的关系是这样的: len(slice) = end - begin 、 cap(slice) = len(array) - begin
。来看一个例子:
如上图所示,这里只有切片的初始长度是 1,初始容量是 4。那么会把容量都填满后,才会触发扩容。而在扩容前,底层引用的还是旧数组。直到扩容后,才会更换新数组。
了解了切片的原理后,来看看它的扩容策略是什么样的呢?
扩容的策略是怎样的?
关于切片的扩容机制,一开始我在网上看到的很多资料都说,在元素数量小于 1024 时,切片的容量会成倍增长,而在 1024 之后,容量增长为旧容量的 1.25 倍。然而,当我自己进行测试时发现与这个规则不同。经过进一步验证,我得出的结论是:切片的扩容机制涉及一个扩容因子,它以旧容量乘以扩容因子的方式来确定新的容量。
具体而言,在元素数量小于等于 256 时,可以认为扩容因子是 2,但在 256 之后,扩容因子会逐步递减为 1.6 几、1.4 几,最终无限趋近于 1.25。后来我查阅了相关资料,发现后一种扩容规则是在 Go 语言版本 1.18 之后引入的,之前的版本采用的是第一种扩容规则。
如下单测,是新版本的扩容机制,容量的过渡更加平滑:
由于扩容需要重新分配内存,并将旧数组的元素逐个拷贝到新数组中,因此扩容会带来较大的开销。无论是哪种扩容规则,当元素数量较少时,为了减少频繁的扩容,切片会采用成倍增长的方式。而当达到一定阈值后,由于元素拷贝和新内存的开销变得非常显著,扩容倍数会减少至 1.25。新版本的扩容机制更加平滑地过渡,以更好地平衡性能和内存开销的考量。
最后再来总结一下切片的扩容规则:
了解了切片的原理,最后再来看一些关于切片使用时的注意事项。
使用时要注意呐
由于 Go 语言具有独特的语法,使用切片时容易出现许多隐藏的 bug,以下是一些需要注意的地方:
- 由于使用字面量的方式创建切片时可能共享底层数组,在操作新切片时可能会影响旧数组。因此,尽量使用
make()
函数创建切片。 - 由于切片的扩容可能会申请新的数组并进行拷贝,开销较大,最好能够预估容量,减少扩容次数。
- 许多资料错误地认为切片是引用传递,但实际上切片本身是拷贝传递。这是因为切片底层数组是一个指针,多个地方操作的是同一内存地址,所以在使用上类似于引用传递。
- 使用
for-range
遍历切片时,第二个遍历参数是一个拷贝的变量,对它的操作不会影响原切片。 - 在切片中,所有索引相关的操作必须使用合法的索引,否则会引发越界的 panic。
总结
- 数组不能够动态扩容,传递时会拷贝数组,开销很大。这时候出现了类似于动态数组的切片,能够动态的扩充容量,可以类似达到引用传递的效果。
- 切片的底层由指针 array、长度 len 和容量 cap 组成。
- 创建切片可以使用字面量和 make 两种方式,通过字面量创建可能会共享底层数组,导致意外的 bug,推荐使用 make 方法创建。
- 往切片中添加元素可能会触发扩容,扩容大致的思路是:申请更大的数组,将旧数组的元素拷贝到新数组,array 指针指向新数组。
- 扩容策略和版本有关,但不论是早期版本的还是现在的版本,在初期时都是翻倍扩容,到达一定阈值后,扩容的倍数会逐步减小到 1.25 倍。