Go语言36讲笔记--09字典的操作和约束

208 阅读6分钟

知识前导:为什么字典的键类型会受到约束?

Go 语言的字典类型其实是一个哈希表(hash table)的特定实现,在这个实现中,键和元素的最大不同在于,键的类型是受限的,而元素却可以是任意类型的

关于映射

我们就先要了解哈希表中最重要的一个过程:映射。

what(映射)

你可以把键理解为元素的一个索引,我们可以在哈希表中通过键查找与它成对的那个元素。

键和元素的这种对应关系,在数学里就被称为“映射”,这也是“map”这个词的本意,哈希表的映射过程就存在于对键 - 元素对的增、删、改、查的操作之中。 how(映射)
映射过程的第一步就是:把键值转换为哈希值。
我们要在哈希表中查找与某个键值对应的那个元素值,那么我们需要先把键值作为参数传给这个哈希表。

哈希表会先用哈希函数(hash function)把键值转换为哈希值。哈希值通常是一个无符号的整数。
一个哈希表会持有一定数量的桶(bucket),我们也可以叫它哈希桶,这些哈希桶会均匀地储存其所属哈希表收纳的键 - 元素对。

因此,哈希表会先用这个键哈希值的低几位去定位到一个哈希桶,然后再去这个哈希桶中,查找这个键

由于键 - 元素对总是被捆绑在一起存储的,所以一旦找到了键,就一定能找到对应的元素值。随后,哈希表就会把相应的元素值作为结果返回。 问题:字典的键类型不能是哪些类型?

典型回答

Go 语言字典的键类型不可以是函数类型、字典类型和切片类型。

问题解析

Go 语言规范规定:键之间必须可以施加操作符==!=,即判等操作。So,由于函数类型、字典类型和切片类型的值并不支持判等操作,...。

即便键的类型是接口类型的,那么键值的实际类型也不能是上述三种类型,否则在程序运行过程中会引发 panic(即运行时恐慌)。
举个例子:

var badMap2 = map[interface{}]int{
	"1":   1,
	[]int{2}: 2, // 这里会引发 panic。
	3:    3,
}

badMap2的类型是interface{}--int的map类型。这种声明可以躲过了 Go 语言编译器的检查。
用字面量在声明该字典的同时对它进行了初始化,使它包含了三个键 - 元素对。

其中第二个键 - 元素对的键值是[]int{2},元素值是2
运行时,Go 语言的运行时(runtime)系统会发现问题,抛出一个 panic,出错问题行指向该行。

如果键的类型是数组类型,确保数组中元素类型非以上三种类型。

比如,由于类型[1][]string的元素类型是[]string,所以它就不能作为字典类型的键类型。 另外,如果键的类型是结构体类型,那么还要保证其中字段的类型的合法性。 无论不合法的类型被埋藏得有多深,比如map[[1][2][3][]string]int,Go 语言编译器都会把它揪出来。

why键必须支持判等操作?

我在前面说过,Go 语言一旦定位到了某一个哈希桶,那么就会试图在这个桶中查找键值。具体查找过程如下:

首先,每个哈希桶都会把自己包含的所有键的哈希值存起来。Go 语言会用被查找键的哈希值与这些哈希值逐个对比,看看是否有相等的。如果一个相等的都没有,那么就说明这个桶中没有要查找的键值,这时 Go 语言就会立刻返回结果了。

如果有相等的,那就再用键值本身去对比一次。为什么还要对比?原因是,“哈希碰撞”,即哈希值相同,实际键值本身不同。

所以,即使哈希值一样,键值也不一定一样。若键之间无法判断相等,那么此时这个映射的过程就没办法继续下去了。

最后,只有键的哈希值和键值都相等,才能说明查找到了匹配的键 - 元素对。

知识扩展

问题 1:应该优先考虑哪些类型作为字典的键类型?

在已知的支持判等的类型中,寻找更适合作为key类型的type。 这里先抛开我们使用字典时的上下文,只从性能的角度看。

映射过程中两个比较耗时的操作:

  1. “把键值转换为哈希值”
  2. “把要查找的键值与哈希桶中的键值做对比” So,求哈希和判等操作的速度越快,对应的类型就越适合作为键类型。
    对于所有基本类型,宽度(单个值所占字节数)越小求哈希越快;对于字符串类型,看值的具体长度,长度越短求哈希越快。 对于高级类型逐个讨论一下:
  3. 数组:依次求每个元素的哈希值并进行合并,速度取决于元素类型与长度;
  4. 结构体:依次求每个字段的哈希并合并
  5. 接口类型:由值的实际类型决定 结论:优先使用基本类型,尽量不考虑高级类型

问题 2:在值为nil的字典上执行读操作会成功吗,那写操作呢?

由于字典是引用类型,所以当我们仅声明而不初始化一个字典类型的变量的时候,它的值会是nil

除了添加键 - 元素对,我们在一个值为nil的字典上做任何操作都不会引起错误。当我们试图在一个值为nil的字典中添加键 - 元素对的时候,Go 语言的运行时系统就会立即抛出一个 panic。

那如何向一个nil的字典中添加键值对呢?

举个栗子

//map的声明(定义)
var m map[string]int

//map的初始化
m = make(map[string]int)

//map中添加数据
m["age"] = 100

//打印map数据
fmt.Printf("%v", m)

map声明map初始化非同一个概念。未初始化mapnilnilmap不允许往里面添加值,否则会出现(panic: assignment to entry in nil map)错误,但是可以取得到默认值

    //map的声明(定义),不初始化
	var m map[string]int
	fmt.Printf("%v\n", m["age"]) //能够打印0

	m["age"] = 100               //报错 panic: assignment to entry in nil map
	fmt.Printf("%v\n", m) //不执行

思考题

今天的思考题是关于并发安全性的。更具体地说,在同一时间段内但在不同的 goroutine(或者说 go 程)中对同一个值进行操作是否是安全的。这里的安全是指,该值不会因这些操作而产生混乱(panic),或其它不可预知的问题。

字典类型的值不是并发安全的,即使我们只是增减其中的键值对也是如此。其根本原因是,字典值内部有时候会根据需要进行存储方面的调整。

非原子操作需要加锁, map并发读写需要加锁,map操作不是并发安全的,判断一个操作是否是原子的可以使用 go run race 命令做数据的竞争检测