ARTICLE / Go

Go Slice 核心机制:len、cap 与底层的暗坑

从 SliceHeader 结构体出发,讲清 Go slice 的 len 与 cap 边界、append 的扩容与原地改写行为,以及共享底层数组引发的两类经典暗坑,并给出复制与完整切片表达式两种隔离方案。

发布
阅读
约 2 分钟
作者
Nicolas Leigh
READS3次阅读

写 Go 时关于 slice 的诡异 bug,基本都源于一个误区:把 slice 当成了数组。它本质只是一个结构体(descriptor),指向一片连续的底层数组:

type SliceHeader struct {
    Data uintptr // 指向底层数组
    Len  int     // 当前可访问长度 (s[len] 直接 panic)
    Cap  int     // 不换数组前提下的最大容量
}

1. len 与 cap 的界限

  • len:当前窗口大小,决定合法的索引范围 0 <= index < len
  • cap:当前窗口右侧到数组末尾的总空间。

只要不超过 cap,切片可以向右展开:

s := make([]int, 3, 5) // len=3, cap=5
// s[3] 会 panic,但可以 reslice:
s = s[:5]              // 此时 len=5, cap=5,合规

2. append 的双重人格:扩容与暗改

append 会根据 cap 判断是否需要搬家:

  • 容量够append 复用原底层数组,从 len(s) 对应的位置开始写入新元素。
  • 容量不够:runtime 开辟新数组,搬运数据,返回指向新数组的 slice(扩容系数是 runtime 细节,不要假设恒为 2 倍)。

append 的返回值必须接住,常见写法是 s = append(s, x)。因为 append 会返回更新后的 slice:它至少具有新的 len,扩容时还可能指向新的底层数组。

3. 最经典的暗坑:共享底层数组

坑 1:切片原地修改

a := []int{1, 2, 3, 4, 5}
b := a[1:4]
b[0] = 100 // a[1] 瞬间变成了 100,因为 b 没拷数据,只是借用了 a 的内存

坑 2:append 引起的幽灵覆盖

a := make([]int, 2, 4) // cap 充裕
b := a
b = append(b, 3)

此时 b 追加了元素,但因为底层空间够用,它直接改写了 a 视野外的第 3 个位置。虽然 fmt.Println(a) 还是打印 2 个元素,但其实它的底层数组已经被污染了。如果后续 a 也做 append,双方就会互相覆盖。

4. 规避指南

如果需要隔离副作用,切断与原数组的联系,有两类方案:

方案一:复制到独立的底层数组(推荐)

b := append([]int(nil), a...)
// 或者 make + copy
b := make([]int, len(a))
copy(b, a)

方案二:完整切片表达式(Full Slice Expression)

b := a[1:3:3] // len=2, cap=2
// 此时 b 与 a 仍共享底层数组:
b[0] = 100 // 会修改 a[1]
// 但 append 超过 cap 后,会分配新的底层数组:
b = append(b, 99)
// 从这一刻起 b 已经与 a 分离:
b[0] = 200 // 不再影响 a