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

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

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

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

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

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

输出:

css 复制代码
[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 能回收指针)。如果你手里还留着原切片或者它的子切片,就会看到数据「消失」:

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

输出:

css 复制代码
[1 4 5] [1 4 5 0 0] 3 5

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

go 复制代码
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)

输出:

css 复制代码
keep=["d" ""] s=["b" "c" "d"]

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

go 复制代码
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 一样,容量够就原地挪,不够就新分配。于是同一行代码,结果取决于原切片的容量:

go 复制代码
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)

输出:

css 复制代码
[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 一下,父切片就被改了:

go 复制代码
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 就只能分配新数组:

go 复制代码
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,不保证相等元素的相对顺序。按某个字段排序时,如果原来的顺序有意义(比如已经按时间排好了,再按状态排),就会被打乱:

go 复制代码
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 个

输出:

makefile 复制代码
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 只去「相邻」的重复

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

go 复制代码
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 在无序切片上「有时能找到」

go 复制代码
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)
}

输出:

arduino 复制代码
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」变成「看到重复值」。

相关推荐
用户4015823324628 小时前
对话一长,AI 就忘了你说过的话?Claude Code、Codex、Aider、WES Code 怎么压缩上下文
人工智能·go·ai编程
我的div丢了肿么办12 小时前
go语言抽离路由文件以及对路由进行分组
后端·go
孔令飞14 小时前
【OneX】OneX AI 工具安装、配置及功能介绍
云原生·go·ai编程
小满zs16 小时前
Go语言第十四章(文件操作)
后端·go
vipxieliang16 小时前
Go 1.24 工具链改进实战:工具依赖管理与 go vet 新分析器
后端·go
程序员爱钓鱼19 小时前
Go 编程实战:Benchmark——基准测试、testing.B、benchmem、性能对比与代码优化实战
go·排序算法
Thinker QAQ1 天前
并发编程(十一):Shared Memory 与 Message Passing——关系与组合使用
java·python·go·并发编程·shared memory·message passing
喵个咪1 天前
GoWind Kanban 拆解:看板拖拽背后——LexoRank、乐观锁与尽力而为广播
后端·架构·go
喵个咪1 天前
GoWind OA 拆解:用 3000 行 Go 写一个够用的审批工作流引擎
后端·架构·go
喵个咪1 天前
GoWind Ledger 拆解:单式记账的余额一致性——confirm、补偿,以及没进事务的那些缝隙
后端·架构·go