Golang Map
切片在处理顺序数据时非常有用。与大多数语言一样,Go 提供了一种内置数据类型(即映射),用于将一个值与另一个值关联起来。映射(map)类型的写法为 map[keyType]valueType。接下来,介绍一下声明映射的几种方法。首先,可以使用 var 声明来创建一个映射(map)变量,并将其设置为初始值(零值):
var nilMap map[string]int // 声明来创建一个映射变量,并将其设置为零值(nil)
在本例中,nilMap 被声明为具有 string 键和 int 值的映射。映射(map)的零值(Zero Value)是 nil,一个 nil 映射的长度为 0。尝试读取一个 nil 映射时,总是返回映射值类型的零值。但是,如果尝试写入一个 nil 映射变量,则会引发程序 panic。这是因为内存中未分配任何存储空间,底层指针为 nil。可以使用 := 声明来创建一个映射变量,并为其分配一个映射(map)空间:
totalWins := map[string]int{} // 声明并初始化一个映射变量
在此示例中,正在使用一个空映射字面量(Literal),这与 nil 映射不同。空映射字面量的长度为 0,但可以安全地对其进行读写。相反,对 nil 映射进行写操作,则会导致 panic。非空映射字面量的示例如下所示:
teams := map[string][]string{
"Orcas": []string{"Fred", "Ralph", "Bijou"},
"Lions": []string{"Sarah", "Peter", "Billie"},
"Kittens": []string{"Waldo", "Raul", "Ze"},
}
在 Go 语言中,map 字面量的结构遵循以下规则:每个键(key)后跟冒号(:),然后是值(value)。map 内的所有键值对均需用逗号分隔,即使最后一个键值对后也需保留逗号。在上述示例中,map 的值为一个字符串切片。映射中值的类型可以是任何类型,而键的类型有一些限制,我们稍后再讨论。如果提前知道要在 map 中放入多少个键值对,但不知道具体的值,可以使用 make 创建一个具有默认大小的 map:
ages := make(map[int][]string, 10) // 用 make 创建一个具有默认大小的映射
make(map[K]V, n) 中的 n 只是一个容量提示,告诉运行时“预计大约会放 n 个元素”,用于减少 map 扩容和重新分配的次数;它不会创建 n 个元素,也不会限制 map 最多只能有 n 个元素。
映射(map)在许多方面,与切片(slice)很类似:
- 映射(map)会随着键值对的添加而自动增长。
- 将映射(map)传递给
len函数,就能知道map中键值对的数量。 - 映射(map)的零值是
nil。 - 映射(map)是不可比较的,可以检查映射(map)是否等于
nil,但不能用==或!=来检查两个映射(map)是否有相同的键值对。
映射(map)的键可以是任何可比较的类型,也就是说,不能使用切片、map 或函数作为映射的键。
键类型的限制:键的类型必须满足可比较性(comparable),具体限制包括:(1)键的类型不能是切片(slice)、map、函数类型;(2)若为结构体类型,其所有字段必须为可比较类型。
什么是哈希映射
在计算机科学中,映射(Map)是一种将一个值关联(或映射)到另一个值的数据结构。映射可以通过多种方式实现,每种方式都有自己的优缺点。Go 语言内置的映射实现是哈希映射或哈希表(Hash Table)。若对哈希表概念不熟悉,推荐阅读 Aditya Bhargava 所著的《算法图解》(Grokking Algorithms,Manning 出版社)第 5 章,其中详细解释了哈希表的工作原理及其核心优势。
Go 语言将哈希表作为运行时(Runtime)的核心组件,这一设计极具价值,因为自行实现高性能且无缺陷的哈希表非常困难。若想深入了解 Go 的哈希表实现细节,可观看 Keith Randall 在 GopherCon 2016 上的演讲《深入解析 Map 实现》(Inside the Map Implementation)。
Go 的独特之处在于:开发者不能(也不被允许)为键(Key)类型自定义哈希算法或相等性判断逻辑。相反,Go 运行时已为所有可用作键的类型(如
int、string、结构体等)预编译了优化的哈希算法和相等性判断代码。这一设计既保障了类型安全,又确保了哈希表操作的高效率。
3.4.1 映射的读写
示例 3.10:声明与读写映射(map)
给map赋值和取值:
totalWins["Orcas"] = 1 // 赋值,用 = 不能用 :=
value := totalWins["Orcas"] // 取值
读取不存在的key会怎样?
Go不会报错,而是返回该类型的零值。比如 int 的零值是 0。
totalWins := make(map[string]int)
fmt.Println(totalWins["Orcas"]) // 输出 0,因为 "Orcas" 还没被赋值
这个特性让 ++ 可以直接用,不用先判断key是否存在:
totalWins["Orcas"]++ // 即使之前没有 "Orcas" 这个key,也能正常执行
// 相当于:totalWins["Orcas"] = totalWins["Orcas"] + 1
// = 0 + 1 = 1
如果没有"零值"这个机制,你就得先写:
if _, exists := totalWins["Orcas"]; !exists {
totalWins["Orcas"] = 0
}
totalWins["Orcas"]++
而Go的零值机制让这一切自动完成,直接一行 totalWins["Orcas"]++ 就够了。
3.4.2 逗号 ok 惯用法
如前所述,当查询映射(map)中不存在的键时,Go 会返回“映射值”类型的零值(Zero Value)。然而,有时仍需明确判断键是否真实存在于映射中。为此,Go 提供了“逗号 ok 模式”(comma ok idiom),用于区分“键存在但其关联值为零值”与“键根本不存在于映射中”这两种场景:
m := map[string]int{
"hello": 5,
"world": 0,
}
v, ok := m["hello"]
fmt.Println(v, ok) // 5 true
v, ok = m["world"]
fmt.Println(v, ok) // 0 true
v, ok = m["goodbye"]
fmt.Println(v, ok) // 0 false
在 Go 语言中,使用逗号 ok 惯用法(comma ok idiom)时,map 的读取操作会将结果赋值给两个变量(而非一个变量)。第一个变量获取与键(key)关联的值,第二个返回值是 bool 类型(通常命名为 ok)。若 ok 为 true,表示该键存在于 map 中。若 ok 为 false,表示该键不存在于 map 中。在此示例中,代码输出结果为 5 true、0 true 和 0 false。
3.4.3 删除映射中的项
用内置的 delete 函数,可以删除映射(map)中的项(即键值对):
m := map[string]int{ // 创建一个映射(map)并初始化其内容
"hello": 5,
"world": 10,
}
delete(m, "hello") // 删除映射中的 "hello" 项
delete 函数接收一个 map 和一个键作为参数,并删除该键对应的项(即键值对)。若键不存在于 map 中或 map 为 nil,则删除操作无任何效果。函数 delete 不返回任何值。
3.4.4 清空映射
在“3.2.5 清空切片<37页>”一节中提到的 clear 函数同样适用于 map。清空后的 map,长度会被置为 0;而清空后的切片,元素被重置为零值,但长度和容量保持不变(这与 map 行为不同)。
m := map[string]int{
"hello": 5,
"world": 10,
}
fmt.Println(m, len(m))
clear(m) // 清空映射
fmt.Println(m, len(m)) // 清空后的映射,其长度变为 0
运行上述代码,将得到如下输出:
map[hello:5 world:10] 2
map[] 0
3.4.5 映射的比较
Go 1.21 在标准库中新增了一个 maps 包,该包提供了用于操作映射(map)的辅助函数。有关 maps 包的更多内容,详见“8.12 将泛型引入到标准库<178页>”。该包中的函数 maps.Equal 和 maps.EqualFunc 可用于比较两个映射(map)是否相等。它们的逻辑与 slices.Equal 和 slices.EqualFunc 函数类似:
m := map[string]int{
"hello": 5,
"world": 10,
}
n := map[string]int{
"world": 10,
"hello": 5,
}
fmt.Println(maps.Equal(m, n)) // 比较映射 m 与 n 是否相等,结果为 true
3.4.6 用映射模拟集合(set)类型
许多编程语言在其标准库中提供了集合(Set)这一数据类型。集合可确保其中的每个值至多出现一次(唯一性),但不保证元素的存储顺序(无序性)。无论集合中有多少元素,判断某元素是否存在的时间复杂度均为 O(1),而检查切片中的元素是否存在的时间复杂度为 O(n)(需遍历所有元素)。随着切片元素数量增加,检查耗时会线性增长。
Go 语言未内置集合(set)类型,但可以通过映射(map)模拟集合的部分特性。实现方式为:将映射(map)的键(key)作为要存入集合的元素类型,而映射的值(value)设置为 bool 类型(如示例 3.11 所示)。可以在 The Go Playground 或“三 复合类型”资源库的 sample_code/map_set 目录中运行示例 3.11 中的代码。
示例 3.11:将映射作为集合(set)使用
intSet := map[int]bool{} // 声明一个映射,键为 int 类型,值为 bool 类型
vals := []int{5, 10, 2, 5, 8, 7, 3, 9, 1, 2, 10} // 声明一个 int 切片
for _, v := range vals { // 遍历切片中的元素,并将每个元素添加到映射中
intSet[v] = true
}
fmt.Println(len(vals), len(intSet))
fmt.Println(intSet[5])
fmt.Println(intSet[500])
if intSet[100] {
fmt.Println("100 is in the set")
}
示例 3.11 演示了实现一个 int 集合的过程:首先,声明一个映射(map)intSet,其键为 int 类型,值为 bool 类型。然后,创建一个 int 切片,用 for range 遍历切片中的值,将其作为键存入映射中,并将关联的布尔值设为 true。
示例 3.11 中向 intSet 中写入了 11 个值,但 intSet 的实际长度为 8,这是因为映射(map)的键具有唯一性,重复的键会被自动合并。如果在 intSet 中查找 5,它将返回 true,表示 5 已存在于集合中。如果在 intSet 中查找不存在的值(如 500),它将返回 false。这是因为映射中不存在的键时,映射会返回其值类型的零值(此处为零值 false)。
若需实现集合的并集(Union)、交集(Intersection)、差集(Subtraction)等高级操作,可以选择以下两种方案:
- 自行实现:基于 Go 的
map编写自定义集合逻辑。 - 使用第三方库:社区提供了成熟的集合库(如
go-set、golang-set),可直接调用其 API。(有关如何集成这些库,详见“十 模块、包与导入<197页>”。)
部分开发者倾向于在使用映射(map)模拟集合时,将值类型设为
struct{}(即结构体,下一节将详细讨论)。其优点是空结构体(struct)不占用任何内存空间,而布尔类型需要占用 1 字节的内存;而缺点是使用struct{}会使代码可读性降低,如必须用“逗号 ok 惯用法”判断元素的存在性。intSet := map[int]struct{}{} vals := []int{5, 10, 2, 5, 8, 7, 3, 9, 1, 2, 10} for _, v := range vals { intSet[v] = struct{}{} } if _, ok := intSet[5]; ok { fmt.Println("5 is in the set") }常规场景,优先使用
map[T]bool,因其代码更简洁直观,且小规模数据内存差异可忽略。而当集合中需要包含“海量数据”(如存千万级键)时,则应考虑使用map[T]struct{}以减少内存压力。
评论