我们学习完了数组和切片之后,会发现,这两个数据类型都属于单一元素的容器,但是今天要学习的字典,就更加复杂一些,它存储的不是单一值的集合,而是“键值对”的集合。
一、键值对 和 哈希表
再学习字典之前,咱们先了解下“键值对” 和 “哈希表”这两个概念。
键值对是从英文 key-value pair 直译过来的一个词。顾名思义,一个键值对就代表了一对键和值。一个“键”和一个“值”分别代表了一个从属于某一类型的独立值,把它们两个捆绑在一起就是一个键值对了。
而哈希表,也叫散列表,英文名 Hash table,是根据关键码值(Key value)而直接进行访问的数据结构。它通过把关键码值映射到表中一个位置来访问记录,以加快查找的速度(常数时间复杂度)。这个映射函数叫做哈希函数,存放记录的数组叫做哈希表。
//根据哈希函数H和查找关键字key确定value所在位置index(映射)
index=H(key)
一个典型的哈希查找过程是这样的:
先把键值key作为参数传给这个哈希表,哈希表会先用哈希函数(hash function)把键值转换为哈希值。哈希值通常是一个无符号的整数。一个哈希表会持有一定数量的桶(bucket),这些哈希桶会均匀地储存其所属哈希表收纳的键 - 元素对。所以先用得到的哈希值的低几位定位一个哈希桶,然后再去哈希桶中查找这个key。因为key-value总是“捆绑”在一起的,所以一旦找到了key,也就意味着找到了value。
之所以确定了哈希值之后,还要再比较键值key,是因为存在“哈希碰撞”的缘故,即两个不同的键值也有可能有相同的哈希值。所以,只有键的哈希值和键值都相等,才能说明查找到了匹配的键 - 元素对。
二、字典Map
在Go语言中,一个map就是一个哈希表的引用,map类型可以写为map[K]V,其中K和V分别对应key和value。map中所有的key都有相同的类型,所有的value也有着相同的类型,但是key和value之间可以是不同的数据类型。字典是无序的。字典的零值是nil,也就是没有引用任何哈希表。
字典的声明、创建和使用举例如下:
//声明一个字典
var dict map[int]int
//用make创建字典类型
ages := make(map[string]int)
//指定长度,可以避免字典在长大的过程中要经历的多次扩容操作
dict := make(map[int]int, 3)
//map字面值(语法糖)的语法创建map
ages := map[string]int{
"alice": 31,
"charlie": 34,
}
//创建空的map
dict := map[string]int{}
//通过下标key访问value
//当不存在该key时会返回零值
ages["alice"] = 32
fmt.Println(ages["alice"])
//内置的delete函数删除元素
delete(ages, "alice")
//运算
ages["bob"] = ages["bob"] + 1
ages["bob"] += 1
ages["bob"]++
//字典取址操作是违法的
// compile error: cannot take address of map element
_ = &ages["bob"]
//遍历(迭代顺序不确定,是随机的)
for name, age := range ages {
fmt.Printf("%s\t%d\n", name, age)
}
//如果要进行有序输出,需要先对key进行排序
import "sort"
names := make([]string, 0, len(ages))
for name := range ages {
names = append(names, name)
}
sort.Strings(names)
for _, name := range names {
fmt.Printf("%s\t%d\n", name, ages[name])
}
//map零值是nil
var ages map[string]int
fmt.Println(ages == nil) // "true"
fmt.Println(len(ages) == 0) // "true"
//向nil的map中存入元素会引发panic
// panic: assignment to entry in nil map
ages["carol"] = 21
//判断key是否存在的写法
age, ok := ages["bob"]
if !ok {
/* "bob" is not a key in this map; age == 0. */
}
//更简短的写法
if age, ok := ages["bob"]; !ok { /* ... */ }
在上面的哈希查找过程中,我们可以看到,用到了key的判等操作,即在键类型的值之间必须可以施加操作符==和!=。所以,在Go语言规范中,规定:
字典的键类型不可以是函数类型、字典类型和切片类型。
至于为什么不用多说,因为这些类型不支持判等操作。但是Value的类型没有任何限制,甚至可以是map或者slice这样的聚合类型。
那我们一般选择什么类型作为键值key呢?
求哈希和判等操作的速度越快,对应的类型就越适合作为键类型。
- 优先选用数值类型和指针类型,通常情况下类型的宽度越小越好
- 如果非要选择字符串类型的话,最好对键值的长度进行额外的约束
三、细说字典变量
我们知道,字典跟切片slice一样属于引用类型,所以字典变量里存的只是一个地址指针,这个指针指向字典的头部对象。所以字典变量占用的空间是一个字,也就是一个指针的大小,64 位机器是 8 字节,32 位机器是 4 字节。
func main() {
var m = map[string]int{
"apple": 2,
"pear": 3,
"banana": 5,
}
//输出8
fmt.Println(unsafe.Sizeof(m))
}
四、字典的并发安全问题
Go语言自带的字典类型map并不是并发安全的。也就是说,在同一时间段内,让不同 goroutine 中的代码,对同一个字典进行读写操作是不安全的。字典值本身可能会因这些操作而产生混乱,相关的程序也可能会因此发生不可预知的问题。
并发安全也叫线程安全(Go中指协程安全),在并发中出现了数据的丢失,称为并发不安全。今天的map和上一节的slice都是并发不安全的。
所以,尽量不要做map的并发,如果用并发要加锁,保证map的操作要么读,要么写。
var lock sync.Mutex
func main() {
m:=make(map[int]int)
go func() { //开一个协程写map
for i:=0;i<10000 ;i++ {
lock.Lock() //加锁
m[i]=i
lock.Unlock() //解锁
}
}()
go func() { //开一个协程读map
for i:=0;i<10000 ;i++ {
lock.Lock() //加锁
fmt.Println(m[i])
lock.Unlock() //解锁
}
}()
time.Sleep(time.Second*20)
}
当然,Go 语言官方后来在 2017 年发布的 Go 1.9 中,正式加入了并发安全的字典类型sync.Map。这个字典类型提供了一些常用的键值存取操作方法,并保证了这些操作的并发安全。这个并发安全的map比上面加锁的方式性能更高,sync.Map可以显著地减少锁的争用。
但是有一点要特别强调:sync.Map与原生map明显不同,它只是 Go 语言标准库中的一员,而不是语言层面的东西,Go 语言的编译器并不会对它的键和值,进行特殊的类型检查。
正因为是这个原因,我们应该在每次操作并发安全字典的时候,都去显式地检查键值的实际类型(可以使用类型断言表达式或者反射操作来保证它们的类型正确性)。无论是存、取还是删,都应该如此。否则一旦类型不正确(出现了函数类型、字典类型和切片类型),就会引发panic。
五、为啥字典Map是无序的?
如果你和我一样,也是一个PHPer转Gopher,那么一定会有一个困扰:Go语言里每次遍历Map输出元素的顺序并不一致,但是在PHP里却是稳定的。今天我们就来看看这个现象的原因。
导致其遍历结果无序,主要有以下两点原因:
- 遍历Map的索引的起点是随机的
- Go的Map本质上是“无序的”(无序写入和扩容策略)
遍历Map的索引的起点是随机的
For ... Range .. 遍历Map的索引的起点是随机的,没错,就是下面这段代码:
// versions/1.13.8/src/cmd/compile/internal/gc/range.go
func walkrange(n *Node) *Node {
// 略...
// 遍历Map时
case TMAP:
// 略...
// 调用mapiterinit mapiterinit函数见下方
fn := syslook("mapiterinit")
// 略...
fn = syslook("mapiternext")
// 略...
}
// versions/1.13.8/src/runtime/map.go
func mapiterinit(t *maptype, h *hmap, it *hiter) {
// 略...
// 对,就是这行代码
// 随机一个索引值,作为遍历开始的地方
// decide where to start
r := uintptr(fastrand())
if h.B > 31-bucketCntBits {
r += uintptr(fastrand()) << 31
}
// 略...
mapiternext(it)
}
Go的Map本质上是“无序的”
Map的底层数据结构是Hash表,当正常写入(非哈希冲突写入)时,是hash到某一个bucket上,而不是按buckets顺序写入;哈希冲突写入时,如果存在hash冲突,会写到同一个bucket上,更有可能写到溢出桶去。所以,写数据时,并没有单独维护键值对的顺序。而PHP(version 5)语言通过一个全局链表维护了Map里元素的顺序。
另外一个原因就是成倍扩容迫使元素顺序变化。等量扩容并没有改变元素顺序。详细原因可以查看下面引用的文章链接。
关于字典map的知识点就总结到这里,你Get了吗?