创建和使用 map
map 类型写作 map[KeyType]ValueType。用字面量或 make 创建,然后按键读取、写入和删除。
输出:
31
3
map[bob:26 cy:40]
2 0
这里体现了两个便利之处。读取不存在的键会返回值类型的零值(counts['z'] 是 0),所以用 m[k]++ 计数时不需要任何准备工作。另外,fmt 打印 map 时会对键排序,这对调试很方便,但和遍历顺序毫无关系。
make(map[K]V, n) 接受一个可选的容量提示。它会预先分配大约能放 n 个元素的空间;和切片不同,map 没有可以读取的容量。
判断键是否存在:comma-ok
因为不存在的键读出来是零值,所以 m[k] == 0 无法区分“不存在”和“存储的值是 0”。请使用双返回值形式:
if v, ok := m[k]; ok { ... } 这种写法让 v 和 ok 只在这个 if 内有效。它是 Go 代码中最常见的写法之一。
删除元素
delete(m, key) 删除对应的元素。删除一个不存在的键什么也不会发生,从 nil map 中删除也是如此。要清空整个 map,可以用 Go 1.21 新增的 clear(m),它会保留已分配的 map 以便复用。
在对同一个 map 做 range 时删除元素是允许的,也是安全的。在循环到达之前就被删除的元素不会再被遍历到。
遍历:顺序是随机的
for k, v := range m 会把每个元素访问一次,但顺序是不确定的。运行时会故意随机化起始位置,所以在同一个程序里对同一个 map 遍历两次,顺序也常常不一样。多运行几次试试:
任何输出依赖 map 顺序的代码都是一个 bug,只等换一次运行就会暴露出来。比较 map 遍历打印结果的测试就是典型的例子。
对键排序
要按键的顺序访问 map,就先取出键,排序,再用它们索引 map。Go 1.23 借助 maps 和 slices 包中的迭代器,把这件事变成了一行代码:
在 Go 1.22 及更早的版本中,标准库里没有 maps.Keys。等价的写法是一个循环:
keys := make([]string, 0, len(m))
for k := range m {
keys = append(keys, k)
}
sort.Strings(keys)
maps 包中的其他辅助函数:maps.Values、maps.Clone(浅拷贝)、maps.Equal、maps.Copy(dst, src) 和 maps.DeleteFunc。
合法的键类型
键必须能用 == 比较:数字、字符串、布尔值、指针、通道、元素可比较的数组、所有字段都可比较的结构体,以及接口值。切片、map 和函数不能作为键。
map[[]int]bool{} // compile error: invalid map key type []int
map[[2]int]bool{} // fine: arrays are comparable
map[struct{ X, Y int }]string{} // fine: a struct key for a grid position
当需要用多个值组合作为键时,惯用的做法是使用结构体键,而不是拼接字符串。
即使动态类型不可比较,接口类型的键也能编译通过,但随后会在运行时 panic:在 map[any]int 中以 []int 为键存值,会失败并报 runtime error: hash of unhashable type []int。
浮点数键可以使用,但 NaN 不等于它自身,所以 NaN 键可以被反复插入,却永远读不回来。避免使用浮点数键。
存放结构体的 map
map 可以存放结构体,但你不能给 map 中某个结构体的字段赋值,因为 map 的值是不可寻址的。
当元素较小、总是整体替换时,选择存值。当你经常更新字段,或者要在多处共享同一条记录时,选择存指针。存指针时,不存在的键会返回 nil,所以 ptrs["nope"].Score 会 panic。
存放切片的 map 在追加时也一样:groups[k] = append(groups[k], v) 不需要初始化,因为不存在的键会得到 nil 切片,而 append 能处理 nil。
map 类似引用
map 值指向共享的数据。把一个 map 赋值给另一个变量,或者传给函数,都不会复制其中的元素:两个变量看到的是同一个 map。
这就是为什么函数可以填充一个 map 而不用返回它,而对切片追加元素时就不行。
nil map panic
map 的零值是 nil。读取 nil map 就像读取一个空 map,但向它写入会 panic。
输出:
0 0
recovered: assignment to entry in nil map
实际中最容易踩坑的是结构体的情况。在构造函数中初始化 map 字段(func NewCache() *Cache { return &Cache{data: map[string]string{}} }),或者在第一次写入之前再延迟初始化。
并发访问
map 不是并发安全的。如果一个 goroutine 在写入,而另一个在读取或写入,运行时可能会以 fatal error: concurrent map writes(或 concurrent map read and map write)终止程序。这是一个致命错误,而不是 panic,所以 recover 无法捕获它。
用互斥锁保护 map:
这段代码总是打印 50 50。当读取远多于写入时,使用 sync.RWMutex。sync.Map 只为两种特定场景而设计(键写入一次后被读取很多次,或者各个 goroutine 操作互不重叠的键);其他情况下,互斥锁加普通 map 更简单,通常也更快。更多内容见互斥锁。
速查表
| 操作 | 代码 |
|---|---|
| 创建 | m := map[string]int{} 或 make(map[string]int) |
| 插入或更新 | m[k] = v |
| 读取(不存在时为零值) | v := m[k] |
| 判断是否存在 | v, ok := m[k] |
| 删除 | delete(m, k) |
| 全部删除 | clear(m)(Go 1.21) |
| 大小 | len(m) |
| 排好序的键 | slices.Sorted(maps.Keys(m))(Go 1.23) |
| 复制 | maps.Clone(m) |
| 比较 | maps.Equal(a, b) |
值为 struct{} 的 map 也是 Go 中的集合类型;见集合。
常见错误
- 向 nil map 写入。 总是用
make创建它,包括结构体中的 map 字段。 - 依赖遍历顺序。 对键排序。
- 用
m[k] != 0判断键是否存在。 使用 comma-ok。 - 通过
m[k].Field修改结构体字段。 先复制出来再写回,或者存储指针。 - 在多个 goroutine 之间共享 map 却不加锁。 这种崩溃无法恢复。
常见问题
Go 语言怎么判断 map 中的键是否存在?
使用双返回值形式的查找:v, ok := m[key]。键存在时 ok 为 true,不存在时为 false,此时 v 是零值。只读取 m[key] 无法区分键不存在和键对应的值恰好是零值这两种情况。
为什么 Go map 的遍历顺序是随机的?
语言本身没有规定顺序,而且运行时会故意让每次 range 从一个随机位置开始,这样程序就没法依赖某种顺序。要按键的顺序遍历,就把键收集起来并排序:for _, k := range slices.Sorted(maps.Keys(m))(Go 1.23)。
Go 语言怎么获取 map 的所有键?
从 Go 1.23 起,maps.Keys(m) 返回一个迭代器;用 slices.Collect(maps.Keys(m)) 把它变成切片,或者用 slices.Sorted(maps.Keys(m)) 得到排好序的切片。在 1.23 之前,用 for k := range m 循环,把每个键追加到一个切片中。
为什么写入 map 时会 panic,提示“assignment to entry in nil map”?
这个 map 变量只声明了,却从未创建:var m map[string]int 是 nil。读取 nil map 会返回零值,但写入会 panic。先用 m = make(map[string]int) 或字面量 m := map[string]int{} 创建它。结构体中的 map 字段也需要同样的初始化。
Go 的 map 是并发安全的吗?
不是。并发写入,或者写入的同时有读取,都可能让程序以 fatal error: concurrent map writes 崩溃,而且 recover 无法捕获它。用 sync.Mutex 或 sync.RWMutex 保护 map,或者在 sync.Map 专门适用的场景中使用它。