Menu

Golang map:创建、判断键、遍历、排序与删除

Go 的 map 存储键值对,查找速度很快。学习如何创建 map、用 comma-ok 判断键是否存在、删除、遍历(顺序是随机的)、对键排序、存储结构体,以及避免 nil map 和并发写入导致的 panic。

本页包含可运行的编辑器。编辑、运行,立即查看输出。

创建和使用 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 专门适用的场景中使用它。

Coddy 编程语言插图

和 Coddy 一起学编程

开始