切片是 Go 里最常用的容器,坑也最多。这篇把最常见的坑整理成 9 个案例:每个坑给出会翻车的代码、翻车现场(输出)、以及背后的机制,最后附上面试回答框架。
🧠 一、核心坑:共享底层数组(面试必考)
关键词: 别名(aliasing)· 共享数组 · append 语义
先看这道经典题,很多人第一次都会答错:
1 | s := make([]int, 0, 10) |
直觉上以为分别是 [1 3] 和 [2 4],实际输出都是 [2 4]。
逐步拆解
切片本质是三个值的结构体:指针 + len + cap,指向一块共享的底层数组。len 是”我能看到的格子数”。
1 | 第1步 s := make([]int, 0, 10) |
最终 s1 和 s2 都指向同一块数组的前两个格子 → [2 4]。
💡 很多人以为
append(s, 1)之后 s 就”包含”了 1。不是。append 返回的是新的切片头,不会修改 s 本身。s 的 len 从头到尾都是 0,下次append(s, ...)又从格子 0 开始写。
更隐蔽的第二层
就算 s 的 len 不是 0,也有同样的坑:
1 | s := make([]int, 2, 10) // len=2, 已有 [0 0] |
只要 len < cap,append 就原地写共享数组,不分配新内存。 只有 len 撞到 cap 触发扩容时,append 才会分配新数组,那时才互相独立。”会不会踩”完全取决于是否扩容,而不是”从 s 复制了一份”。
如何规避
1 | // ① 需要独立副本时,复制底层数组 |
💬 二、面试怎么回答
按 结论 → 机制 → 例子 → 规避 四段式,1~2 分钟讲完。
结论:
Go 的 slice 是引用类型,append 操作的是底层共享数组,会导致多个 slice 别名(aliasing)互相覆盖,这是 Go 里最常见的坑。
机制:
slice 本质是”指针 + len + cap”的结构体。append 时如果 len < cap,直接在底层数组的 len 位置写入、不分配内存,返回的新切片和原切片共享同一块数组;只有 len == cap 触发扩容才会分配新数组,此时才互相独立。
例子:
s := make([]int, 0, 10),s1 := append(s, 1)写入下标 0;s2 := append(s, 2)因为 s 的 len 还是 0,又从下标 0 写,把 1 覆盖。结果两个都是[2 4]。核心是 append 不修改原切片。
规避:
需要独立副本时用
copy或append([]int{}, s...);或用三索引切片s[a:b:c]限制 cap,让 append 主动扩容。
加分点(答出来显得理解深):
- 规范要求
s = append(s, x)赋值回去,正因为 append 可能返回新地址 - 三索引切片
s[a:b:c]是控制共享的官方手段 - 扩容策略:容量 < 256 翻倍,≥ 256 后约 1.25 倍(Go 1.18 后)
收尾一句话:
append 是”在原数组上往后写”,不是”复制一份”;只要没扩容,所有派生 slice 都在同一块地上盖房子,后盖的盖掉先盖的。
⚠️ 三、其他高频坑
坑 1:append 不赋值回去,结果丢了
1 | s := []int{1, 2} |
append 返回新切片头,不赋值回去就白调了:触发扩容时新数组直接被丢弃;未扩容时数据写进了共享数组,但 s 的 len 没变、看不到。
坑 2:切片后 cap 残留,append 写回原数组
1 | a := []int{1, 2, 3, 4, 5} |
⚠️
a[i:j]的 cap 是cap(a)-i,不是j-i。对子切片 append 会把原数组后面覆盖掉。这是面试爱考的”输出的 a 是什么”。想要独立就用三索引b := a[1:3:3](cap 限为 2),append 立即扩容。
坑 3:for range 变量捕获(Go 1.22 之前)
1 | var fs []func() |
range 的 v 在 Go 1.22 之前是同一个变量复用。Go 1.22 起每次迭代是新变量,此坑已修复,但旧版本代码、以及闭包捕获循环外部声明的变量时仍会遇到。
坑 4:传参 — 改元素生效,append 不生效
1 | func f(s []int) { |
slice 传参传的是结构体本身(指针+len+cap 的值拷贝),指针指向的数组是共享的。
坑 5:nil slice vs 空 slice,JSON 序列化不同
1 | var s []int // nil,json.Marshal → null |
接口返回 null 和 [] 对前端处理完全不同,是生产环境最常见的 bug 来源之一。需要返回空数组时用 make([]int, 0)。
坑 6:slice 不能直接 == 比较
1 | s1 == s2 // 💥 编译错误:slice can only be compared to nil |
只能用 reflect.DeepEqual 或 slices.Equal(Go 1.21+)。
坑 7:大数组切片后内存泄漏
1 | big := make([]byte, 1<<30) // 1GB |
解决:copy 一小段出来,让大数组能被回收。
坑 8:多维切片必须逐行初始化
1 | m := make([][]int, 3) // 每行都是 nil |
二维数组 [3][3]int 是整块连续内存自动初始化,但切片得手动 make 每行。
📋 四、速查对照
| 现象 | 根因 | 解决方案 |
|---|---|---|
| 两次 append 结果互相覆盖 | 共享底层数组,未扩容 | copy / append([]int{}, s...) / 三索引 |
| append 结果丢失 | 没把返回值赋回去 | s = append(s, x) |
| 子切片 append 改到原数组 | a[i:j] 的 cap 残留 | 三索引切片限 cap |
| 闭包全部打印同一个值 | range 变量复用 | Go 1.22+ 或循环体传参 |
| 函数内 append 外部看不到 | len/cap 是值拷贝 | 返回新切片或传指针 |
JSON 输出 null 不是 [] | 切片是 nil | make([]int, 0) |
| 大切片切出小片内存暴涨 | 引用整个底层数组 | copy 出独立小片 |
| 二维切片 index panic | 内层是 nil | 逐行 make |
🎯 五、实战小贴士
- 面试最常考:核心坑(共享底层数组)、坑 1(append 必须赋值)、坑 2(cap 残留)、坑 4(传参语义)
- 次高频:range 变量捕获(Go 1.22 差异)、内存泄漏
- 生产高频:nil vs 空切片的 JSON 序列化
- 记住一句话:append 是”指哪写哪”,不是”复制一份”
- 判断是否踩坑,只看一个条件:append 有没有触发扩容