Go 入门(03):数组、切片与 map——长度容量与扩容陷阱
更新时间:2026-09-01。本文是
languages/go/beginner/入门层第 3 篇,接 函数与错误处理。数组是定长,切片是 Go 最常用的动态序列——但切片的"长度 vs 容量"和"扩容换底层"是新手第一大坑,本篇讲透。
本文要回答的问题
- 数组和切片有什么区别?为什么 Go 几乎不用数组,都用切片?
- len() 和 cap() 分别是什么?什么时候 len 不等于 cap?
- append 什么时候会换底层数组?换了会有什么坑?
- 切片是引用类型吗?Go 传参时切片到底复制了什么?
- map 怎么用?有哪些常见坑?
一、数组:定长,值类型
Go 的数组是定长的,长度是类型的一部分:
var a [5]int // 数组,长度 5,每个元素都是 int,初始零值 0
a[0] = 10
fmt.Println(a[0], a[1]) // 10 0
fmt.Println(len(a)) // 5关键点:
- 长度必须编译期确定,
[5]int和[10]int是不同类型 - 数组是值类型——赋值给另一个变量会完整拷贝整个数组
- 因为定长不灵活,Go 日常编程几乎不用数组,都用切片
二、切片:动态长度,len + cap
切片是动态序列,它本身是个轻量结构体,指向一块底层数组:
// 切片结构(理解用,Go 源码大概就是这样)
type slice struct {
ptr *byte // 指向底层数组的指针
len int // 长度:已经使用的元素个数
cap int // 容量:底层数组总共能放多少
}创建切片的几种方式:
// 1. 直接创建
s := []int{1, 2, 3} // len=3, cap=3
// 2. make 创建:指定 len,cap 可选
s := make([]int, 5) // len=5, cap=5,全零值
s := make([]int, 5, 10) // len=5, cap=10,前面 5 个零值,还能再放 5 个再扩容
// 3. 从数组/切片切出来
a := [5]int{1, 2, 3, 4, 5}
s := a[1:3] // 从索引 1(包含)到 3(不包含),元素是 [2, 3]
// len=2, cap=4(从 1 到末尾一共 4 个位置)len vs cap:
len(s):切片现在有多少元素,range只会遍历 len 个元素cap(s):底层数组从切片指针开始最多能放多少元素,不需要你总是记着,但扩容时一定要懂
三、append:追加元素,扩容换底层
用 append 给切片追加元素:
s := make([]int, 2, 3) // len=2, cap=3 [0 0]
s = append(s, 1) // len=3, cap=3 → [0 0 1],还装得下,不用扩容
s = append(s, 2) // len=4 > cap=3 → 必须扩容!分配新底层数组扩容规则(Go 1.18+ 大概逻辑):
- 如果
cap < 1024,每次翻倍 - 如果
cap >= 1024,每次增长 25% - 扩容一定会分配新的底层数组,然后把旧数据拷贝过去
四、经典坑:append 扩容后,原切片看不到新内容
因为扩容换了底层数组,如果有多个切片指向同一个底层数组,修改其中一个的元素会互相影响,但扩容后就切断了关系:
s1 := []int{1, 2, 3, 4} // len=4, cap=4
s2 := s1[0:2] // s2 指向同一个底层数组,len=2, cap=4
fmt.Println(s1, s2) // [1 2 3 4] [1 2]
s2 = append(s2, 100) // 还有 cap,不用扩容 → 直接改底层数组!
fmt.Println(s1, s2) // s1 变成 [1 2 100 4],s2 是 [1 2 100]
// 因为共享底层数组,s2 append 没扩容,直接改了底层第三个位置,s1 也看到了!
s2 = append(s2, 200) // 现在 cap 满了,扩容,换了新底层
s2[0] = 999 // 改 s2 第一个元素
fmt.Println(s1, s2) // s1 还是 [1 2 100 4],不受影响 → 已经不共享了这个坑太经典了——当 cap 够的时候 append 不换底层,共享会互相干扰;扩容后换底层,就没关系了。新手经常踩。
五、切片是引用类型吗?传参时复制什么?
经常有人说"切片是引用类型"——这个说法不准确:
- 切片本身是结构体(值),传参时会复制这个结构体
- 结构体里的
ptr指针指向同一个底层数组
所以:
- 函数里修改切片的元素,外面能看到(因为 ptr 指向同一个底层数组)
- 函数里给切片
append扩容了,外面看不到(因为结构体是复制的,原来的 len/cap 没变,ptr 还是旧的)
解决:如果想让函数里扩容对外可见,返回切片:
func appendOne(s []int) []int {
s = append(s, 1)
return s
}
s = appendOne(s) // 外面接收新的切片这是 Go 标准库的惯例。
六、切片遍历:for vs for range
s := []string{"a", "b", "c"}
// 方式一:用索引
for i := 0; i < len(s); i++ {
fmt.Println(i, s[i])
}
// 方式二:for range
for i, v := range s {
fmt.Println(i, v)
// v 是元素的副本,改 v 不影响原切片!
}坑:for range 里的 v 是同一个变量,每次循环覆盖值,闭包捕获要小心:
for _, v := range s {
go func() {
fmt.Println(v) // 几乎都会打印最后一个元素!因为 v 被循环覆盖了
}()
}
// 解决:循环内 v := v,让每个闭包抓自己的副本
for _, v := range s {
v := v
go func() { fmt.Println(v) }()
}七、map:哈希表,键值对
map 是 Go 内置的哈希表(字典),所有可比较的类型都能当键:
// 创建
m := make(map[string]int) // 空 map,可以插入
m["age"] = 25
m["score"] = 90
// 字面量创建
m := map[string]int{
"age": 25,
"score": 90,
}
// 查找
age, ok := m["age"]
if ok {
// key 存在
} else {
// key 不存在
}技巧:查找返回两个值,第二个是是否存在——区分"键不存在"和"键存在但值是零值"。
删除用 delete:
delete(m, "age") // 不存在也不会报错遍历用 for range:
for k, v := range m {
fmt.Println(k, v)
}坑:遍历顺序是不确定的,Go 故意随机化,别依赖顺序。
八、map 常见坑
| 坑 | 现象 | 对策 |
|---|---|---|
| nil map 不能插入 | var m map[string]int 之后 m["a"] = 1 panic | 必须 make(map[string]int) 再用 |
| 查不存在的键返回零值 | age := m["age"],键不存在 age 是 0,你不知道不存在 | 用 age, ok := m["age"] 检查 ok |
| 并发读写不安全 | 多个 goroutine 同时写 map 直接 panic | 加锁 sync.RWMutex,或者用 sync.Map |
九、常见坑汇总(切片 + map)
| 坑 | 原因 |
|---|---|
| 分不清 len 和 cap | len 是用了多少,cap 是底层数组容量 |
| append 不扩容时共享底层互相改 | 多个切片切同一个数组,都能改同一块内存 |
| 函数里 append 外面看不到 | 切片结构体是值传递,len/cap 没变,要返回新切片 |
| for range 闭包抓循环变量 | v 是同一个变量,循环覆盖,闭包抓到最后一个 |
| nil map 插数据 panic | nil map 没分配哈希表,必须 make |
| map 遍历依赖顺序 | Go 故意随机,每次不一样 |
相关与延伸
下一篇:结构体与指针——Go 的面向对象基础;切片底层结构和内存布局对应 C 数组与指针;map 哈希表扩容和负载因子,进阶篇讲 Go runtime 实现;并发读写 map 的问题见 goroutine 与 channel。
一句话总结
数组定长是值类型,切片动态靠底层数组;len 是已用长度,cap 是总容量;append 满了才扩容,扩容换底层就切断共享;map 是内置哈希表,查找用双返回值区分"不存在"和"零值";切片传参复制结构体,扩容后要返回新切片让外面看到。