Go 从 0 到精通 · 第 10 课:映射 map

学习定位:这是整套 Go 教程的第 10 课。
前置要求:已经完成第 9 课,掌握了切片的创建、append、长度与容量、截取与共享。
本课目标:掌握 map 的创建、增删改查、遍历,理解判断键是否存在的语法,知道 map 是引用类型的特点。


1. 本课你要解决的核心问题

切片用下标(0, 1, 2, …)来定位元素。但现实中很多数据不适合用数字下标来索引:

  • 用学号查学生姓名
  • 用单词查释义
  • 用 URL 统计访问次数

这些场景需要的是"通过一个键(key)找到对应的值(value)"——这就是 map 的用武之地。

你需要搞明白以下问题:

  • map 怎么创建、怎么存取数据
  • 怎么判断一个键是否存在
  • 怎么删除键值对
  • 怎么遍历 map
  • map 和切片有什么区别
  • map 有哪些常见坑

学完这一课,你就能用键值对结构来建模查找型的数据关系了。


2. map 的基本概念

2.1 什么是 map

map 是一种键值对(key-value)集合:

  • 每个(key)在 map 中唯一
  • 每个键对应一个(value)
  • 通过键可以快速找到对应的值

打个比方:map 就像一本字典——你查"apple"(键),能找到"苹果"(值)。

2.2 类型声明

1
map[键类型]值类型

例如:

  • map[string]int:键是字符串,值是整数
  • map[int]string:键是整数,值是字符串
  • map[string][]string:键是字符串,值是字符串切片

3. map 的创建

3.1 用字面量创建

1
2
3
4
5
6
7
8
9
10
11
12
13
package main

import "fmt"

func main() {
scores := map[string]int{
"小明": 90,
"小红": 85,
"小刚": 92,
}

fmt.Println(scores)
}

输出:

1
map[小刚:92 小明:90 小红:85]

注意:map 的打印顺序不固定。map 不保证遍历顺序。

3.2 用 make 创建

1
2
3
4
5
6
7
8
9
10
11
12
package main

import "fmt"

func main() {
ages := make(map[string]int)

ages["小明"] = 20
ages["小红"] = 22

fmt.Println(ages)
}

make(map[string]int) 创建一个空的 map,可以直接使用。

3.3 nil map

1
2
3
4
var m map[string]int  // m 是 nil

fmt.Println(m == nil) // true
fmt.Println(len(m)) // 0

nil map 可以读取(返回零值),但不能写入

1
2
3
var m map[string]int
fmt.Println(m["key"]) // 输出 0(零值),不会 panic
m["key"] = 1 // panic: assignment to entry in nil map

使用 map 前一定要初始化(用 make 或字面量)。


4. 增删改查

4.1 添加和修改

1
2
3
4
5
6
7
m := map[string]int{}

m["小明"] = 90 // 键不存在,添加
m["小红"] = 85 // 键不存在,添加
m["小明"] = 95 // 键已存在,更新

fmt.Println(m) // map[小明:95 小红:85]

添加和修改用的是同一个语法:m[key] = value。键不存在就添加,存在就更新。

4.2 查找

1
2
3
4
5
6
7
m := map[string]int{"小明": 90, "小红": 85}

score := m["小明"]
fmt.Println(score) // 90

notFound := m["小王"]
fmt.Println(notFound) // 0(零值,不是报错)

直接用 m[key] 查找。如果键不存在,返回值类型的零值(int0string"")。

但这有一个问题:m["小王"] 返回 0,你无法区分"小王的分数是 0"和"小王不存在"。

4.3 判断键是否存在(重要)

Go 提供了"逗号 ok"模式来判断键是否存在:

1
2
3
4
5
6
7
8
9
10
11
12
13
m := map[string]int{"小明": 90, "小红": 85}

score, ok := m["小明"]
if ok {
fmt.Printf("小明的分数:%d\n", score)
} else {
fmt.Println("小明不存在")
}

score2, ok := m["小王"]
if !ok {
fmt.Println("小王不存在")
}
  • 第一个返回值是值
  • 第二个返回值 okbool:键存在为 true,不存在为 false

这是 Go 中非常常见的模式,一定要掌握。

4.4 删除

1
2
3
4
m := map[string]int{"小明": 90, "小红": 85, "小刚": 92}

delete(m, "小刚")
fmt.Println(m) // map[小明:90 小红:85]

delete(map, key) 删除指定键的键值对。如果键不存在,什么都不做(不报错)。


5. map 的遍历

5.1 用 for range 遍历

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
package main

import "fmt"

func main() {
scores := map[string]int{
"小明": 90,
"小红": 85,
"小刚": 92,
}

for name, score := range scores {
fmt.Printf("%s: %d分\n", name, score)
}
}

for range 遍历 map 返回两个值:键和值。

5.2 遍历顺序不固定

1
2
3
4
for name, score := range scores {
fmt.Println(name, score)
}
// 每次运行的输出顺序可能不同

Go 从 1.12 开始,map 的遍历顺序是随机的。不要依赖 map 的遍历顺序

如果需要有序遍历,先提取键并排序,再按顺序查 map。

5.3 只遍历键或只遍历值

1
2
3
4
5
6
7
8
9
// 只遍历键
for name := range scores {
fmt.Println(name)
}

// 只遍历值
for _, score := range scores {
fmt.Println(score)
}

6. map 的零值与初始化

6.1 三种状态对比

1
2
3
var a map[string]int          // nil map
b := map[string]int{} // 空 map(已初始化)
c := make(map[string]int) // 空 map(已初始化)
状态 == nil 能读 能写 len
nil map true 能(返回零值) panic 能(返回 0)
空 map false

关键nil map 只能读不能写。使用 map 前务必初始化。

6.2 make 的第二个参数

1
m := make(map[string]int, 100)

第二个参数 100 是预估的元素数量提示。它不影响 map 的行为(不像切片的 cap),只是给运行时一个提示,让它分配更合适的初始内存,减少后续扩容次数。


7. map 作为函数参数

7.1 map 是引用类型

1
2
3
4
5
6
7
8
9
10
11
12
13
package main

import "fmt"

func addScore(m map[string]int, name string, score int) {
m[name] = score
}

func main() {
scores := map[string]int{"小明": 90}
addScore(scores, "小红", 85)
fmt.Println(scores) // map[小明:90 小红:85]
}

map 传给函数后,函数内外共享同一个 map。在函数里增删改查都会影响外部。

这和切片不同(切片的 append 在函数内不影响外部),也和数组不同(数组传参完全复制)。

7.2 但 map 本身仍然是值传递

严格来说,Go 还是值传递——传给函数的是 map header 的副本。但 map header 内部包含指向底层哈希表的指针,所以函数内外共享同一个底层数据。

你不需要太纠结这个细节,只要记住:map 在函数内修改会影响外部


8. map 的常见用法

8.1 统计词频

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
package main

import (
"fmt"
"strings"
)

func main() {
text := "apple banana apple cherry banana apple"
words := strings.Fields(text)

freq := make(map[string]int)
for _, w := range words {
freq[w]++
}

for word, count := range freq {
fmt.Printf("%s: %d\n", word, count)
}
}

输出:

1
2
3
apple: 3
banana: 2
cherry: 1

freq[w]++ 是一个很优雅的模式:如果 w 不存在,freq[w] 返回 0++ 后变成 1

8.2 用 map 模拟集合(Set)

Go 没有内置的 Set 类型,可以用 map[T]struct{} 实现:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
package main

import "fmt"

func main() {
seen := make(map[string]struct{})

items := []string{"apple", "banana", "apple", "cherry", "banana"}

for _, item := range items {
seen[item] = struct{}{}
}

fmt.Println("去重后的元素:")
for item := range seen {
fmt.Println(item)
}
}

map[string]struct{} 的值类型是空结构体 struct{},它不占内存。这种模式只关心"键是否存在"。

8.3 map 嵌套切片

1
2
3
4
5
6
7
8
students := map[string][]int{
"小明": {90, 85, 92},
"小红": {88, 91, 79},
}

for name, scores := range students {
fmt.Printf("%s 的成绩:%v\n", name, scores)
}

9. 一段综合示例

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
package main

import (
"fmt"
"sort"
)

func main() {
// 创建 map
inventory := map[string]int{
"苹果": 50,
"香蕉": 30,
"橙子": 20,
}

// 修改
inventory["苹果"] += 10
inventory["葡萄"] = 15

// 删除
delete(inventory, "香蕉")

// 判断键是否存在
if qty, ok := inventory["橙子"]; ok {
fmt.Printf("橙子库存:%d\n", qty)
}

// 按键排序遍历
keys := make([]string, 0, len(inventory))
for k := range inventory {
keys = append(keys, k)
}
sort.Strings(keys)

fmt.Println("=== 库存清单 ===")
for _, k := range keys {
fmt.Printf(" %s: %d\n", k, inventory[k])
}

// 统计总库存
total := 0
for _, qty := range inventory {
total += qty
}
fmt.Printf("总库存:%d\n", total)
}

输出:

1
2
3
4
5
6
橙子库存:20
=== 库存清单 ===
橙子: 20
葡萄: 15
苹果: 60
总库存:95

10. 常见坑总结

10.1 使用未初始化的 nil map

1
2
var m map[string]int
m["key"] = 1 // panic

防范:用 make 或字面量初始化。

10.2 无法区分"键不存在"和"值为零值"

1
2
3
4
5
m := map[string]int{"小明": 0}
val := m["小明"] // val = 0
val2 := m["小王"] // val2 = 0

// val 和 val2 都是 0,但含义不同

防范:用 val, ok := m[key] 判断。

10.3 以为遍历顺序固定

1
2
3
4
5
m := map[int]string{1: "a", 2: "b", 3: "c"}
for k, v := range m {
fmt.Println(k, v)
}
// 每次运行顺序可能不同

防范:需要有序遍历时,先提取键并排序。

10.4 在并发中使用普通 map

普通的 map 不是并发安全的。多个 goroutine 同时读写一个 map 可能导致 panic。

防范:并发场景下使用 sync.Map 或加锁保护。

10.5 混淆切片和 map 的函数传参语义

1
2
3
4
5
// 切片:append 在函数内不影响外部
func add(s []int) { s = append(s, 1) }

// map:在函数内修改会影响外部
func put(m map[string]int) { m["key"] = 1 }

11. 本课练习

练习 1:学生信息管理

要求:

  • 创建 map[string]int,存储若干学生的成绩
  • 实现添加学生、查询成绩、删除学生三个操作
  • 用"逗号 ok"模式处理查不到的情况

练习 2:词频统计

要求:

  • 给定一个字符串,统计每个字符出现的次数
  • 例如 "hello" 输出 h:1 e:1 l:2 o:1

练习 3:用 map 去重

要求:

  • 给定一个 []string 切片
  • 用 map 去重,保持首次出现的顺序
  • 返回去重后的切片

练习 4:map 作为函数参数

要求:

  • 定义函数 update(m map[string]int, key string, delta int)
  • 如果 key 不存在就添加,存在就在原值基础上加 delta
  • 验证函数内外共享同一个 map

练习 5:分组

要求:

  • 给定一个 []string,例如 ["apple", "ant", "bat", "bee", "cat"]
  • 按首字母分组,返回 map[string][]string
  • 例如 {"a": ["apple", "ant"], "b": ["bat", "bee"], "c": ["cat"]}

练习 6:按键排序遍历

要求:

  • 创建一个 map[string]int
  • 实现按键的字母序排序后遍历打印
  • 提示:先提取键到切片,排序切片,再遍历

12. 自测题

12.1 概念题

  1. map 的类型声明语法是什么?
  2. 怎么判断 map 中某个键是否存在?
  3. nil map 进行写入会怎样?
  4. map 的遍历顺序是固定的吗?
  5. map 是值类型还是引用类型?传给函数后修改会影响外部吗?
  6. delete 函数做什么?对不存在的键使用会报错吗?
  7. map[string]int{"a": 0} 中,m["b"] 返回什么?怎么区分"值是 0"和"键不存在"?
  8. 怎么用 map 实现集合(Set)?

13. 本课总结

这一课你学到了 Go 中最常用的查找型数据结构——map。

你现在应该已经理解:

  • map 是键值对集合,用 map[K]V 声明
  • 用字面量或 make(map[K]V) 创建
  • m[key] = val 添加或修改,delete(m, key) 删除
  • val, ok := m[key] 判断键是否存在
  • for range 遍历 map,顺序不固定
  • map 是引用类型,传给函数后内外共享
  • nil map 只能读不能写,使用前务必初始化
  • 不要在并发场景直接使用普通 map

14. 下一课预告

下一课我们深入 Go 的字符串底层:字符串、byterune

会重点讲:

  • 字符串在内存中是什么样子
  • byterune 分别是什么
  • 为什么 len("你好") 不等于 2
  • 怎么正确遍历和处理中文字符串
  • 字符串不可变性

学完下一课,你就能正确处理包含中文、emoji 等多字节字符的场景了。