Blog

Go 的 map:nil map、遍历顺序和 comma-ok

Go 的 map 按键存值,靠哈希来查找。弄清读取不存在的键为什么得到零值,写入 nil map 为什么会 panic,遍历顺序为什么每次运行都不一样。

在 Go 里按名字查东西,用的就是 map。你给它一个键,它把这个键下存的值交给你,而且不管有多少个条目,都查得很快。

map 用起来简单,但有几条规则容易让人栽跟头:键不存在不算错误,nil map 能读不能写,取出来的顺序也从来不是你放进去的顺序。本文把这些规则都讲一遍,再介绍 maps 包。下面每个程序都在 Go 1.26 上跑过,输出直接从运行结果粘贴而来。

创建、读取、写入和删除

map 类型写作 map[K]VK 是键的类型,V 是值的类型。创建 map 最快的方式是字面量:

package main

import "fmt"

func main() {
	stock := map[string]int{
		"apples": 5,
		"bread":  2,
	}
	stock["cheese"] = 7 // add a key
	stock["apples"] = 4 // replace a value
	delete(stock, "bread")
	delete(stock, "figs") // not there, so nothing happens

	fmt.Println(stock["apples"], stock["cheese"], len(stock))
	fmt.Println(stock)
}

输出:

4 7 2
map[apples:4 cheese:7]

m[key] = value 在键是新的时添加它,键已存在时替换它的值。没有单独的“插入”和“更新”。delete 删除一个键,删除不存在的键也没问题。len 告诉你 map 里有多少个键。

最后一行看起来是有序的,但别从中推断什么。fmt 打印 map 之前会先给键排序,所以输出是稳定的。map 本身不保存任何顺序,后面有一节会讲到。

你也可以用 make 创建 map,并给出大小提示:

package main

import (
	"fmt"
	"strconv"
)

func main() {
	a := map[string]int{}
	b := make(map[string]int)
	c := make(map[string]int, 1000)
	fmt.Println(len(a), len(b), len(c))

	for i := range 1000 {
		c[strconv.Itoa(i)] = i
	}
	fmt.Println(len(c), c["999"])
}

输出:

0 0 0
1000 999

ab 是一回事:一个空 map,可以直接用。c 也是空的。1000 不是长度,而是提示大约会有 1000 个条目,这样运行时可以提前留出足够的空间,不必在你填充的过程中反复扩容。

和切片不同,map 没有可以查询的容量。cap 不接受 map,添加键时 map 会自己增长。你永远不用像写 s = append(s, x) 那样写 m = add(m, ...)

键不存在时得到零值

在 Go 里读取 map 中不存在的键不会失败。你得到的是值类型的零值,大多数 map 相关的 bug 就从这里开始:

package main

import "fmt"

func main() {
	stock := map[string]int{"apples": 5, "bread": 0}
	fmt.Println(stock["bread"], stock["figs"])

	n, ok := stock["bread"]
	fmt.Println(n, ok)

	n, ok = stock["figs"]
	fmt.Println(n, ok)

	fmt.Println(len(stock))
}

输出:

0 0
0 true
0 false
2

stock["bread"]stock["figs"] 都输出 0。一个表示“面包卖完了”,另一个表示“我们从来没卖过无花果”。普通的读取分不清这两种情况。

双返回值的形式能分清。n, ok := stock["figs"] 在键存在时把 ok 设为 true,不存在时设为 false。这就是 comma-ok 惯用法,只要零值可能是一个真实的值,就该用它。

最后一行也很重要。读取 stock["figs"] 并没有把 "figs" 加进 map,长度还是 2。读取从不改变 map。

如果零值不可能是真实的答案,就不需要 ok。最常见的例子是把 map[string]bool 当集合用:不存在的键读出来是 false,正好就是你要的答案。

用十岁孩子能懂的话说

想象一整面墙的储物柜。每个柜门上贴着标签,比如“apples”,里面放着东西,比如数字 5。这就是 map。标签是键,里面的东西是值。

你要“apples”那个柜子,就拿到里面的东西。你要一个标着“figs”的柜子,而它根本不存在,没人会冲你嚷嚷,只会递给你一个空盒子。装数字的空盒子里是 0,装文字的空盒子里什么也没有。

如果你想知道那个柜子是不是真的存在,就再问一句:“那个柜子有吗?”这就是 ok

准确的说法

map 查找键时不会逐个检查所有条目。它先把键交给哈希函数,把键变成一个数。这个数决定去 map 的哪一小块里找,只有那一小块里的条目才会拿来和你的键比较。

下面用四个桶画出这个思路。桶的编号只是示意,不是真实的哈希值:

"cai" "eve" 哈希 桶号 2 3 0 1 2 3 ana: 31 ben: 4 cai: 9 dee: 2 m 有 4 个键,分布在 4 个桶里 v, ok := m["cai"] v, ok := m["cai"] // 9, true v, ok := m["eve"] v, ok := m["eve"] // 0, false 每个键值对都放在某一个桶里 对 "cai" 求哈希:假设落在 2 号桶 只查 2 号桶:cai 匹配,返回 9, true 对 "eve" 求哈希:假设落在 3 号桶 3 号桶里没有 eve:返回零值和 false

查找时先对键求哈希,用哈希值选出一个桶,只在这个桶里比较键。键匹配就返回它的值和 true,键不存在就返回零值和 false。桶的编号只是示意,Go 真正的 map 结构也比四个桶复杂得多,但思路一样:先用哈希定位到一小块地方,然后只在那里找。

如果动画没有播放,下面用文字把这几步再说一遍:

  1. map 里有四对键值。每一对放在一个桶里,桶由键的哈希决定。
  2. m["cai"]"cai" 交给哈希函数。假设哈希落在 2 号桶。
  3. map 只查 2 号桶。它比较键,找到 cai,返回 9 和 true
  4. m["eve"]"eve" 求哈希。假设落在 3 号桶。
  5. map 只查 3 号桶。那里的两个键都不是 eve,所以返回零值 0 和 false。map 里什么也没有添加。

真实的运行时比图里复杂。从 Go 1.24 起,map 采用 Swiss table(瑞士表)。条目按每组八个槽位存放,每组有一个控制字,每个槽位在里面占一个字节。这个字节存着键的哈希值中的几位,所以运行时不用比较键,就能排除大部分槽位。大的 map 会拆成几张表,各自独立增长。这些都不会改变你在代码里看到的行为:哈希决定去哪儿找,查不到的开销和查到差不多一样小。

每个 map 还有自己的随机哈希种子。同一个键在两个不同的 map 里落在不同位置,在程序的两次运行中也不一样。所以上面的数字永远只能是示意,这也是遍历顺序不固定的原因之一。

这个比喻的局限:真正的空盒子是个新柜子,你可以往里放东西。而 Go 读取不存在的键时什么都不会创建。它只是递回一个零值,map 保持原样。

nil map:能读,写就 panic

map 类型的零值是 nil。除了存入键以外,nil map 在其他方面都和空 map 一样:

package main

import "fmt"

func main() {
	var prices map[string]float64
	fmt.Println(prices == nil, len(prices), prices["tea"])

	for k := range prices {
		fmt.Println("never runs", k)
	}
	delete(prices, "tea")

	prices["tea"] = 2.5
	fmt.Println("never reached")
}

它输出第一行,然后停下:

true 0 0
panic: assignment to entry in nil map

从 nil map 读取、用 range 遍历它、取它的长度、从中删除,都没问题。这些问题在什么都没有的时候答案都很明显。写入就不一样了。写入需要一个地方放条目,而 nil map 连表都没有。

为什么 Go 不自动帮你分配一个?因为 map 变量保存的是指向 map 表的指针。如果写入时分配了新表,只有被写入的那个变量会指向它。这个 nil map 的任何副本,比如调用方传进函数的那一份,仍然是 nil,条目就像凭空消失了。panic 更响亮,也更诚实。

修复方法是在写入之前先创建 map,用字面量或 make 都行:

package main

import "fmt"

func main() {
	var prices map[string]float64
	if prices == nil {
		prices = make(map[string]float64)
	}
	prices["tea"] = 2.5
	fmt.Println(prices)
}

输出:

map[tea:2.5]

在实际代码中,nil map 通常藏在别的东西里,比如一个没人初始化的结构体字段。只读的时候,var m map[K]V 没问题。要写入时,就从 m := map[K]V{}make 开始。

遍历顺序算不上顺序

用 range 遍历 Go 的 map,每个键都恰好访问一次,但顺序不固定,而且同一次运行里对同一个 map 的两次循环,顺序也可能不一样:

package main

import "fmt"

func main() {
	m := map[string]int{"a": 1, "b": 2, "c": 3, "d": 4, "e": 5}
	for k := range m {
		fmt.Println("loop 1:", k)
	}
	for k := range m {
		fmt.Println("loop 2:", k)
	}
}

某次运行输出:

loop 1: d
loop 1: e
loop 1: a
loop 1: b
loop 1: c
loop 2: a
loop 2: b
loop 2: c
loop 2: d
loop 2: e

两次循环之间 map 没有变。每个 range 都各自随机选了一个起点。

在 Go 1.26 上运行这段代码时,发现了一个值得了解的现象。map 这么小的时候,顺序常常看起来像是插入顺序转了个圈:a b c d e,然后 d e a b c,再然后 c d e a b。这是小 Swiss table 存放条目方式带来的巧合,而这恰恰是人们容易开始依赖的那种规律。语言对此没有任何保证,下一个版本随时可以改变它。

在遍历时修改 map 有它自己的规则。删除一个还没遍历到的键,你就不会再看到它;删除当前正在访问的键是安全的。循环中添加的键可能被访问到,也可能不会,两种情况都别指望。边遍历边删除是常见又安全的做法:

package main

import "fmt"

func main() {
	stock := map[string]int{"apples": 5, "bread": 0, "cheese": 7, "dates": 0}
	for item, n := range stock {
		if n == 0 {
			delete(stock, item)
		}
	}
	fmt.Println(stock)
}

输出:

map[apples:5 cheese:7]

得到稳定的顺序

输出需要顺序时,由你自己决定,通常是给键排序。maps.Keys 返回键的迭代器,slices.Sorted 把它们收集成一个有序切片。也可以按值排序:

package main

import (
	"cmp"
	"fmt"
	"maps"
	"slices"
	"strings"
)

func main() {
	votes := map[string]int{"tea": 4, "coffee": 7, "juice": 4, "water": 1}

	fmt.Println(slices.Sorted(maps.Keys(votes)))

	drinks := slices.Collect(maps.Keys(votes))
	slices.SortFunc(drinks, func(a, b string) int {
		return cmp.Or(
			cmp.Compare(votes[b], votes[a]), // most votes first
			strings.Compare(a, b),           // then by name
		)
	})
	fmt.Println(drinks)
}

输出:

[coffee juice tea water]
[coffee juice tea water]

第一行按字母顺序排列。第二次排序把票数最多的放在前面。teajuice 都是 4 票,所以由按名字排序的次要规则决定先后。如果没有这条次要规则,两次运行可能给出不同的顺序,因为键本来就是以随机顺序出来的。

cmp.Or 返回第一个不为零的参数,正是它让“先按这个排,再按那个排”变成一个表达式。

把 map 传给函数

把 map 传给 Go 函数,map 不会被复制。函数得到的是 map 变量的副本,而这个副本指向同一张表,所以每处修改都会反映出来:

package main

import "fmt"

func restock(m map[string]int, item string, n int) {
	m[item] += n
}

func replace(m map[string]int) {
	m = map[string]int{"surprise": 1}
	fmt.Println("inside replace:", m)
}

func main() {
	stock := map[string]int{"apples": 5}
	restock(stock, "apples", 3)
	restock(stock, "bread", 2)
	replace(stock)
	fmt.Println(stock)
}

输出:

inside replace: map[surprise:1]
map[apples:8 bread:2]

restock 修改了已有的值,还添加了一个全新的键,调用方两处改动都看得到。和切片比较一下。对传进来的切片做 append 的函数,改变不了调用方的长度,因为长度在函数复制的切片头里。map 把大小保存在共享的表里,所以添加的键也会反映出来。

replace 就是界限。把一个全新的 map 赋给 m,只改变了函数自己的变量,调用方仍然指向旧表。想交回另一个 map 的函数,应该返回它。

m[item] += n 对还不存在的键也有效。读取得到 0,写入存的是 0 加 n

不能修改 map 里结构体的字段

Go map 中存储的值不可寻址,所以你不能原地给它的一部分赋值:

package main

import "fmt"

type player struct {
	name  string
	score int
}

func main() {
	players := map[string]player{"ana": {name: "Ana"}}
	players["ana"].score = 10
	fmt.Println(players)
}

构建失败,报错:

./main.go:12:2: cannot assign to struct field players["ana"].score in map

原因就是查找那一节里提到的增长。map 增长时,运行时会把条目挪到新的位置。如果 Go 允许你持有指向 map 内部某个值的指针,这个指针最后可能指向一个 map 早已搬离的槽位。所以语言不允许你取 map 值的地址,而给它的字段赋值恰恰需要取地址。&players["ana"] 也因为同样的原因被拒绝。

有两种修复方法。读出值,修改副本,再存回去;或者在 map 里存指针:

package main

import "fmt"

type player struct {
	name  string
	score int
}

func main() {
	players := map[string]player{"ana": {name: "Ana"}}
	p := players["ana"]
	p.score = 10
	players["ana"] = p

	byPointer := map[string]*player{"ben": {name: "Ben"}}
	byPointer["ben"].score = 7

	fmt.Println(players["ana"].score, byPointer["ben"].score)
}

输出:

10 7

复制再存回的写法让 map 始终是它的值的唯一所有者。存指针的写法在频繁更新时更简短,但有一个陷阱:指针的零值是 nil,所以对不存在的键执行 byPointer["zed"].score = 1 会 panic。讲结构体、方法和指针的那一部分会介绍什么时候选哪种。

什么可以做键

Go map 的键必须是可比较的,也就是能用 ==,因为 map 要检查两个键是否相同。切片、map 和函数不能用 == 比较,所以不能做键:

package main

import "fmt"

func main() {
	seen := map[[]string]bool{}
	fmt.Println(seen)
}

构建失败,报错:

./main.go:6:14: invalid map key type []string

字符串、数字、布尔值、指针和通道都可以。数组和结构体也可以,只要每个元素或字段也都可比较。想用多个值共同作为 map 的键,结构体键是干净的做法:

package main

import "fmt"

type cell struct {
	row, col int
}

func main() {
	board := map[cell]string{}
	board[cell{0, 0}] = "X"
	board[cell{1, 2}] = "O"

	fmt.Println(board[cell{1, 2}], len(board))
	_, taken := board[cell{2, 2}]
	fmt.Println("2,2 taken:", taken)

	trips := map[[2]string]int{}
	trips[[2]string{"home", "work"}]++
	trips[[2]string{"home", "work"}]++
	trips[[2]string{"work", "gym"}]++
	fmt.Println(trips[[2]string{"home", "work"}], len(trips))
}

输出:

O 2
2,2 taken: false
2 2

在两个不同地方构造的 cell{1, 2} 是同一个键,因为所有字段都相等时,两个结构体就相等。你不需要把行和列拼成 "1,2" 这样的字符串。

有一种键类型能通过编译器,却在之后出错。用 any 这样的接口做键时,编译器没法知道你会存什么,所以切片会一直溜到程序运行时才被发现:

package main

import "fmt"

func main() {
	seen := map[any]bool{}
	seen[42] = true
	seen["42"] = true
	fmt.Println(len(seen))

	seen[[]int{4, 2}] = true
}

它输出第一行,然后停下:

2
panic: runtime error: hash of unhashable type []int

42"42" 是不同的键,因为它们的类型不同。切片根本无法求哈希,而这个检查只能在运行时进行。

实例:统计单词

统计每个单词出现的次数是 map 的教科书式用法,有了零值,循环体里一行就够了:

package main

import (
	"fmt"
	"maps"
	"slices"
	"strings"
)

func main() {
	text := "the cat sat on the mat and the cat slept"

	counts := map[string]int{}
	for _, word := range strings.Fields(text) {
		counts[word]++
	}

	for _, word := range slices.Sorted(maps.Keys(counts)) {
		fmt.Println(word, counts[word])
	}
}

输出:

and 1
cat 2
mat 1
on 1
sat 1
slept 1
the 3

counts[word]++ 读取当前计数(单词第一次出现时是 0),加一再存回去。不需要“如果键存在”的检查。

实例:分组到切片中

把值按键分组是另一项日常工作,map[string][]string 配合 append 就能搞定:

package main

import (
	"fmt"
	"maps"
	"slices"
)

func main() {
	fruit := []string{"apple", "banana", "avocado", "cherry", "blueberry", "apricot"}

	byLetter := map[string][]string{}
	for _, f := range fruit {
		letter := f[:1]
		byLetter[letter] = append(byLetter[letter], f)
	}

	for _, letter := range slices.Sorted(maps.Keys(byLetter)) {
		fmt.Println(letter, byLetter[letter])
	}
}

输出:

a [apple avocado apricot]
b [banana blueberry]
c [cherry]

某个字母第一次出现时,byLetter[letter] 是一个 nil 切片。对 nil 切片做 append 没问题,所以分组会自己建立起来。你必须用 byLetter[letter] = ... 把结果存回去,原因和写 s = append(s, x) 一样:append 可能返回基于新数组的切片。

每组里的水果保持输入的顺序,因为切片是有序的。只有键需要排序。

maps 包和 clear

标准库在 Go 1.21 中加入了 maps 包,以前要自己写循环的活儿,现在交给它就行:

package main

import (
	"fmt"
	"maps"
)

func main() {
	prices := map[string]int{"tea": 3, "coffee": 4, "cake": 5}

	backup := maps.Clone(prices)
	prices["tea"] = 99
	fmt.Println(backup["tea"], maps.Equal(prices, backup))

	maps.DeleteFunc(prices, func(item string, price int) bool {
		return price > 4
	})
	fmt.Println(prices)

	clear(prices)
	fmt.Println(prices, len(prices), prices == nil)
}

输出:

3 false
map[coffee:4]
map[] 0 false

maps.Clone 创建一个键和值都相同的新 map,所以修改 prices 不会影响 backup。不过这是浅复制。如果值是切片或指针,两个 map 共享它们指向的内容。

maps.Equal 判断两个 map 是否拥有相同的键和相同的值。之所以需要它,是因为 == 不能用在两个 map 之间。map 唯一能比较的对象是 nil

maps.DeleteFunc 删除函数返回 true 的每个条目,相当于把遍历那一节里的循环变成一次调用。蛋糕的价格是 5,茶现在是 99,所以两者都被删掉了。

clear 从 Go 1.21 起是内置函数,它删除所有条目。map 空了,但仍然可以用,而且不是 nil。

clear 还能做一件 delete 做不到的事。浮点数 NaN 不等于任何值,连它自己都不等于,所以 NaN 键能放进去,却再也找不回来:

package main

import (
	"fmt"
	"math"
)

func main() {
	m := map[float64]string{}
	m[math.NaN()] = "first"
	m[math.NaN()] = "second"
	fmt.Println(len(m))

	delete(m, math.NaN())
	fmt.Println(len(m))

	clear(m)
	fmt.Println(len(m))
}

输出:

2
2
0

两次写入“同一个” NaN 键,产生了两个条目,delete 一个也找不到。clear 不查找键,所以照样能把它们删掉。你很少会用浮点数做 map 的键,但真这么做了,这就是原因。

map 和 goroutine

普通的 Go map 不能被多个 goroutine 同时使用。两个 goroutine 同时写入时,运行时可能以 fatal error: concurrent map writes 终止整个程序。讲 sync 和竞态检测器的那一部分会介绍如何避免。

要点

  • m[k] = v 添加或替换,delete(m, k) 删除,len(m) 计数。读取不存在的键返回零值,而且不会添加这个键。
  • 只要零值可能是真实的值,就用 v, ok := m[k]
  • nil map 可以读取、遍历和删除,但写入会 panic。先用字面量或 make 创建。
  • 遍历顺序从来没有保证,哪怕小 map 看起来保持着某种顺序。顺序重要时,用 slices.Sorted(maps.Keys(m)) 给键排序。
  • 传给函数的 map 共享同一张表,所以新增的键和修改的值都会反映出来。
  • map 的值不可寻址。复制、修改再存回,或者存指针。键必须可比较,结构体很适合做多部分组成的键。
  • maps.Clonemaps.Equalmaps.DeleteFuncclear 可以替代大多数手写的 map 循环。

map 靠键的哈希找到值,所以它给不了你顺序,只能给你答案。

这篇文章对你有帮助吗?

点一颗爱心来评分!

平均评分 0 / 5. 投票总数: 0

还没有人投票。来做第一个评分的人吧。