切片是 Go 里最常用的容器,坑也最多。这篇把最常见的坑整理成 9 个案例:每个坑给出会翻车的代码、翻车现场(输出)、以及背后的机制,最后附上面试回答框架。


🧠 一、核心坑:共享底层数组(面试必考)

关键词: 别名(aliasing)· 共享数组 · append 语义

先看这道经典题,很多人第一次都会答错:

1
2
3
4
5
6
7
8
9
10
s := make([]int, 0, 10)

s1 := append(s, 1)
s1 = append(s1, 3)

s2 := append(s, 2)
s2 = append(s2, 4)

fmt.Println(s1) // 输出?
fmt.Println(s2) // 输出?

直觉上以为分别是 [1 3][2 4]实际输出都是 [2 4]

逐步拆解

切片本质是三个值的结构体:指针 + len + cap,指向一块共享的底层数组。len 是”我能看到的格子数”。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
1步  s := make([]int, 0, 10)
底层数组: [ ][ ][ ][ ]...
下标: 0 1 2 3
s = 指针→0, len=0, cap=10 // 只能"看到" 0 个格子

2步 s1 := append(s, 1)
底层数组: [ 1 ][ ][ ][ ]... // 在格子0写入
s1 = 指针→0, len=1
s = 指针→0, len=0 // ⚠️ append 不改 s,s 还是 0 个格子

3步 s1 = append(s1, 3)
底层数组: [ 1 ][ 3 ][ ][ ]...
s1 = 指针→0, len=2

4步 s2 := append(s, 2) // 回到 s:len 还是 0!
底层数组: [ 2 ][ 3 ][ ][ ]... // 又从格子0写,1 被覆盖成 2
s2 = 指针→0, len=1

5步 s2 = append(s2, 4)
底层数组: [ 2 ][ 4 ][ ][ ]... // 3 被覆盖成 4
s2 = 指针→0, len=2

最终 s1 和 s2 都指向同一块数组的前两个格子 → [2 4]

💡 很多人以为 append(s, 1) 之后 s 就”包含”了 1。不是。append 返回的是新的切片头,不会修改 s 本身。s 的 len 从头到尾都是 0,下次 append(s, ...) 又从格子 0 开始写。

更隐蔽的第二层

就算 s 的 len 不是 0,也有同样的坑:

1
2
3
s := make([]int, 2, 10)   // len=2, 已有 [0 0]
a := append(s, 1) // 从格子2写
b := append(s, 2) // 也从格子2写 ← 覆盖

只要 len < cap,append 就原地写共享数组,不分配新内存。 只有 len 撞到 cap 触发扩容时,append 才会分配新数组,那时才互相独立。”会不会踩”完全取决于是否扩容,而不是”从 s 复制了一份”。

如何规避

1
2
3
4
5
6
7
8
9
10
// ① 需要独立副本时,复制底层数组
indep := append([]int{}, s...)

// ② 或 copy 到新数组
cp := make([]int, len(s), len(s))
copy(cp, s)

// ③ 三索引切片限制 cap,让 append 立即扩容、不再共享
// s 假定为 len=2, cap=10 的切片
sub := s[0:2:2] // len=2, cap=2 → 下次 append 立即扩容

💬 二、面试怎么回答

结论 → 机制 → 例子 → 规避 四段式,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 不修改原切片。

规避:

需要独立副本时用 copyappend([]int{}, s...);或用三索引切片 s[a:b:c] 限制 cap,让 append 主动扩容。

加分点(答出来显得理解深):

  1. 规范要求 s = append(s, x) 赋值回去,正因为 append 可能返回新地址
  2. 三索引切片 s[a:b:c] 是控制共享的官方手段
  3. 扩容策略:容量 < 256 翻倍,≥ 256 后约 1.25 倍(Go 1.18 后)

收尾一句话:

append 是”在原数组上往后写”,不是”复制一份”;只要没扩容,所有派生 slice 都在同一块地上盖房子,后盖的盖掉先盖的。


⚠️ 三、其他高频坑

坑 1:append 不赋值回去,结果丢了

1
2
3
4
s := []int{1, 2}
append(s, 3) // 返回值被丢弃,s 还是 [1,2]

s = append(s, 3) // ✅ 必须赋值回去

append 返回新切片头,不赋值回去就白调了:触发扩容时新数组直接被丢弃;未扩容时数据写进了共享数组,但 s 的 len 没变、看不到。

坑 2:切片后 cap 残留,append 写回原数组

1
2
3
a := []int{1, 2, 3, 4, 5}
b := a[1:3] // b = [2,3],但 cap 是 4,不是 2!
b = append(b, 99) // 写进 a 的下标 3,a 变成 [1,2,3,99,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
2
3
4
5
var fs []func()
for _, v := range []int{1, 2, 3} {
fs = append(fs, func() { fmt.Println(v) })
}
// 三个闭包都打印 3

range 的 v 在 Go 1.22 之前是同一个变量复用。Go 1.22 起每次迭代是新变量,此坑已修复,但旧版本代码、以及闭包捕获循环外部声明的变量时仍会遇到。

坑 4:传参 — 改元素生效,append 不生效

1
2
3
4
5
6
7
8
9
func f(s []int) {
s[0] = 100 // 外部能看到(共享底层数组)
s = append(s, 1) // 外部看不到(只改了本地副本的 len/cap)
}

// ✅ 想让追加也生效,就返回新切片
func f2(s []int) []int {
return append(s, 1)
}

slice 传参传的是结构体本身(指针+len+cap 的值拷贝),指针指向的数组是共享的。

坑 5:nil slice vs 空 slice,JSON 序列化不同

1
2
var s  []int    // nil,json.Marshal → null
s2 := []int{} // 空,json.Marshal → []

接口返回 null[] 对前端处理完全不同,是生产环境最常见的 bug 来源之一。需要返回空数组时用 make([]int, 0)

坑 6:slice 不能直接 == 比较

1
s1 == s2   // 💥 编译错误:slice can only be compared to nil

只能用 reflect.DeepEqualslices.Equal(Go 1.21+)。

坑 7:大数组切片后内存泄漏

1
2
3
big := make([]byte, 1<<30)   // 1GB
sub := big[100:110] // 只取 10 字节
// sub 持有整个 1GB 数组的引用,GC 永远收不掉!

解决:copy 一小段出来,让大数组能被回收。

坑 8:多维切片必须逐行初始化

1
2
3
4
5
6
7
m := make([][]int, 3)   // 每行都是 nil
m[0][0] = 1 // 💥 panic: index out of range

// ✅ 逐行 make
for i := range m {
m[i] = make([]int, 3)
}

二维数组 [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 不是 []切片是 nilmake([]int, 0)
大切片切出小片内存暴涨引用整个底层数组copy 出独立小片
二维切片 index panic内层是 nil逐行 make

🎯 五、实战小贴士

  1. 面试最常考:核心坑(共享底层数组)、坑 1(append 必须赋值)、坑 2(cap 残留)、坑 4(传参语义)
  2. 次高频:range 变量捕获(Go 1.22 差异)、内存泄漏
  3. 生产高频:nil vs 空切片的 JSON 序列化
  4. 记住一句话:append 是”指哪写哪”,不是”复制一份”
  5. 判断是否踩坑,只看一个条件:append 有没有触发扩容