大家好,我是程序员柏风,AI 最全的 Vibecoding 学习地图 http://coderoadmap.cn 作者。
数组、切片与 map
这一课你将能做到
- 说清数组与切片的本质区别,画出切片「指针 + len + cap」的底层结构
- 用 make / append / 切片表达式 / copy 熟练操作切片,并能解释 append 的扩容行为
- 用 map 完成增删改查与 comma-ok 判断,说出哪些类型可以当 map 的 key
为什么要学
Go 日常开发 90% 的数据容器就是切片(slice)和 map。数组反而很少直接用——它只是切片的地基。这两样东西的底层机制是 Go 面试的固定题库:切片怎么扩容、两个切片为什么会被牵连修改、map 为什么遍历无序、map 并发读写会怎样。这一课把这些一次讲透,后面写并发、写服务才不会踩雷。
先建立图像
切片不是数组,它是一个「三件套」结构体,指向一块底层数组:
s := []int{10, 20, 30} (cap 为 3)
slice header(切片头)
┌──────────┬─────┬──────┐
│ 指针 ptr │ len │ cap │
└────┬─────┴─────┴──────┘
│ 指向
▼
底层数组 ┌────┬────┬────┐
│ 10 │ 20 │ 30 │
└────┴────┴────┘
◀—— len=3 ——▶
len:窗口里能看到几个元素(len(s))cap:从切片起点开始,底层数组总共能容纳几个元素(cap(s))append:空间够就在底层数组后面写;不够就换一块更大的数组,指针指向新地址
理解了这张图,本课一半的坑就自动解开了。
跟着做
步骤 1:数组——值类型,很少直接用
package main
import "fmt"
func main() {
a := [3]int{1, 2, 3} // 长度是类型的一部分:[3]int ≠ [4]int
b := a // 整块拷贝!数组是值类型
b[0] = 100
fmt.Println(a) // [1 2 3],a 没变
fmt.Println(b) // [100 2 3]
// 数组长度也可以由编译器数出来
c := [...]string{"x", "y", "z"}
fmt.Println(len(c)) // 3
}
因为赋值/传参会整块拷贝,加上长度写死,实际工程里数组主要作为切片的底层存在。
步骤 2:切片的两种创建方式
package main
import "fmt"
func main() {
// 方式一:字面量,最常用
s := []int{1, 2, 3} // 注意 [] 里没有数字,就是切片
// 方式二:make,预先指定长度和容量
nums := make([]int, 3) // len=3, cap=3,元素是零值 [0 0 0]
buf := make([]int, 0, 8) // len=0, cap=8,空切片但已预留空间
fmt.Println(s, len(s), cap(s))
fmt.Println(nums, len(nums), cap(nums))
fmt.Println(buf, len(buf), cap(buf))
nums[0] = 9 // len=3 的切片可以直接按下标赋值
fmt.Println(nums)
}
已知大概数量时用 make([]T, 0, n) 预分配,能避免多次扩容拷贝,这是性能优化的常识。
步骤 3:append 与扩容
package main
import "fmt"
func main() {
s := make([]int, 0, 3)
fmt.Printf("初始: len=%d cap=%d\n", len(s), cap(s))
for i := 1; i <= 6; i++ {
s = append(s, i) // 必须接收返回值!
fmt.Printf("append %d 后: len=%d cap=%d 底层地址=%p\n",
i, len(s), cap(s), s)
}
}
典型输出(地址以实际为准):
初始: len=0 cap=3
append 1 后: len=1 cap=3 ...
append 4 后: len=4 cap=6 ... ← cap 满了,扩容并搬到新数组
规则:容量够就原地追加;不够就按增长策略分配新数组(小切片大约翻倍,大切片增速放缓)再整体拷贝。所以 append 的返回值必须接回来,否则可能还在旧数组上操作。
步骤 4:切片表达式——共享底层数组的坑
package main
import "fmt"
func main() {
s := []int{1, 2, 3, 4, 5}
sub := s[1:3] // 左闭右开:元素 2、3
fmt.Println(sub, len(sub), cap(sub)) // [2 3] 2 4
sub[0] = 999
fmt.Println(s) // [1 999 3 4 5]!原切片被改了
// append 也可能写到别人家里:
sub2 := append(sub, 777)
fmt.Println(s) // [1 999 3 777 5],777 挤掉了 4!
fmt.Println(sub2) // [999 3 777]
}
s[1:3] 与 s 共享同一块底层数组,改子切片就是改原切片。要独立的副本,用 copy 或完整三索引写法(下一步)。
步骤 5:copy 与三索引切片
package main
import "fmt"
func main() {
s := []int{1, 2, 3, 4, 5}
// copy:按短的来,返回拷贝的元素个数
dst := make([]int, len(s))
n := copy(dst, s)
fmt.Println(dst, n)
dst[0] = 111
fmt.Println(s) // 不受影响
// 三索引 s[low:high:max]:把 cap 限制为 max-low,append 必然新开数组
safe := s[1:3:3]
safe = append(safe, 888) // cap 已满,搬新家,不再污染 s
fmt.Println(s) // [1 2 3 4 5]
fmt.Println(safe) // [2 3 888]
}
步骤 6:map——增删改查与 comma-ok
package main
import "fmt"
func main() {
// 创建:字面量 / make
ages := map[string]int{"张三": 20, "李四": 25}
scores := make(map[string]float64)
// 增、改
ages["王五"] = 30
scores["math"] = 98.5
// 查:comma-ok 判断 key 是否存在
v, ok := ages["赵六"]
fmt.Println(v, ok) // 0 false —— 零值 + 不存在
if age, ok := ages["张三"]; ok {
fmt.Println("张三:", age)
}
// 删:delete 内置函数,key 不存在也不报错
delete(ages, "李四")
delete(ages, "不存在也没事")
// 遍历:for range(顺序是随机的,这是故意的!)
for name, age := range ages {
fmt.Println(name, age)
}
fmt.Println(len(ages))
}
为什么 comma-ok 必不可少:key 不存在时 map 返回 value 的零值。如果 0 本身是合法值,单看 v == 0 无法区分「值为 0」和「没有这个 key」。
步骤 7:字符串的遍历——byte 与 rune
package main
import "fmt"
func main() {
s := "Go语言"
fmt.Println(len(s)) // 8:字节数(中文 UTF-8 每个字 3 字节)
for i, b := range s { // range 按字节下标、解码出 rune
fmt.Printf("%d %c(%x) ", i, b, b)
}
fmt.Println()
runes := []rune(s) // 转成 rune 切片才是「字符」视图
fmt.Println(len(runes)) // 4
for _, r := range runes {
fmt.Printf("%c ", r)
}
}
for range 字符串时,下标 i 是字节位置、值是解码后的 rune,两者不连续递增——这是初学者最容易困惑的点。
讲清楚要点
- 数组是值类型,切片是「头 + 底层数组」:赋值切片只是拷贝了三件套头,底层数组仍共享。
- append 必须接收返回值:
s = append(s, x)。扩容会搬家,不接收返回值可能丢数据。 - 切片传参是「传头拷贝」:函数内修改元素会影响调用方(共享底层数组),但函数内 append 超出 cap 换了新数组后,调用方看不到。
- map 遍历无序是故意的:运行时刻意随机化起点,防止开发者依赖顺序;需要顺序就先取 key 排序。
- map 的 value 是值拷贝:结构体作 value 时不能直接
m["k"].field = x,要先取出、改、再存回。 - key 必须可比较:int/string/指针/只含可比较字段的结构体可以;切片、map、函数不行。
你可能会懵的地方
-
切片和数组到底什么时候用哪个? 几乎总是切片。数组只在两种场景出现:固定长度的密码学缓冲区、以及作为切片的底层。日常写
[]T即可。 -
var s []int(nil 切片)和s := []int{}(空切片)有区别吗?len都是 0,append 行为完全一样,日常可互换。区别在 JSON 序列化:nil 切片输出null,空切片输出[]。 -
切片作为函数参数,函数里 append 会不会影响我? 影响范围只到 cap 之内。函数内 append 若触发扩容,新数组只存在于函数内;所以「在函数里给切片加元素」要么返回新切片,要么传
*[]T指针。 -
map 为什么不能用
for按下标遍历? map 是哈希表,没有下标这个概念,key 的物理位置由哈希函数决定且会随扩容变化,语言层面干脆禁止依赖顺序。 -
为什么 map 不是并发安全的? 出于性能考虑,普通 map 读写不加锁,并发读写直接抛 fatal error(不是 panic,无法 recover)。需要并发安全时用
sync.Mutex包住,或用sync.Map(第 6 课展开)。
错法对照
| 错法 | 问题 | 正解 |
|---|---|---|
append(s, x) 不接收返回值 | 扩容后新数组被丢弃 | s = append(s, x) |
sub := s[1:3] 后直接改 sub | 与原切片共享底层数组 | copy 独立副本,或三索引 s[1:3:len(s)] |
向 nil map 写入 m["k"]=v | assignment to entry in nil map panic | 先 m = make(map[K]V) |
用 m[k] == 0 判断 key 存在 | 无法区分零值与不存在 | v, ok := m[k] |
| 依赖 map 遍历顺序写业务逻辑 | 每次运行顺序都可能不同 | 取出 key 排序后遍历 |
len("中文") 当字符数用 | 得到的是字节数 | len([]rune(s)) |
卡点急救
index out of range:访问了len之外的下标。打印len(s)检查;切片必须先 append/make 出长度才能按下标赋值。assignment to entry in nil map:map 忘了 make 就写入。fatal error: concurrent map read and map write:多 goroutine 读写同一个 map,立刻用go run -race复现,加sync.Mutex保护(第 6 课)。- 改了子切片却「莫名其妙」改了原数据:共享底层数组,按步骤 5 的 copy/三索引处理。
面试常问
-
切片的底层结构和扩容机制? 切片头是 {指向底层数组的指针, len, cap}。append 时 cap 不足则分配新数组:小切片(约 256 以下)大致翻倍,之后增速放缓到约 1.25 倍并做取整对齐,然后整块搬迁。所以 append 可能引起 O(n) 拷贝,频繁追加时预分配 cap 能显著提速。
-
切片和数组的区别? 数组长度是类型的一部分且是值类型(赋值即拷贝);切片长度可变、传递的是三件套头(浅拷贝),共享底层数组。
-
切片作为函数参数传递的是什么? 按值传递切片头(24 字节左右),底层数组共享。因此函数内改元素外部可见,append 换数组后外部不可见。
-
map 的 key 有什么要求?遍历为什么无序? key 必须是可比较类型(能
==)。Go 在运行时对遍历起点做了随机化,防止程序依赖未定义的顺序,这是语言层的刻意设计。
自测三问
make([]int, 3)和make([]int, 0, 3)的 len/cap 分别是多少?各适合什么场景?- 写出判断 map 中 key 是否存在的惯用写法,并解释为什么不能用零值比较代替。
s := []int{1,2,3,4,5},执行s2 := s[1:3]; s2 = append(s2, 99)后,s[3]是多少?为什么?
小结一句话
切片 = 指针 + len + cap 三件套挂在共享的底层数组上,append 会扩容搬家;map 是无序哈希表,存在性判断永远用 comma-ok——这两句话能解开本课 90% 的坑。
延伸
- Go 官方博客:Go Slices: usage and internals
- Go 语言规范:切片类型
- 下一课:结构体与接口——Go 没有 class,靠 struct + method + interface 撑起整个面向对象世界。