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 运行时已为所有可用作键的类型(如 intstring、结构体等)预编译了优化的哈希算法和相等性判断代码。这一设计既保障了类型安全,又确保了哈希表操作的高效率。

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)。若 oktrue,表示该键存在于 map 中。若 okfalse,表示该键不存在于 map 中。在此示例中,代码输出结果为 5 true0 true0 false

3.4.3 删除映射中的项

用内置的 delete 函数,可以删除映射(map)中的项(即键值对):

m := map[string]int{ // 创建一个映射(map)并初始化其内容
    "hello": 5,
    "world": 10,
}

delete(m, "hello") // 删除映射中的 "hello" 项

delete 函数接收一个 map一个键作为参数,并删除该键对应的项(即键值对)。若键不存在于 map 中或 mapnil,则删除操作无任何效果。函数 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.Equalmaps.EqualFunc 可用于比较两个映射(map)是否相等。它们的逻辑与 slices.Equalslices.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-setgolang-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{} 以减少内存压力。

Reference

chenyz1984/LearningGoV2CN: 《Learning Go, 2nd Ed》

评论