ARTICLE / Go
An English translation is not available yet. The Chinese original is shown below.
Go Slice 核心机制:len、cap 与底层的暗坑
从 SliceHeader 结构体出发,讲清 Go slice 的 len 与 cap 边界、append 的扩容与原地改写行为,以及共享底层数组引发的两类经典暗坑,并给出复制与完整切片表达式两种隔离方案。
- Published
- Reading time
- 2 min read
- Author
- Nicolas Leigh
READS3reads
写 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