字典Map

复习日志(1)

* 本页面主要介绍Go语言引用数据类型字典Map的相关内容。

我们学习完了数组和切片之后,会发现,这两个数据类型都属于单一元素的容器,但是今天要学习的字典,就更加复杂一些,它存储的不是单一值的集合,而是“键值对”的集合

一、键值对 和 哈希表

再学习字典之前,咱们先了解下“键值对” 和 “哈希表”这两个概念。

键值对是从英文 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了吗?


* 本页内容更新时间线:

  • 2021年07月08日 16:05:13 : 增加为什么Map是无序的模块
  • 2020年12月21日 17:11:34 : 创建文档

* 本页内容参考以下数据源:

  • 《Go程序设计语言》
  • 《Go语言核心36讲》 09 字典的操作和约束
  • 《Go语言核心36讲》 34 并发安全字典sync.Map (上)
  • 《Go语言核心36讲》 35 并发安全字典sync.Map (下)
  • https://studygolang.com/articles/17421
  • https://www.cnblogs.com/mqhpy/p/13490169.html
  • https://blog.csdn.net/weixin_43844941/article/details/106570116
  • https://blog.csdn.net/weixin_43851310/article/details/87897247
  • https://blog.csdn.net/github_38773259/article/details/114362871

凯冰科技 · 代码改变世界,技术改变生活
Next Page→