Go 中的 Slice:len、cap 和 append 的陷阱
1. 概述
Slice 是 Go 中使用最频繁的数据类型,但同时也是最容易引发难以发现的 bug 的。原因是多个 slice 可能共享同一个底层数组。这时 append 可能会覆盖另一个 slice 的数据,而不会报任何错误。
本文将讲解 len 和 cap,append 如何决定是分配新数组还是写入旧数组,以及避免错误的技巧:full slice expression、copy、slices.Clone。读完这篇文章后,你将能够正确预测包含 append 的代码的结果,并知道如何编写测试来捕获 aliasing 错误。
示例使用 Go 1.21 及更高版本,因为 slices 包从这个版本开始提供。
2. Slice 究竟是什么
一个 slice 是一个包含三个字段的小 struct:
- pointer:指向底层数组(backing array)中的第一个元素。
- len:你可以访问的元素个数。
- cap:从 pointer 指向的位置到底层数组末尾的元素个数。
当你将 slice 传入函数或赋值给另一个变量时,Go 只会复制这三个字段。底层数组不会被复制。
2.1. 观察 len 和 cap
package main
import "fmt"
func main() {
orders := []string{"A01", "A02", "A03", "A04", "A05"}
recent := orders[1:3]
fmt.Println(recent, len(recent), cap(recent))
}
[A02 A03] 2 4
orders[1:3] 获取从索引 1 到索引 3 之前的元素,所以 len 为 2。cap 为 4,因为从 orders[1] 的位置到数组末尾还有 4 个位置:A02、A03、A04、A05。
2.2. 两个 slice,一个数组
因为 recent 指向与 orders 相同的数组,修改这个 slice 的元素会立即在另一个 slice 中看到:
recent[0] = "CANCELLED"
fmt.Println(orders)
[A01 CANCELLED A03 A04 A05]
这是有意设计的行为。它使 slice 切片操作零成本。但这也是后面所有陷阱的根源。
3. append 的工作原理
append(s, x) 做以下两件事之一:
- 如果
len(s) < cap(s):将x写入当前数组中的位置s[len(s)],然后返回一个新的 slice,其len + 1。 - 如果
len(s) == cap(s):分配一个更大的新数组,将旧数据复制过去,写入x,然后返回指向新数组的 slice。
正因为情况 2 可能发生,你必须总是重新赋值结果:s = append(s, x)。
3.1. 观察 cap 逐步增长
package main
import "fmt"
func main() {
var ids []int
for i := 0; i < 10; i++ {
ids = append(ids, i)
fmt.Printf("len=%d cap=%d\n", len(ids), cap(ids))
}
}
len=1 cap=1
len=2 cap=2
len=3 cap=4
len=4 cap=4
len=5 cap=8
len=6 cap=8
len=7 cap=8
len=8 cap=8
len=9 cap=16
len=10 cap=16
对于小 slice,cap 通常每次满时翻倍。对于大 slice,runtime 增长更慢,结果还会根据内存分配器的 size class 进行舍入。
注意:
cap增长公式是 runtime 的实现细节,在不同 Go 版本中已经改变。不要编写依赖append后的特定cap值的代码。
4. 陷阱 1:append 覆盖原始 slice 的数据
这是最常见的错误:
package main
import "fmt"
func main() {
prices := []int{10, 20, 30, 40, 50}
firstTwo := prices[:2]
firstTwo = append(firstTwo, 99)
fmt.Println("firstTwo:", firstTwo)
fmt.Println("prices: ", prices)
}
firstTwo: [10 20 99]
prices: [10 20 99 40 50]
firstTwo 的 len = 2 但 cap = 5。数组还有空间,所以 append 没有分配新数组,而是将 99 写入共享数组的第三个位置。结果是 prices[2] 从 30 变成了 99。
这个 bug 很难发现。代码不会 panic,被覆盖的值可能只在另一个模块中显露,通常很久之后。
5. 陷阱 2:从同一个 slice 进行两次 append
package main
import "fmt"
func main() {
base := make([]string, 3, 4)
base[0], base[1], base[2] = "pen", "book", "bag"
cartA := append(base, "laptop")
cartB := append(base, "phone")
fmt.Println("cartA:", cartA)
fmt.Println("cartB:", cartB)
}
cartA: [pen book bag phone]
cartB: [pen book bag phone]
base 恰好还有一个空位。第一个 append 命令将 "laptop" 写入该位置。第二个命令将 "phone" 写入同一个位置。cartA 和 cartB 都指向同一个数组,所以 "laptop" 消失了。
如果 base 的 cap = 3,两个 append 都会分配各自的数组,结果就如你期望的那样。同样的代码,行为因 cap 而改变。这就是为什么这个 bug 只是偶尔出现。
6. 陷阱 3:函数内的 append 不会改变调用者的 slice
package main
import "fmt"
func addItem(items []string, item string) {
items = append(items, item)
}
func main() {
cart := make([]string, 0, 10)
addItem(cart, "pen")
fmt.Println(len(cart), cart)
fmt.Println(cart[:1])
}
0 []
[pen]
函数 addItem 接收了 slice struct 的一个副本。append 已将 "pen" 写入共享数组,因为还有 cap。但新的 len 只被赋值给了本地变量 items。main 中的 cart 变量仍然有 len = 0。数据已经在数组中,但只有当你故意重新切割 cart[:1] 时才能看到它。
解决方法是返回新的 slice:
func addItem(items []string, item string) []string {
return append(items, item)
}
// 使用
cart = addItem(cart, "pen")
这也是标准库设计 append 为返回值函数而不是在原地修改 slice 的原因。
7. 陷阱 4:混淆 make 的 len 和 cap
package main
import "fmt"
func main() {
names := []string{"An", "Binh", "Chi"}
upper := make([]string, len(names)) // 错误意图
for _, n := range names {
upper = append(upper, n+"!")
}
fmt.Println(len(upper), upper)
}
6 [ An! Binh! Chi!]
make([]string, 3) 创建一个有3 个空元素的 slice,而不是一个容量为 3 的空 slice。append 添加到那 3 个空字符串之后。
你有两种正确的修复方法。选择其中一种,不要混合使用:
// 方法 1:len = 0,cap = n,然后 append
upper := make([]string, 0, len(names))
for _, n := range names {
upper = append(upper, n+"!")
}
// 方法 2:len = n,按索引赋值
upper := make([]string, len(names))
for i, n := range names {
upper[i] = n + "!"
}
两种方法都只分配一次,比让 append 多次增长 cap 更快。
8. 防御方法
8.1. Full slice expression:限制 cap
语法 s[low:high:max] 设置 cap = max - low。当 cap 等于 len 时,下一次 append 必须分配新数组:
prices := []int{10, 20, 30, 40, 50}
firstTwo := prices[:2:2] // len=2, cap=2
firstTwo = append(firstTwo, 99)
fmt.Println("firstTwo:", firstTwo)
fmt.Println("prices: ", prices)
firstTwo: [10 20 99]
prices: [10 20 30 40 50]
prices 不受影响。当你将内部 slice 的一部分返回到外部时,这个技巧很有用,这样调用者可以自由 append 而不会破坏你的数据。
8.2. slices.Clone 和 copy:完全分离数组
当需要一个完全独立的副本时,使用 clone:
import "slices"
orders := []string{"A01", "A02", "A03"}
snapshot := slices.Clone(orders)
snapshot[0] = "X"
fmt.Println(orders[0], snapshot[0]) // A01 X
对于 Go 1.21 之前的版本,使用 copy:
snapshot := make([]string, len(orders))
copy(snapshot, orders)
copy 返回复制的元素个数,等于 len(dst) 和 len(src) 中较小的值。因此 dst 必须有足够大的 len,不只是 cap。一个常见的错误是写 copy(make([]string, 0, n), src) 然后得到一个空 slice。
8.3. 保留内存的陷阱
从一个大数组中切割一个小 slice 会在内存中保留整个大数组:
func firstLine(data []byte) []byte {
for i, b := range data {
if b == '\n' {
return slices.Clone(data[:i]) // 不保留整个 data
}
}
return slices.Clone(data)
}
如果 data 是一个 100 MB 的文件,你返回 data[:i] 而不 clone,garbage collector 无法释放那 100 MB 只要返回的 slice 还被引用。
9. 编写测试来捕获 aliasing 错误
假设你有一个 Inventory 类型返回产品列表。你想确保调用者对结果 append 不会破坏内部数据:
// inventory.go
package inventory
type Inventory struct {
items []string
}
func New(items ...string) *Inventory {
return &Inventory{items: items}
}
func (inv *Inventory) Items() []string {
return inv.items[:len(inv.items):len(inv.items)]
}
// inventory_test.go
package inventory
import (
"slices"
"testing"
)
func TestItems_AppendDoesNotCorruptInternalState(t *testing.T) {
inv := New("pen", "book")
inv.items = append(inv.items[:0:0], "pen", "book", "bag")[:2] // cap 故意设置过大
got := inv.Items()
got = append(got, "laptop")
want := []string{"pen", "book"}
if !slices.Equal(inv.Items(), want) {
t.Fatalf("internal items = %v, want %v", inv.Items(), want)
}
if inv.items[:3][2] != "bag" {
t.Fatalf("backing array 被覆盖:%v", inv.items[:3])
}
if !slices.Equal(got, []string{"pen", "book", "laptop"}) {
t.Fatalf("got = %v", got)
}
}
测试故意创建一个 cap 大于 len 的内部 slice,因为这是 bug 出现的条件。如果你将 Items() 改为 return inv.items,命令 append(got, "laptop") 会覆盖 "bag",测试会在第二个断言处失败。
go test ./...
ok example.com/inventory 0.002s
注意: Aliasing 测试必须始终设置
len < cap的情况。如果测试中的 slice 是用字面量创建的,比如[]string{"pen", "book"},那么len == cap,append总是分配新数组,即使代码有 bug 测试也会通过。
10. 何时无需担心
并非所有 slice 都需要 clone。过度复制会浪费内存和 CPU。你可以安全地共享数组的情况:
- Slice 仅被读取,没有人
append或修改元素。 - Slice 是局部变量,不被共享到函数外。
- 你主动想在原地修改,比如用
s[:0]过滤元素以重用数组:
active := orders[:0]
for _, o := range orders {
if o != "CANCELLED" {
active = append(active, o)
}
}
这个技巧是正确的,因为 active 永远不会写到 orders 的读取位置之外。循环后,你只应该使用 active 而放弃 orders。
11. 总结
Slice 是一个包含 pointer、len 和 cap 的 struct,指向一个可以被共享的数组。append 仅在 len == cap 时分配新数组。在其他所有情况下,它直接写入共享数组。这是我们遇到的四个陷阱的根源:覆盖原始 slice、两次 append 相互覆盖、函数内 append 不改变调用者、以及在 make 时混淆 len 和 cap。
为了安全,请记住以下几条规则:
- 始终写
s = append(s, ...)。 - 需要添加元素的函数应该返回新的 slice。
- 返回内部 slice 时使用
s[low:high:high]。 - 需要独立副本或想释放大数组时使用
slices.Clone。
编写测试时,记住设置 len < cap 条件,让 bug 有机会出现。