CodeRoadMap
首页路线题库资源库文章社区
CodeRoadMap

程序员的学习成长路线图。内容免费开放,登录后可同步路线图与课时进度。

由 程序员柏风 独立维护 · 关于作者

学习

  • 路线图
  • 课程
  • 面试题
  • 文章
  • 我

更多

  • 资源
  • 社区
  • 关于作者
  • 内容说明

© 2026 CodeRoadMap

津ICP备2026012044号-1·津公网安备12011002026155号·coderoadmap@126.com
Go 语言入门/课时/数组、切片与 map
RawBlame
课时目录12 节

Go 语言入门

  1. 01Go 是什么
  2. 02基础语法与类型
  3. 03数组、切片与 map
  4. 04结构体与接口
  5. 05模块与包管理
  6. 06并发模型
  7. 07错误处理
  8. 08泛型入门
  9. 09I/O 与 CLI
  10. 10Web 与数据库
  11. 11测试与基准
  12. 12综合练习

数组、切片与 map

arrays、slices、maps、strings、make、comma-ok。

35 分钟

数组、切片与 map

这一课你将能做到

  1. 说清数组与切片的本质区别,画出切片「指针 + len + cap」的底层结构
  2. 用 make / append / 切片表达式 / copy 熟练操作切片,并能解释 append 的扩容行为
  3. 用 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,两者不连续递增——这是初学者最容易困惑的点。

讲清楚要点

  1. 数组是值类型,切片是「头 + 底层数组」:赋值切片只是拷贝了三件套头,底层数组仍共享。
  2. append 必须接收返回值:s = append(s, x)。扩容会搬家,不接收返回值可能丢数据。
  3. 切片传参是「传头拷贝」:函数内修改元素会影响调用方(共享底层数组),但函数内 append 超出 cap 换了新数组后,调用方看不到。
  4. map 遍历无序是故意的:运行时刻意随机化起点,防止开发者依赖顺序;需要顺序就先取 key 排序。
  5. map 的 value 是值拷贝:结构体作 value 时不能直接 m["k"].field = x,要先取出、改、再存回。
  6. 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"]=vassignment 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))

卡点急救

  1. index out of range:访问了 len 之外的下标。打印 len(s) 检查;切片必须先 append/make 出长度才能按下标赋值。
  2. assignment to entry in nil map:map 忘了 make 就写入。
  3. fatal error: concurrent map read and map write:多 goroutine 读写同一个 map,立刻用 go run -race 复现,加 sync.Mutex 保护(第 6 课)。
  4. 改了子切片却「莫名其妙」改了原数据:共享底层数组,按步骤 5 的 copy/三索引处理。

面试常问

  • 切片的底层结构和扩容机制? 切片头是 {指向底层数组的指针, len, cap}。append 时 cap 不足则分配新数组:小切片(约 256 以下)大致翻倍,之后增速放缓到约 1.25 倍并做取整对齐,然后整块搬迁。所以 append 可能引起 O(n) 拷贝,频繁追加时预分配 cap 能显著提速。

  • 切片和数组的区别? 数组长度是类型的一部分且是值类型(赋值即拷贝);切片长度可变、传递的是三件套头(浅拷贝),共享底层数组。

  • 切片作为函数参数传递的是什么? 按值传递切片头(24 字节左右),底层数组共享。因此函数内改元素外部可见,append 换数组后外部不可见。

  • map 的 key 有什么要求?遍历为什么无序? key 必须是可比较类型(能 ==)。Go 在运行时对遍历起点做了随机化,防止程序依赖未定义的顺序,这是语言层的刻意设计。

自测三问

  1. make([]int, 3) 和 make([]int, 0, 3) 的 len/cap 分别是多少?各适合什么场景?
  2. 写出判断 map 中 key 是否存在的惯用写法,并解释为什么不能用零值比较代替。
  3. 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 撑起整个面向对象世界。
加载本节练习…
← 上一课基础语法与类型下一课 →结构体与接口