切片、map 与那个 append 的坑
切片(slice)是 Go 里你用得最多的类型,装数据全靠它。它用起来像 ArrayList,但底层是完全不同的东西——一个「指针 + 长度 + 容量」的小三元组,看向某段数组。搞不清这个结构,你迟早会写出 Go 最经典的那个 bug:给一个切片 append,结果另一个切片的数据被悄悄改了。这一章用一台真引擎带你看清它。
切片不是数组,是「看向数组的一个窗口」
上一章说数组 [3]int 是彻底的值,长度固定、复制即深拷贝。但你日常几乎不用它,因为长度写死太僵。你用的是切片 []int(注意方括号里没数字)。切片看起来像个可变长数组,但它的本质是三个字段:
// 切片头(slice header)在内存里其实长这样:
type slice struct {
ptr *T // 指向底层数组某个位置
len int // 长度:当前有几个元素
cap int // 容量:从 ptr 起,底层数组还能放几个
}
关键:切片自己不存数据,数据在它指向的那块「底层数组」里。切片只是一个「我从这里开始、能看到 len 个、最多能扩到 cap 个」的窗口。这个结构解释了切片所有让人困惑的行为。
下面这台是真的切片引擎。左边每执行一句,右边的 ptr / len / cap 和底层数组就实时更新(实线框 = 这个切片看得到的窗口,虚线 = cap 还剩的空间)。先跑第一个场景:
append:够就地写,不够就搬家
append 是往切片尾部加元素的方式。它的行为完全由容量决定,这是理解切片的核心:
- cap 还够(len < cap):直接在底层数组的下一个位置写进去,len 加 1。没有分配、没有复制,还是原来那块底层数组。(Demo 第一个场景:make 出 cap 5,append 两次都在原地写。)
- cap 不够了(len == cap):Go 另开一块更大的底层数组,把旧元素全复制过去,再写新元素。这时切片的 ptr 变了,指向新数组,和旧的一刀两断。(Demo 第二个场景:cap 从 2 长到 4,ptr 变了。)
扩容时新容量怎么定?小切片大致翻倍(cap 2 → 4 → 8 …),大切片(超过 256)改成大约 1.25 倍增长,避免浪费太多内存。Demo 第二个场景里,cap 2 装满后 append,你会看到它变成 4 —— 这个数是引擎按 Go 的真实扩容公式算的。
你一定注意到 Go 里 append 永远写成 s = append(s, x),要把结果赋回给自己。现在你知道为什么了:append 可能会换一块底层数组、返回一个 ptr 不同的新切片头。如果你只写 append(s, x) 不接返回值,扩容后的新切片就丢了,s 还指着旧数组 —— 你的元素加了个寂寞。
这不是啰嗦,是切片「值语义 + 内部含指针」的直接后果:切片头本身是值(会复制),但它指向的底层数组是共享的。append 要么改共享数组、要么换个新的,无论哪种,你都得用返回值更新手里的切片头。
那个坑:子切片和父切片共享底层数组
现在来看 Go 最出名的 bug。切片可以「再切」:b := a[1:3] 得到一个新切片,但 —— 它和 a 共享同一块底层数组。b 只是换了个窗口去看同一片数据。跑 Demo 的第三个场景:
a := []int{1, 2, 3, 4}
b := a[:2] // b 看 a 的前 2 个,len 2,但 cap 还是 4(共享底层)
b = append(b, 99) // append!cap 够(2 < 4),就地写到底层数组的第 3 格
fmt.Println(a) // [1 2 99 4] —— a[2] 被改成 99 了!
看清楚发生了什么:b := a[:2] 时,b 的 len 是 2 但 cap 是 4(它继承了 a 的容量)。所以 append(b, 99) 发现「cap 够,不用扩容」,就直接把 99 写进了共享底层数组的第 3 个位置 —— 那正是 a[2]。你以为你在给 b 加东西,其实你在改 a。
这个 bug 特别阴,因为它只在 cap 够用时发生:如果 append 恰好触发了扩容,b 会搬到新数组,就不会影响 a 了 —— 于是这个 bug 时有时无,取决于容量,极难排查。几乎每个 Go 程序员都被它咬过至少一次。
要斩断共享,有两招。一是三索引切片 b := a[0:2:2],第三个数把 b 的 cap 也限制成 2,这样 b 一 append 就必然扩容、搬到新数组,碰不到 a。二是干脆 copy 一份:b := make([]int, 2); copy(b, a[:2]),b 从一开始就是独立的底层数组。记住这个信号:只要你要把一个切片的一部分交出去、而对方可能 append,就得警惕别名。
map:Go 的哈希表
map 是 Go 的键值表,用起来直白:
m := make(map[string]int) // 必须 make,别忘了(上一章说过 nil map 不能写)
m["a"] = 1
m["b"] = 2
// comma-ok:区分「值是 0」和「键不存在」
v, ok := m["c"] // v=0, ok=false(键不存在)
if ok { use(v) }
delete(m, "a") // 删键
for k, v := range m { // 遍历(顺序随机!)
fmt.Println(k, v)
}
三个要点:一,map 的零值是 nil,写之前必须 make(或用 map[string]int{} 字面量)。二,那个 v, ok := m[key] 的 comma-ok 惯用法很重要 —— 因为 map 里不存在的键返回的是值类型的零值,你没法靠「值是不是 0」判断键存不存在,得靠第二个返回值 ok。三,map 的遍历顺序是随机的,Go 故意每次都打乱,防止你写出依赖遍历顺序的代码。
和切片一样,map 也是「引用式」的:把 map 传进函数、赋给别的变量,大家操作的是同一个底层哈希表(复制的只是那个内部指针)。所以你能把 map 传进函数、在函数里往里加键,调用方看得到 —— 这和结构体的值语义正好相反。切片和 map 是 Go 里两个「看着像值、其实内部藏着共享指针」的类型,它们是你值语义直觉的两个例外,务必单独记牢。
Kotlin 对照
Kotlin 的 List / MutableList 把底层数组、容量、扩容全藏起来了。你 list.add(x),它自己管扩容,你根本不知道也不用知道底层数组换没换。子列表 subList 的共享问题相对隐蔽,日常也少踩。这是「加法」哲学的抽象 —— 帮你把细节盖住,代价是那些细节咬你时你没有心智模型去理解。
Go 的 slice 把这三个数(ptr / len / cap)直接摊在你面前。它更「漏」—— 你必须理解底层数组、容量、共享,否则会踩 append 别名坑。但正因为它透明,你能精确控制内存:预分配容量避免反复扩容(make([]T, 0, 1000))、用 copy 切断共享、贴着 cap 做优化。Kotlin 让你不用想,Go 逼你想清楚 —— 后者更容易出 bug,也更容易写出对内存精打细算的代码。
还有个直觉要改:Kotlin 里 val a = list; val b = a,a、b 指同一个 list。Go 里切片和 map 是这样(共享底层),但普通 struct 和数组不是(复制)。「这个类型赋值是共享还是复制」在 Go 里要一个个类型地记 —— 切片、map、channel 是共享,其余是复制。
这一章的一句话
切片是「指针 + 长度 + 容量」的三元组,看向一块底层数组:append 容量够就地写、不够就搬到新数组并复制(所以要 s = append(s, x));子切片和父切片共享底层数组,这就是那个「append 悄悄改了别人」的经典坑。map 同理是引用式的,写前必须 make。
卷 II 到此结束 —— 你已经重装了 Go 的值观。接下来是全书最重的一卷:接口。它是 Go 最不像 Kotlin 的东西,也是你此行最大的收获。我们从「方法到底是什么」开始。