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) 做以下两件事之一:

  1. 如果 len(s) < cap(s):将 x 写入当前数组中的位置 s[len(s)],然后返回一个新的 slice,其 len + 1。
  2. 如果 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 有机会出现。

golangsliceappend内存