Skip to content

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 底层数组第一个元素的指针;
  • lenslice 中当前存储的元素个数,可以用内置函数 len() 获取;
  • capslice 底层数组的长度,可以用内置函数 cap() 获取。

1.3. 数组和 slice 的区别

  1. 数组长度是固定的,使用前必须确定长度。slice 长度可变。
  2. 数组是基于连续的内存空间存储数据的,作为函数参数传递时传递的是数组的拷贝。slice 是一个包含指向底层数组的指针、长度和容量的结构体。
  3. 当两个数组长度相同,且数组元素类型可比较时,可以使用 ==!= 来比较这两个数组;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 及之后:

  1. 如果 slice 扩容后的元素个数大于两倍扩容前的容量,新容量等于扩容后的元素个数;
  2. 如果扩容前的容量小于 256,新容量等于两倍扩容前的容量;
  3. 如果扩容前的容量大于等于 256,新容量初始值等于扩容前的容量,每次增加 25% + 192,直到值大于新长度。

在 Go 1.18 之前

  1. 如果 slice 扩容后元素个数大于两倍扩容前的容量,新容量等于扩容后元素个数;
  2. 如果扩容前的容量小于 1024,新容量等于两倍扩容前的容量;
  3. 如果扩容前的容量大于等于 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)。函数内部对 lencap 的修改在函数外部不会生效,而函数内部对 slice 元素的修改则需要分情况讨论。

如果在函数内部只修改了 slice 元素的值,在函数外部这些修改也会生效;如果在函数内部对 slice 进行了元素追加(修改了 len),则分为以下两种情况:

  1. 追加元素后 slicecap 没有改变。此时函数外部 slice 和函数内部 slice 指向的底层数组一致,所以函数内部的修改在函数外部也是生效的。但是函数外部 slicelen 没有改变,因此看不到使用 append 函数追加的元素内容。但函数内部对追加元素前长度范围内元素的修改会影响到函数外。
  2. 追加元素后 slicecap 发生了改变。此时 append 操作会重新创建一个新的底层数组,这种情况下函数内外的 slice 指向的底层数组不一样,对函数内 slice 的修改完全不影响函数外的 slice

如果函数内部需要改变原 slice 的长度或容量,并需要在函数外部生效,可以选择返回一个新的 slice 让函数外部接收。

4. 遍历 slice

4.1. 通过索引遍历

for i := 0; i < len(s); i++ {
    fmt.Println(s[i])
}

这种方式可以修改 slice 中元素的值。

4.2. for-range 遍历

// 遍历索引和值
for i, v := range s {
    fmt.Printf("index: %d, value: %d\n", i, v)
}

// 只遍历索引
for i := range s {
    fmt.Println(s[i])
}

// 只遍历值
for _, v := range s {
    fmt.Println(v)
}

使用 for-range 遍历的话,返回的 vslice 中元素的值的拷贝。对 v 的修改不会影响到 slice 中的内容。

5. 切片 slice

切片允许你从现有 slice 或数组中创建新的 slice,而无需复制底层数据。

newSlice := s[low:high]     // 从 low 到 high
newSlice := s[low:high:cap] // 从 low 到 high
newSlice := s[:high]        // 从开头到 high
newSlice := s[low:]         // 从 low 到末尾
newSlice := s[:]            // 整个切片

通过切片创建的新 slice,和原 slice 共享一个底层数组。

  • 对于 s[low:high] 的写法,新 slice 的容量为 cap(s) - low
  • 对于 s[low:high:cap] 的写法,在切片的基础上,限制新 slice 能访问到原底层数组的最大索引位置。0 <= low <= high <= cap <= cap(s)。新 slice 的容量为 cap - low

对新 slice 进行 append 操作,超过新 slice 的容量后,新 slice 会触发重新分配,此时新老 slice 不再共享底层数组。

6. 拷贝 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 写法简洁,适合一步完成创建和拷贝(但可能因扩容机制而分配比所需更大的容量)。