Go 语言中的 slice 类型#
1. 数组和 slice#
1.1. 数组#
数组是具有固定长度且拥有零个或者多个相同数据类型元素的序列。由于数组的长度固定,所以在 Go 中很少直接使用。
var arr1 [3]int // 定义一个长度为 3,元素类型为 int 的数组,元素的值为其类型的零值
arr2 := [3]int{1, 2, 3} // 定义一个长度为 3,元素类型为 int 的数组,并用给定的值初始化每个元素
arr3 := [...]int{4, 5, 6, 7} // 如果使用 ... 代替数组的长度,Go 会根据初始化时数组元素的数量确定数据的长度
arr4 := [5]int{1: 10, 3: 30} // 支持索引为 1、3 的元素的值,其他索引的值为其类型的零值
fmt.Println(len(arr1), arr1)
fmt.Println(len(arr2), arr2)
fmt.Println(len(arr3), arr3)
fmt.Println(len(arr4), arr4)输出:
3 [0 0 0]
3 [1 2 3]
4 [4 5 6 7]
5 [0 10 0 30 0]1.2. slice#
在 Go 中,slice 表示一个拥有相同类型元素的可变长度的序列,写作 []T,其中元素的类型都是 T。
slice 的定义位于 src/runtime/slice.go:
type slice struct {
array unsafe.Pointer
len int
cap int
}其中:
array:指向 slice 底层数组第一个元素的指针;len:slice 中当前存储的元素个数,可以用内置函数len()获取;cap:slice 底层数组的长度,可以用内置函数cap()获取。
1.3. 数组和 slice 的区别#
- 数组长度是固定的,使用前必须确定长度。slice 长度可变。
- 数组是基于连续的内存空间存储数据的,作为函数参数传递时传递的是数组的拷贝。slice 是一个包含指向底层数组的指针、长度和容量的结构体。
- 当两个数组长度相同,且数组元素类型可比较时,可以使用
==和!=来比较这两个数组;slice 只能和nil比较。
2. 创建 slice#
s1 := make([]int, 5) // 创建长度为 5,容量为 5(默认容量等于长度)的 slice
s2 := make([]int, 3, 10) // 创建长度为 3,显式指定容量为 10 的 slice
s3 := []int{1, 2, 3} // 创建长度为 3,容量为 3 的 slice
s4 := []int{99: 1} // 创建长度为 100(长度由最大索引决定),容量为 100 的 slice。索引为 99 的元素显式初始化为 1,其他元素为类型的零值(0)
fmt.Println(len(s1), cap(s1))
fmt.Println(len(s2), cap(s2))
fmt.Println(len(s3), cap(s3))
fmt.Println(len(s4), cap(s4))3. 向 slice 追加元素#
3.1. append 函数#
内置函数 append 用于将元素追加到 slice 的后面,返回追加后的 slice。
s1 = append(s1, 0) // 向 slice s1 追加元素
s2 = append(s2, 1, 2, 3) // 向 slice s2 追加多个元素
s3 = append(s3, s2...) // 向 slice s3 追加另一个 slice s2执行 append 函数后,可能出现以下两种情况:
- 当 slice 还有剩余容量时,
append直接追加,底层数组不变; - 当容量不足时,
append触发growslice,新分配一个更大的数组,并将原元素复制过去,原数组若无引用则被 GC 回收。
3.2. slice 的扩容策略#
Slice 的扩容策略位于 src/runtime/slice.go:
// growslice 为 slice 分配新的底层存储
// 参数:
// oldPtr = 指向 slice 底层数组的指针
// newLen = 扩容后的长度(oldLen + num)
// oldCap = 原始 slice 的容量
// num = 扩容的元素数量
// et = 元素类型
//
// 要求 newLen 大于 oldCap(即发生了 cap 变化)
func growslice(oldPtr unsafe.Pointer, newLen, oldCap, num int, et *_type) slice {
...
}
// nextslicecap 计算扩容后的 slice 容量
//
// 参数:
// newLen = 扩容后的长度
// oldCap = 原始 slice 的容量
func nextslicecap(newLen, oldCap int) int {
...
}在 Go 1.18 及之后:
- 如果 slice 扩容后的元素个数大于两倍扩容前的容量,新容量等于扩容后的元素个数;
- 如果扩容前的容量小于 256,新容量等于两倍扩容前的容量;
- 如果扩容前的容量大于等于 256,新容量初始值等于扩容前的容量,每次增加 25% + 192,直到值大于新长度。
在 Go 1.18 之前
- 如果 slice 扩容后元素个数大于两倍扩容前的容量,新容量等于扩容后元素个数;
- 如果扩容前的容量小于 1024,新容量等于两倍扩容前的容量;
- 如果扩容前的容量大于等于 1024,新容量初始值等于扩容前的容量,每次增加 25%,直到值大于新长度。
如何理解这次变动呢?比较下两个方式的扩容因子:
| 原始容量 | Go 1.18 之前扩容因子 | Go 1.18 及之后扩容因子 |
|---|---|---|
| < 256 | 2.0 | 2.0 |
| 256 | 2.0 | 2.0 |
| 512 | 2.0 | 1.625 |
| 1024 | 1.25 | 1.4375 |
| 2048 | 1.25 | 1.34375 |
| 4096 | 1.25 | 1.296875 |
| 超大容量 | 1.25 | 无限趋近 1.25 |
可以看到,旧方式中,旧容量从 1024 开始扩容因子突降至 1.25;新方式中,旧容量从 256 开始,扩容因子平滑过渡到 1.25。
3.3. 当 slice 作为函数参数#
当 slice 作为函数参数传递时,如果需要函数内修改 slice 的长度并对外部生效,通常应返回新的 slice,而不是传递 slice 的指针 *[]int(虽然也可行,但不符合习惯)。
当我们将 slice 作为函数参数传递时,实质上传递的是一个指向底层数组的指针、元素的个数(len)和底层数组的长度(cap)。函数内部对 len 和 cap 的修改在函数外部不会生效,而函数内部对 slice 元素的修改则需要分情况讨论。
如果在函数内部只修改了 slice 元素的值,在函数外部这些修改也会生效;如果在函数内部对 slice 进行了元素追加(修改了 len),则分为以下两种情况:
- 追加元素后 slice 的
cap没有改变。此时函数外部 slice 和函数内部 slice 指向的底层数组一致,所以函数内部的修改在函数外部也是生效的。但是函数外部 slice 的len没有改变,因此看不到使用append函数追加的元素内容。但函数内部对追加元素前长度范围内元素的修改会影响到函数外。 - 追加元素后 slice 的
cap发生了改变。此时append操作会重新创建一个新的底层数组,这种情况下函数内外的 slice 指向的底层数组不一样,对函数内 slice 的修改完全不影响函数外的 slice。
如果函数内部需要改变原 slice 的长度或容量,并需要在函数外部生效,可以选择返回一个新的 slice 让函数外部接收。
4. 拷贝 slice#
直接将一个 slice 赋值给另一个 slice,属于浅拷贝,两个 slice 还是使用同一个底层数组。如果需要深拷贝,可以使用 copy 函数或 append 函数。
使用 copy 函数:
s1 := []int{1, 2, 3}
s2 := make([]int, len(s1)) // 需要提前分配长度。如果 s2 的长度不足,则只拷贝 len(s2) 个元素
n := copy(s2, s1) // 返回拷贝的元素数量,也就是 len(src) 和 len(dst) 的最小值使用 append 函数:
s1 := []int{1, 2, 3}
s2 := append([]int(nil), s1...)copy 效率较高,适合已有目标切片或需要精确控制要拷贝的元素数量时使用。append 写法简洁,适合一步完成创建和拷贝(但可能因扩容机制而分配比所需更大的容量)。