Go 的 map 按键存值,靠哈希来查找。弄清读取不存在的键为什么得到零值,写入 nil map 为什么会 panic,遍历顺序为什么每次运行都不一样。
在 Go 里按名字查东西,用的就是 map。你给它一个键,它把这个键下存的值交给你,而且不管有多少个条目,都查得很快。
map 用起来简单,但有几条规则容易让人栽跟头:键不存在不算错误,nil map 能读不能写,取出来的顺序也从来不是你放进去的顺序。本文把这些规则都讲一遍,再介绍 maps 包。下面每个程序都在 Go 1.26 上跑过,输出直接从运行结果粘贴而来。
创建、读取、写入和删除
map 类型写作 map[K]V,K 是键的类型,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
a 和 b 是一回事:一个空 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 的哪一小块里找,只有那一小块里的条目才会拿来和你的键比较。
下面用四个桶画出这个思路。桶的编号只是示意,不是真实的哈希值:
查找时先对键求哈希,用哈希值选出一个桶,只在这个桶里比较键。键匹配就返回它的值和 true,键不存在就返回零值和 false。桶的编号只是示意,Go 真正的 map 结构也比四个桶复杂得多,但思路一样:先用哈希定位到一小块地方,然后只在那里找。
如果动画没有播放,下面用文字把这几步再说一遍:
- map 里有四对键值。每一对放在一个桶里,桶由键的哈希决定。
m["cai"]把"cai"交给哈希函数。假设哈希落在 2 号桶。- map 只查 2 号桶。它比较键,找到
cai,返回 9 和true。 m["eve"]对"eve"求哈希。假设落在 3 号桶。- 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]
第一行按字母顺序排列。第二次排序把票数最多的放在前面。tea 和 juice 都是 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.Clone、maps.Equal、maps.DeleteFunc和clear可以替代大多数手写的 map 循环。
map 靠键的哈希找到值,所以它给不了你顺序,只能给你答案。