Go slices 包的七个坑:Delete 把旧切片清零、Insert 时而写穿、SortFunc 不稳定(Go 1.25 实跑)

0 阅读6分钟

Go 1.21 把 slices 包放进标准库之后,很多手写的 for 循环被换成了 slices.Delete、slices.Insert、slices.SortFunc。代码短了,但切片「共享底层数组」这件事一点没变,有几个函数的行为还在 1.22 改过一次。

下面七个坑,都是我自己写 Go 后端(包括 forxi.cn 这个小站)时踩过、或者在代码评审里见过的。每个都附了能直接跑的代码和真实输出,环境是 Go 1.25.0 darwin/arm64。

1. append 写穿:两个结果指向同一块内存

先复习一下所有坑的根源:

base := make([]int, 3, 10)
a := append(base, 1)
b := append(base, 2)
fmt.Println(a, b)

输出:

[0 0 0 2] [0 0 0 2]

base 还有 7 个空位,两次 append 都没分配新数组,直接写在 base[3] 这个位置上,后一次覆盖了前一次。a 和 b 是同一块内存的两个视图。

只要记住:append / Insert 在容量够的时候原地写,不够的时候才分配新数组。下面几个坑都是这句话的变形。

2. slices.Delete 会把「被挤出来的尾巴」清零

Go 1.22 开始,slices.Delete 删完之后会把原切片末尾空出来的元素置为零值(为了让 GC 能回收指针)。如果你手里还留着原切片或者它的子切片,就会看到数据「消失」:

s := []int{1, 2, 3, 4, 5}
view := s
s = slices.Delete(s, 1, 3)
fmt.Println(s, view, len(s), cap(s))

输出:

[1 4 5] [1 4 5 0 0] 3 5

view 末尾两个元素变成了 0。更隐蔽的是子切片:

s := []string{"a", "b", "c", "d"}
keep := s[2:]          // 本来是 ["c" "d"]
s = slices.Delete(s, 0, 1)
fmt.Printf("keep=%q s=%q\n", keep, s)

输出:

keep=["d" ""] s=["b" "c" "d"]

keep 既被前移覆盖了一位,最后一位又被清零。如果元素是指针,被清零的位置就是 nil,后面一解引用就 panic:

ps := []*User{{"a", 1}, {"b", 2}, {"c", 3}}
old := ps
ps = slices.Delete(ps, 0, 1)
fmt.Println(len(ps), old[2] == nil) // 2 true

规则很简单:对一个切片调 Delete 之后,只用它的返回值,别再碰原来任何一个视图。如果确实需要保留原数据,先 slices.Clone 一份。

3. slices.Insert:有时写穿,有时不写穿

Insert 和 append 一样,容量够就原地挪,不够就新分配。于是同一行代码,结果取决于原切片的容量:

x := []int{1, 2, 3}            // cap 3,满了
y := slices.Insert(x, 1, 9)
y[0] = 100
fmt.Println(x, y, cap(x), cap(y))

x = make([]int, 3, 8)          // cap 8,有空位
copy(x, []int{1, 2, 3})
y = slices.Insert(x, 1, 9)
y[0] = 100
fmt.Println(x, y)

输出:

[1 2 3] [100 9 2 3] 3 6
[100 9 2] [100 9 2 3]

第一次分配了新数组(cap 变成 6),改 y 不影响 x;第二次原地插入,x 被改成了 [100 9 2],连原来的 3 都被挤出了 x 的可见范围。

这类 bug 最难查的地方在于:切片的容量取决于它是怎么来的。从 make 来的、从 append 增长来的、从数据库驱动扫描出来的,容量都不一样,测试时可能刚好是满的,线上换个数据量就有空位了。

4. 子切片 append 写穿父切片,用 slices.Clip 挡住

把切片的前一部分交给别的函数,对方 append 一下,父切片就被改了:

parent := []int{1, 2, 3, 4, 5}
child := parent[:2]
_ = append(child, 99)
fmt.Println(parent) // [1 2 99 4 5]

child 的容量是 5,append 直接写进了 parent[2]。slices.Clip 把容量截到和长度一样,对方再 append 就只能分配新数组:

parent = []int{1, 2, 3, 4, 5}
child = slices.Clip(parent[:2])
_ = append(child, 99)
fmt.Println(parent) // [1 2 3 4 5]

slices.Clip(s) 等价于三下标切片 s[:len(s):len(s)],可读性好一些。把内部切片返回给调用方、或者传给不受控的回调时,顺手 Clip 一下。

5. SortFunc 不是稳定排序

slices.SortFunc 用的是 pdqsort,不保证相等元素的相对顺序。按某个字段排序时,如果原来的顺序有意义(比如已经按时间排好了,再按状态排),就会被打乱:

us := []User{}
for i := 0; i < 40; i++ {
    us = append(us, User{fmt.Sprintf("u%02d", i), i % 3})
}
u1 := slices.Clone(us)
slices.SortFunc(u1, func(p, q User) int { return p.Age - q.Age })
u2 := slices.Clone(us)
slices.SortStableFunc(u2, func(p, q User) int { return p.Age - q.Age })
// 打印 Age==0 那组的前 6 个

输出:

unstable: u15,u39,u36,u03,u33,u30
stable:   u00,u03,u06,u09,u12,u15

Age 都是 0 的 14 个人,SortFunc 之后顺序完全乱了。元素少的时候(12 个以内会走插入排序)往往碰巧是稳定的,所以测试数据小时发现不了。需要保序就用 SortStableFunc,或者在比较函数里加第二排序键。

另外,比较函数里写 p.Age - q.Age 在整数很大时会溢出,生产代码用 cmp.Compare(p.Age, q.Age) 更稳。

6. Compact 只去「相邻」的重复

名字容易让人以为是去重:

c := []int{3, 1, 3, 1, 1, 2}
fmt.Println(slices.Compact(slices.Clone(c))) // [3 1 3 1 2]
slices.Sort(c)
fmt.Println(slices.Compact(c))               // [1 2 3]

它只把连续相同的元素合并成一个,和 Unix 的 uniq 一样。要真正去重,先排序再 Compact;不能改变顺序的话,还是用 map 记录见过的元素。

还有一点:Compact 也是原地操作,1.22 起同样会把尾部清零,第 2 条的规则对它一样适用。

7. BinarySearch 在无序切片上「有时能找到」

d := []int{5, 1, 4, 2}
for _, t := range []int{5, 1, 4, 2} {
    i, ok := slices.BinarySearch(d, t)
    fmt.Println(t, i, ok)
}

输出:

5 4 false
1 0 false
4 2 true
2 2 false

四个明明都在切片里的值,只找到了一个。没有 panic、没有报错,就是静默返回 false。这种 bug 在数据「大致有序」的时候尤其难发现。用之前确认切片排过序,不确定就用 slices.Contains / slices.Index,线性查找慢一点但不会错。

一张表总结

函数会不会改原数组要注意的
append / Insert容量够就会结果是否和原切片共享内存取决于容量
Delete / Compact会,且 1.22 起尾部清零调用后只用返回值
Clip不会用来阻断别人的 append 写穿
Clone不会Clone(nil) 返回 nil,Clone([]int{}) 返回非 nil 空切片
SortFunc会(原地排序)不稳定,需要保序用 SortStableFunc
BinarySearch不会前提是有序,否则静默返回错误结果

局限

上面的输出是 Go 1.25.0 在 arm64 上跑的。第 5 条 SortFunc 乱序后的具体顺序依赖排序算法的实现细节,换版本可能不一样,但「不稳定」这个结论不会变。第 2 条的清零行为是 1.22 引入的,这个行为跟着工具链走(slices 是用你编译时那个 Go 版本的标准库),和 go.mod 里写的版本号无关。如果你还在用 1.21 的工具链,Delete 不会清零,但旧视图里的数据一样是错位的,问题只是从「看到 0」变成「看到重复值」。