这几个点都被人写过很多次了,但我还是在真实项目里见它们造成过 bug。集中记一下,顺便写清楚为什么会这样。
一、append 可能改到原 slice
a := make([]int, 0, 4)
a = append(a, 1, 2, 3)
b := a[:2] // b 和 a 共用底层数组
b = append(b, 99) // 容量够,直接写在原数组上
fmt.Println(a) // [1 2 99]
fmt.Println(b) // [1 2 99]
我原本以为 a 是 [1 2 3]。实际上 b 的容量还有余量,append 就直接覆盖了索引 2 的位置。
slice 是三个字段:指针、长度、容量。切片操作 a[:2] 只改了长度,指针还是指向同一个数组开头。只要容量够,append 就原地写。
容量不够时会分配新数组,那时候两个就不互相影响了:
a := []int{1, 2, 3} // 长度容量都是 3
b := a[:2]
b = append(b, 99) // 容量 3,长度 2,够用,还是原地写
b = append(b, 88) // 容量不够,分配新数组,从这里开始分家
这种"取决于容量够不够"的行为最难查,因为小数据量测试通过、生产数据量一大就出问题。
规避办法:需要独立副本时显式拷贝。
b := make([]int, 2)
copy(b, a[:2])
或者用 Go 1.21 之后的 slices.Clone。
二、range 的循环变量在旧版本里是复用的
这个在 Go 1.22 变了,但很多项目还在老版本,所以还是得知道。
var fns []func()
for _, v := range []int{1, 2, 3} {
fns = append(fns, func() { fmt.Print(v) })
}
for _, f := range fns { f() }
// Go 1.21 及以前输出: 333
// Go 1.22 及以后输出: 123
原因是 1.22 之前 v 整个循环只有一个变量,所有闭包捕获的是同一个。修复很简单,循环里加一行 v := v。
我们现在项目是 1.22,理论上没这个问题了。但闭包捕获循环变量这个坑还有别的形式:go 1.22 改动只针对 for range,普通的 for i := 0; ... 三句式循环没有变,因为它本来就是每次迭代新变量。
三、map 遍历顺序是故意的随机
m := map[string]int{"a": 1, "b": 2, "c": 3}
for k := range m {
fmt.Print(k)
}
// 每次运行顺序都不一样
这个不是实现缺陷,是故意的。早期 Go 的 map 遍历是有序的(按哈希桶顺序),结果大量程序无意中依赖了这个顺序,换版本或者数据量变化就崩。所以官方直接加了随机起点,逼你不要依赖。
需要稳定顺序就自己排:
keys := make([]string, 0, len(m))
for k := range m { keys = append(keys, k) }
sort.Strings(keys)
for _, k := range keys {
fmt.Println(k, m[k])
}
Go 1.23 加了 maps.Keys 返回迭代器,配合 slices.Sorted 可以一行搞定。
顺手提一个相关的事:map 不是并发安全的。并发读写会直接 fatal error,不是 panic,recover 都抓不住:
fatal error: concurrent map writes
要么加锁,要么用 sync.Map(但 sync.Map 只在"读多写少且 key 集合稳定"时才有优势,其他情况比加锁慢)。
四、slice 比较只能跟 nil
a := []int{1, 2}
b := []int{1, 2}
fmt.Println(a == b) // 编译错误!
slice 不能直接用 == 比,只能跟 nil 比。要比较内容得用 slices.Equal 或者自己写循环。
原因是 slice 只包含指针、长度、容量,逐字段比较没意义——两个不同的底层数组可能有相同内容。而且如果允许比较,就没法当 map 的 key 了。
map 和 func 同理,都不可比较。struct 里如果含 slice 字段,整个 struct 也不能用 == 比,这个错误经常在写单元测试断言时遇到。
五、make 和 new 的区别
p := new(int) // *int,指向零值 0
s := make([]int, 3) // []int,长度 3,已初始化
new 只分配内存返回指针,make 会初始化 slice/map/channel 的内部结构。
我见过有人写 new(map[string]int) 然后往里塞数据,直接 panic。因为那个 map 还是 nil。
实际上 new 我几乎不用,用 & 取地址更清楚:
cfg := &Config{Name: "x"}
什么时候会真的踩到
这几个知识点本身不难,但它们的共同点是:单元测试数据量小、执行次数少、刚好不会触发。
- append 覆盖:测试用了容量刚好够的 slice
- 闭包捕获:测试里只循环一次
- map 顺序:测试恰好只有一个 key
我现在的习惯是,涉及 slice 分包处理的逻辑,测试里一定加一个跨越容量边界的用例;并发相关的代码,跑 go test -race。这两个习惯帮我拦下过好几次问题。