Go 复合数据类型 原生map类型的实现机制是怎样的?_初始化

这一节课,我们会继续前面的脉络,学习另外一种日常 Go 编码中比较常用的复合类型,这种类型可以让你将一个值(Value)唯一关联到一个特定的键(Key)上,可以用于实现特定键值的快速查找与更新,这个复合数据类型就是 map。很多中文 Go 编程语言类技术书籍都会将它翻译为映射、哈希表或字典,但在我的课程中,为了保持原汁原味,我就直接使用它的英文名,map。

map 是我们既切片之后,学到的第二个由 Go 编译器与运行时联合实现的复合数据类型,它有着复杂的内部实现,但却提供了十分简单友好的开发者使用接口。这一节课,我将从 map 类型的定义,到它的使用,再到 map 内部实现机制,由浅到深地让你吃透 map 类型。

什么是 map 类型?


map 是 Go 语言提供的一种抽象数据类型,它表示一组无序的键值对。在后面的讲解中,我们会直接使用 key 和 value 分别代表 map 的键和值。而且,map 集合中每个 key 都是唯一的: 

Go 复合数据类型 原生map类型的实现机制是怎样的?_golang_02

和切片类似,作为复合类型的 map,它在 Go 中的类型表示也是由 key 类型与 value 类型组成的,就像下面代码:

map[key_type]value_type

key 与 value 的类型可以相同,也可以不同:

map[string]string // key与value元素的类型相同
map[int]string // key与value元素的类型不同

如果两个 map 类型的 key 元素类型相同,value 元素类型也相同,那么我们可以说它们是同一个 map 类型,否则就是不同的 map 类型。

这里,我们要注意,map 类型对 value 的类型没有限制,但是对 key 的类型却有严格要求,因为 map 类型要保证 key 的唯一性。Go 语言中要求,key 的类型必须支持“==”和“!=”两种比较操作符。

但是,在 Go 语言中,函数类型、map 类型自身,以及切片只支持与 nil 的比较,而不支持同类型两个变量的比较。如果像下面代码这样,进行这些类型的比较,Go 编译器将会报错:

  array1 := [5]int{}
array2 := [5]int{}

if array1 == array2{
fmt.Println("array1 == array2")
}
s1 := make([]int, 1)
s2 := make([]int, 2)

f1 := func() {}
f2 := func() {}

m1 := make(map[int]string)
m2 := make(map[int]string)
println(s1 == s2) // 错误:invalid operation: s1 == s2 (slice can only be compared to nil)
println(f1 == f2) // 错误:invalid operation: f1 == f2 (func can only be compared to nil)
println(m1 == m2) // 错误:invalid operation: m1 == m2 (map can only be compared to nil)

Map 声明和初始化


用的最多的是第一个和第二个,切片有make创建有两个参数一个是长度,还有一个是容量。

map初始化的时候只有容量,len的单元格初始化的时候都会赋值为0值,map是没有办法做所谓的0值的,什么时候使用make初始化呢?

map和切片一样都是自增长度的一种存储,所以在自增长的时候都会分配,每一次在自增长的时候都会分配新的内存空间,然后将数据进行拷贝,这样的话就会有相当的消耗,如果在初始化的时候可以将容量初始化到需要的大小,那么这样就可以避免这些提高性能。

(1)m := map[string]int{"one": 1, "two": 2, "three": 3}

(2)m1 := map[string]int{}

         m1["one"] = 1

(3)m2 := make(map[string]int, 10 /*Initial Capacity*/)

         //为什么不初始化len?

map 变量的声明和初始化


我们可以这样声明一个 map 变量: 

var m map[string]int // 一个map[string]int类型的变量

不过切片变量和 map 变量在这里也有些不同。初值为零值 nil 的切片类型变量,可以借助内置的 append 的函数进行操作,这种在 Go 语言中被称为“零值可用”。定义“零值可用”的类型,可以提升我们开发者的使用体验,我们不用再担心变量的初始状态是否有效。

  var a []int
a = append(a,1)
fmt.Println(a)

但 map 类型,因为它内部实现的复杂性,无法“零值可用”。所以,如果我们对处于零值状态的 map 变量直接进行操作,就会导致运行时异常(panic),从而导致程序进程异常退出:

var m map[string]int // m = nil
m["key"] = 1 // 发生运行时异常:panic: assignment to entry in nil map

所以,我们必须对 map 类型变量进行显式初始化后才能使用。那我们怎样对 map 类型变量进行初始化呢?

和切片一样,为 map 类型变量显式赋值有两种方式:一种是使用复合字面值;另外一种是使用 make 这个预声明的内置函数。

方法一:使用复合字面值初始化 map 类型变量。

我们先来看这句代码:

m := map[int]string{}

这里,我们显式初始化了 map 类型变量 m。不过,你要注意,虽然此时 map 类型变量 m 中没有任何键值对,但变量 m 也不等同于初值为 nil 的 map 变量。这个时候,我们对 m 进行键值对的插入操作,不会引发运行时异常。

这里我们再看看怎么通过稍微复杂一些的复合字面值,对 map 类型变量进行初始化:

m1 := map[int][]string{
1: []string{"val1_1", "val1_2"},
3: []string{"val3_1", "val3_2", "val3_3"},
7: []string{"val7_1"},
}

type Position struct {
x float64
y float64
}

m2 := map[Position]string{
Position{29.935523, 52.568915}: "school",
Position{25.352594, 113.304361}: "shopping-mall",
Position{73.224455, 111.804306}: "hospital",
}

我们看到,上面代码虽然完成了对两个 map 类型变量 m1 和 m2 的显式初始化,但不知道你有没有发现一个问题,作为初值的字面值似乎有些“臃肿”。你看,作为初值的字面值采用了复合类型的元素类型,而且在编写字面值时还带上了各自的元素类型,比如作为 map[int] []string 值类型的[]string,以及作为 map[Position]string 的 key 类型的 Position。

别急!针对这种情况,Go 提供了“语法糖”。这种情况下,Go 允许省略字面值中的元素类型。因为 map 类型表示中包含了 key 和 value 的元素类型,Go 编译器已经有足够的信息,来推导出字面值中各个值的类型了。我们以 m2 为例,这里的显式初始化代码和上面变量 m2 的初始化代码是等价的:

m2 := map[Position]string{
{29.935523, 52.568915}: "school",
{25.352594, 113.304361}: "shopping-mall",
{73.224455, 111.804306}: "hospital",
}

 以后在无特殊说明的情况下,我们都将使用这种简化后的字面值初始化方式。

方法二:使用 make 为 map 类型变量进行显式初始化。

和切片通过 make 进行初始化一样,通过 make 的初始化方式,我们可以为 map 类型变量指定键值对的初始容量,但无法进行具体的键值对赋值,就像下面代码这样:

m1 := make(map[int]string) // 未指定初始容量
m2 := make(map[int]string, 8) // 指定初始容量为8

不过,map 类型的容量不会受限于它的初始容量值,当其中的键值对数量超过初始容量后,Go 运行时会自动增加 map 类型的容量,保证后续键值对的正常插入。

 

map 的基本操作


针对一个 map 类型变量,我们可以进行诸如插入新键值对、获取当前键值对数量、查找特定键和读取对应值、删除键值对,以及遍历键值等操作。我们一个个来学习。 

操作一:插入新键值对

面对一个非 nil 的 map 类型变量,我们可以在其中插入符合 map 类型定义的任意新键值对。插入新键值对的方式很简单,我们只需要把 value 赋值给 map 中对应的 key 就可以了:

m := make(map[int]string)
m[1] = "value1"
m[2] = "value2"
m[3] = "value3"

而且,我们不需要自己判断数据有没有插入成功,因为 Go 会保证插入总是成功的。这里,Go 运行时会负责 map 变量内部的内存管理,因此除非是系统内存耗尽,我们可以不用担心向 map 中插入新数据的数量和执行结果。

不过,如果我们插入新键值对的时候,某个 key 已经存在于 map 中了,那我们的插入操作就会用新值覆盖旧值:

m := map[string]int {
"key1" : 1,
"key2" : 2,
}

m["key1"] = 11 // 11会覆盖掉"key1"对应的旧值1
m["key3"] = 3 // 此时m为map[key1:11 key2:2 key3:3]

从这段代码中你可以看到,map 类型变量 m 在声明的同时就做了初始化,它的内部建立了两个键值对,其中就包含键 key1。所以后面我们再给键 key1 进行赋值时,Go 不会重新创建 key1 键,而是会用新值 (11) 把 key1 键对应的旧值 (1) 替换掉。

操作二:获取键值对数量

如果我们在编码中,想知道当前 map 类型变量中已经建立了多少个键值对,那我们可以怎么做呢?和切片一样,map 类型也可以通过内置函数 len,获取当前变量已经存储的键值对数量: 

m := map[string]int {
"key1" : 1,
"key2" : 2,
}

fmt.Println(len(m)) // 2
m["key3"] = 3
fmt.Println(len(m)) // 3

不过,这里要注意的是我们不能对 map 类型变量调用 cap,来获取当前容量,这是 map 类型与切片类型的一个不同点。

操作三:查找和数据读取

和写入相比,map 类型更多用在查找和数据读取场合。所谓查找,就是判断某个 key 是否存在于某个 map 中。有了前面向 map 插入键值对的基础,我们可能自然而然地想到,可以用下面代码去查找一个键并获得该键对应的值:

m := make(map[string]int)
v := m["key1"]

乍一看,第二行代码在语法上好像并没有什么不当之处,但其实通过这行语句,我们还是无法确定键 key1 是否真实存在于 map 中。这是因为,当我们尝试去获取一个键对应的值的时候,如果这个键在 map 中并不存在,我们也会得到一个值,这个值是 value 元素类型的零值。

我们以上面这个代码为例,如果键 key1 在 map 中并不存在,那么 v 的值就会被赋予 value 元素类型 int 的零值,也就是 0。所以我们无法通过 v 值判断出,究竟是因为 key1 不存在返回的零值,还是因为 key1 本身对应的 value 就是 0。 

那么在 map 中查找 key 的正确姿势是什么呢?Go 语言的 map 类型支持通过用一种名为“comma ok”的惯用法,进行对某个 key 的查询。接下来我们就用“comma ok”惯用法改造一下上面的代码: 

m := make(map[string]int)
v, ok := m["key1"]
if !ok {
// "key1"不在map中
}

// "key1"在map中,v将被赋予"key1"键对应的value

 我们看到,这里我们通过了一个布尔类型变量 ok,来判断键“key1”是否存在于 map 中。如果存在,变量 v 就会被正确地赋值为键“key1”对应的 value。

不过,如果我们并不关心某个键对应的 value,而只关心某个键是否在于 map 中,我们可以使用空标识符替代变量 v,忽略可能返回的 value:

m := make(map[string]int)
_, ok := m["key1"]
... ...

因此,你一定要记住:在 Go 语言中,请使用“comma ok”惯用法对 map 进行键查找和键值读取操作。

操作四:删除数据

接下来,我们再看看看如何从 map 中删除某个键值对。在 Go 中,我们需要借助内置函数 delete 来从 map 中删除数据。使用 delete 函数的情况下,传入的第一个参数是我们的 map 类型变量,第二个参数就是我们想要删除的键。我们可以看看这个代码示例:

m := map[string]int {
"key1" : 1,
"key2" : 2,
}

fmt.Println(m) // map[key1:1 key2:2]
delete(m, "key2") // 删除"key2"
fmt.Println(m) // map[key1:1]

这里要注意的是,delete 函数是从 map 中删除键的唯一方法。即便传给 delete 的键在 map 中并不存在,delete 函数的执行也不会失败,更不会抛出运行时的异常。

操作五:遍历 map 中的键值数据

最后,我们来说一下如何遍历 map 中的键值数据。这一点虽然不像查询和读取操作那么常见,但日常开发中我们还是有这个需求的。在 Go 中,遍历 map 的键值对只有一种方法,那就是像对待切片那样通过 for range 语句对 map 数据进行遍历。我们看一个例子:

package main

import "fmt"

func main() {
m := map[int]int{
1: 11,
2: 12,
3: 13,
}

fmt.Printf("{ ")
for k, v := range m {
fmt.Printf("[%d, %d] ", k, v)
}
fmt.Printf("}\n")
}

你看,通过 for range 遍历 map 变量 m,每次迭代都会返回一个键值对,其中键存在于变量 k 中,它对应的值存储在变量 v 中。我们可以运行一下这段代码,可以得到符合我们预期的结果:

{ [1, 11] [2, 12] [3, 13] }

 如果我们只关心每次迭代的键,我们可以使用下面的方式对 map 进行遍历:

for k, _ := range m { 
// 使用k
}

当然更地道的方式是这样的:

for k := range m {
// 使用k
}

如果我们只关心每次迭代返回的键所对应的 value,我们同样可以通过空标识符替代变量 k,就像下面这样:

for _, v := range m {
// 使用v
}

不过,前面 map 遍历的输出结果都非常理想,给我们的表象好像是迭代器按照 map 中元素的插入次序逐一遍历。那事实是不是这样呢?我们再来试试,多遍历几次这个 map 看看。

我们先来改造一下代码:

package main

import "fmt"

func doIteration(m map[int]int) {
fmt.Printf("{ ")
for k, v := range m {
fmt.Printf("[%d, %d] ", k, v)
}
fmt.Printf("}\n")
}

func main() {
m := map[int]int{
1: 11,
2: 12,
3: 13,
}

for i := 0; i < 3; i++ {
doIteration(m)
}
}

运行一下上述代码,我们可以得到这样结果:

{ [3, 13] [1, 11] [2, 12] }
{ [1, 11] [2, 12] [3, 13] }
{ [3, 13] [1, 11] [2, 12] }

我们可以看到,对同一 map 做多次遍历的时候,每次遍历元素的次序都不相同。这是 Go 语言 map 类型的一个重要特点,也是很容易让 Go 初学者掉入坑中的一个地方。所以这里你一定要记住:程序逻辑千万不要依赖遍历 map 所得到的的元素次序。

从我们前面的讲解,你应该也感受到了,map 类型非常好用,那么,我们在各个函数方法间传递 map 变量会不会有很大开销呢?

 


Map 与⼯⼚模式


  • Map 的 value 可以是⼀个⽅法
  • 与 Go 的 Dock type 接⼝⽅式⼀起,可以⽅便的实现单⼀⽅法对象的⼯⼚模式


Map的值除了可以是数据类型之外呢,Map的值也可以是一个方法。

  m1 := map[int]func(int) int{}
m1[1] = func(a int) int {
return a
}
m1[2] = func(a int) int {
return a * a
}
if v,ok := m1[1];ok{
fmt.Println(v(1))
}
if v,ok := m1[2];ok{
fmt.Println(v(2))
}

 

 


实现 Set


Go 的内置集合中没有 Set 实现, 可以 map[type]bool


1. 元素的唯⼀性


2. 基本操作


1) 添加元素


2) 判断元素是否存在


3) 删除元素


4) 元素个数


其实go语言没有提供内置的set功能,set可以保证我们添加元素的唯一性。

MySet :=map[int]bool{}
MySet[1] = true
n := 3

if MySet[n]{
fmt.Println(n,"is exits")
}else {
fmt.Println(n,"is not exits")
}

MySet[3] = true
delete(MySet,3)

 

 

 

map 变量的传递开销


其实你不用担心开销的问题。

和切片类型一样,map 也是引用类型。这就意味着 map 类型变量作为参数被传递给函数或方法的时候,实质上传递的只是一个“描述符”(后面我们再讲这个描述符究竟是什么),而不是整个 map 的数据拷贝,所以这个传递的开销是固定的,而且也很小。

并且,当 map 变量被传递到函数或方法内部后,我们在函数内部对 map 类型参数的修改在函数外部也是可见的。比如你从这个示例中就可以看到,函数 foo 中对 map 类型变量 m 进行了修改,而这些修改在 foo 函数外也可见。 

func foo(m map[string]int) {
m["key1"] = 11
m["key2"] = 12
}

func main() {
m := map[string]int{
"key1": 1,
"key2": 2,
}

fmt.Println(m) // map[key1:1 key2:2]
foo(m)
fmt.Println(m) // map[key1:11 key2:12]
}

引用类型


下面要介绍的是引用类型 map。

map

下面我来试验一下,如下所示:

func main() {

m:=make(map[string]int)

m["飞雪无情"] = 18

fmt.Println("飞雪无情的年龄为",m["飞雪无情"])

modifyMap(m)

fmt.Println("飞雪无情的年龄为",m["飞雪无情"])

}

func modifyMap(p map[string]int) {

p["飞雪无情"] =20

}

我定义了一个 map[string]int 类型的变量 m,存储一个 Key 为飞雪无情、Value 为 18 的键值对,然后把这个变量 m 传递给函数 modifyMap。modifyMap 函数所做的事情就是把对应的值修改为 20。现在运行这段代码,通过打印输出来看是否修改成功,结果如下所示: 

飞雪无情的年龄为 18

飞雪无情的年龄为 20

确实修改成功了。你是不是有不少疑惑?没有使用指针,只是用了 map 类型的参数,按照 Go 语言值传递的原则,modifyMap 函数中的 map 是一个副本,怎么会修改成功呢?

要想解答这个问题,就要从 make 这个 Go 语言内建的函数说起。在 Go 语言中,任何创建 map 的代码(不管是字面量还是 make 函数)最终调用的都是 runtime.makemap 函数。

小提示:用字面量或者 make 函数的方式创建 map,并转换成 makemap 函数的调用,这个转换是 Go 语言编译器自动帮我们做的。

从下面的代码可以看到,makemap 函数返回的是一个 *hmap 类型,也就是说返回的是一个指针,所以我们创建的 map 其实就是一个 *hmap。 

// makemap implements Go map creation for make(map[k]v, hint).

func makemap(t *maptype, hint int, h *hmap) *hmap{

//省略无关代码

}

因为 Go 语言的 map 类型本质上就是 *hmap,所以根据替换的原则,我刚刚定义的 modifyMap(p map) 函数其实就是 modifyMap(p *hmap)。这是不是和上一小节讲的指针类型的参数调用一样了?这也是通过 map 类型的参数可以修改原始数据的原因,因为它本质上就是个指针。

为了进一步验证创建的 map 就是一个指针,我修改上述示例,打印 map 类型的变量和参数对应的内存地址,如下面的代码所示:

func main(){

//省略其他没有修改的代码

fmt.Printf("main函数:m的内存地址为%p\n",m)

}

func modifyMap(p map[string]int) {

fmt.Printf("modifyMap函数:p的内存地址为%p\n",p)

//省略其他没有修改的代码

}

map 的内部实现 


和切片相比,map 类型的内部实现要更加复杂。Go 运行时使用一张哈希表来实现抽象的 map 类型。运行时实现了 map 类型操作的所有功能,包括查找、插入、删除等。在编译阶段,Go 编译器会将 Go 语法层面的 map 操作,重写成运行时对应的函数调用。大致的对应关系是这样的: 

// 创建map类型变量实例
m := make(map[keyType]valType, capacityhint) → m := runtime.makemap(maptype, capacityhint, m)

// 插入新键值对或给键重新赋值
m["key"] = "value" → v := runtime.mapassign(maptype, m, "key") v是用于后续存储value的空间的地址

// 获取某键的值
v := m["key"] → v := runtime.mapaccess1(maptype, m, "key")
v, ok := m["key"] → v, ok := runtime.mapaccess2(maptype, m, "key")

// 删除某键
delete(m, "key") → runtime.mapdelete(maptype, m, “key”)

这是 map 类型在 Go 运行时层实现的示意图:

Go 复合数据类型 原生map类型的实现机制是怎样的?_golang_03

我们可以看到,和切片的运行时表示图相比,map 的实现示意图显然要复杂得多。接下来,我们结合这张图来简要描述一下 map 在运行时层的实现原理。我们重点讲解一下一个 map 变量在初始状态、进行键值对操作后,以及在并发场景下的 Go 运行时层的实现原理。

初始状态

从图中我们可以看到,与语法层面 map 类型变量(m)一一对应的是 runtime.hmap 的实例。hmap 类型是 map 类型的头部结构(header),也就是我们前面在讲解 map 类型变量传递开销时提到的 map 类型的描述符它存储了后续 map 类型操作所需的所有信息,包括: 

Go 复合数据类型 原生map类型的实现机制是怎样的?_键值对_04

真正用来存储键值对数据的是桶,也就是 bucket,每个 bucket 中存储的是 Hash 值低 bit 位数值相同的元素,默认的元素个数为 BUCKETSIZE(值为 8,Go 1.17 版本中在 $GOROOT/src/cmd/compile/internal/reflectdata/reflect.go 中定义,与 runtime/map.go 中常量 bucketCnt 保持一致)。

当某个 bucket(比如 buckets[0]) 的 8 个空槽 slot)都填满了,且 map 尚未达到扩容的条件的情况下,运行时会建立 overflow bucket,并将这个 overflow bucket 挂在上面 bucket(如 buckets[0])末尾的 overflow 指针上,这样两个 buckets 形成了一个链表结构,直到下一次 map 扩容之前,这个结构都会一直存在。

从图中我们可以看到,每个 bucket 由三部分组成,从上到下分别是 tophash 区域、key 存储区域和 value 存储区域。