【Go】实例分析GoLang built-in数据结构map的赋值引用行为

备注1:本文旨在介绍Go语言中map这个内置数据结构的引用行为,并用实例来说明如何避免这种引用行为带来的“副作用”。
备注2:文末列出的参考资料均来自GoLang.org官方文档,需翻墙访问。

1. map internals
map是go中内置的数据结构,关于其语法规则,可以查看language specification中这里的说明,或者查看Effective Go中关于Maps的说明,此处略过。
map的底层是用hashmap实现的(底层hashmap源码路径为src/pkg/runtime/hashmap.c),部分注释摘出如下:

 
  1. // This file contains the implementation of Go's map type.

  2. //

  3. // The map is just a hash table. The data is arranged

  4. // into an array of buckets. Each bucket contains up to

  5. // 8 key/value pairs. The low-order bits of the hash are

  6. // used to select a bucket. Each bucket contains a few

  7. // high-order bits of each hash to distinguish the entries

  8. // within a single bucket.

  9. //

  10. // If more than 8 keys hash to a bucket, we chain on

  11. // extra buckets.

  12. //

  13. // When the hashtable grows, we allocate a new array

  14. // of buckets twice as big. Buckets are incrementally

  15. // copied from the old bucket array to the new bucket array.

这段注释除表明map底层确实是hashmap实现的外,还解释了hashmap部分实现细节。此外,源码中还包含遍历map的处理细节以及一个map性能的小实验,可以查看源码文件了解。
目前已经清楚,map在内部维护了一个hashmap,那么语法层面的map数据结构是如何与底层的hashmap关联起来的呢?
在Effective Go关于Maps的说明文档中,有这样一句话:
Like slices, maps hold references to an underlying data structure. If you pass a map to a function that changes the contents of the map, the changes will be visible in the caller.
具体而言,map这个数据结构在内部维护了一个指针,该指针指向一个真正存放数据的hashmap。参考Go官网博客的文章Go Slices: usage and internals关于slice内部结构的说明,再结合map底层hashmap.c源码片段(注意下面摘出的Hmap结构体定义中的count和buckets字段,而oldbuckets只在map rehash时有用),可以看出map内部确实维护着map元素的count和指向hashmap的指针。

 
  1. struct Hmap

  2. {

  3. // Note: the format of the Hmap is encoded in ../../cmd/gc/reflect.c and

  4. // ../reflect/type.go. Don't change this structure without also changing that code!

  5. uintgo count; // # live cells == size of map. Must be first (used by len() builtin)

  6. uint32 flags;

  7. uint32 hash0; // hash seed

  8. uint8 B; // log_2 of # of buckets (can hold up to LOAD * 2^B items)

  9. uint8 keysize; // key size in bytes

  10. uint8 valuesize; // value size in bytes

  11. uint16 bucketsize; // bucket size in bytes

  12.  
  13. byte *buckets; // array of 2^B Buckets. may be nil if count==0.

  14. byte *oldbuckets; // previous bucket array of half the size, non-nil only when growing

  15. uintptr nevacuate; // progress counter for evacuation (buckets less than this have been evacuated)

  16. };

2. map type is reference type
先看下面一段简单代码:

 
  1. // demo.go

  2. package main

  3.  
  4. import "fmt"

  5.  
  6. func main() {

  7. foo := make(map[string]string)

  8. foo["foo"] = "foo_v"

  9. bar := foo

  10. bar["bar"] = "bar_v"

  11.  
  12. fmt.Printf("foo=%v, ptr_foo=%v\n", foo, &foo)

  13. fmt.Printf("bar=%v, ptr_bar=%v\n", bar, &bar)

  14. }

编译并执行:

 
  1. $ go build demo.go

  2. $ ./demo

输出结果如下:

 
  1. foo=map[foo:foo_v bar:bar_v], ptr_foo=0xc210000018

  2. bar=map[foo:foo_v bar:bar_v], ptr_bar=0xc210000020

看到了吧?foo和bar的地址不同,但它们的内容是相同的。当我们执行bar := foo时,bar被自动声明为map[string][string]类型并进行赋值,而这个赋值行为并没有为bar申请一个新的hashmap并把foo底层的hashmap内容copy过去,它只是把foo指向底层hashmap的指针copy给了bar,赋值后,它们指向同一个底层hashmap。这个行为类似于C++中的“浅拷贝”。
可见,正如Go maps in action一文中提到的,Go的map类型是引用类型(Map types are reference types)。关于Go语言的设计者们为何要把map设计成reference type,可以参考Go FAQ在这里的解释。
新手需要特别注意这种引用行为,下面开始用实例来说明。

3. handel "deep copy" manually if necessary
有时候,业务场景并不希望两个map变量指向同一个底层hashmap,但若Go新手恰好对map的引用行为理解不深的话,很有可能踩到坑,我们来段有Bug的代码感受下。

 
  1. // bug version: mapref.go

  2. package main

  3.  
  4. import (

  5. "fmt"

  6. )

  7.  
  8. func main() {

  9. foo := make(map[string]map[string]map[string]float32)

  10. foo_s12 := map[string]float32{"s2": 0.1}

  11. foo_s1 := map[string]map[string]float32{"s1": foo_s12}

  12. foo["x1"] = foo_s1

  13. foo_s22 := map[string]float32{"s2": 0.5}

  14. foo_s2 := map[string]map[string]float32{"s1": foo_s22}

  15. foo["x2"] = foo_s2

  16.  
  17. x3 := make(map[string]map[string]float32)

  18. for _, v := range foo {

  19. for sk, sv := range v {

  20. if _, ok := x3[sk]; ok {

  21. for tmpk, tmpv := range sv {

  22. if _, ok := x3[sk][tmpk]; ok {

  23. x3[sk][tmpk] += tmpv

  24. } else {

  25. x3[sk][tmpk] = tmpv

  26. }

  27. }

  28. } else {

  29. x3[sk] = sv ## 注意这里,map的赋值是个引用行为!

  30. }

  31. }

  32. }

  33. fmt.Printf("foo=%v\n", foo)

  34. fmt.Printf("x3=%v\n", x3)

  35. }

上述代码的目的是对一个3层map根据第2层key做merge(本例中是值累加),最终结果存入x3。比如,foo的一级key是"x1"和"x2",其对应的value都是个两级map结构,我们要对1级key的value这个两级map根据其key做merge,具体在上述代码中,一级key对应的value分别是map[s1:map[s2:0.1]]和map[s1:map[s2:0.5]],由于它们有公共的key "s1",所以需要merge s1的value,而由于s1 value也是个map(分别是map[s2:0.1]和map[s2:0.5])且它们仍有公共key "s2",所以需要对两个s2的value做累加。总之,我们预期的结果是x3 = map[s1:map[s2:0.6]],同时不改变原来的那个3层map的值。
下面是上述代码的执行结果:

 
  1. foo=map[x1:map[s1:map[s2:0.6]] x2:map[s1:map[s2:0.5]]]

  2. x3=map[s1:map[s2:0.6]]

可以看到,x3确实得到了预期的结果,但是,foo的值却被修改了(注意foo["x1"]["s1"]["s2"]的值由原来的0.1变成了0.6),如果应用程序后面要用到foo,那这个坑肯定是踩定了。
bug是哪里引入的呢?
请看代码中x3[sk] = sv那句(第29行),由于前面提到的map的reference特性,s3[sk]的值与sv指向的是同一个hashmap,而代码在第21-27行对x3[sk]的值做进一步merge时,修改了这个hashmap!这会导致foo["x1"]["s1"]["s2"]的值也被修改(因为它们共用底层存储区)。
所以,这种情况下,我们必须手动对目的map做“深拷贝”,避免源map也被修改,下面是bug fix后的代码。

 
  1. // bug fix version: mapref.go

  2. package main

  3.  
  4. import (

  5. "fmt"

  6. )

  7.  
  8. func main() {

  9. foo := make(map[string]map[string]map[string]float32)

  10. foo_s12 := map[string]float32{"s2": 0.1}

  11. foo_s1 := map[string]map[string]float32{"s1": foo_s12}

  12. foo["x1"] = foo_s1

  13. foo_s22 := map[string]float32{"s2": 0.5}

  14. foo_s2 := map[string]map[string]float32{"s1": foo_s22}

  15. foo["x2"] = foo_s2

  16.  
  17. x3 := make(map[string]map[string]float32)

  18. for _, v := range foo {

  19. for sk, sv := range v {

  20. if _, ok := x3[sk]; ok {

  21. for tmpk, tmpv := range sv {

  22. if _, ok := x3[sk][tmpk]; ok {

  23. x3[sk][tmpk] += tmpv

  24. } else {

  25. x3[sk][tmpk] = tmpv

  26. }

  27. }

  28. } else {

  29. // handel "deep copy" manually if necessary

  30. tmp := make(map[string]float32)

  31. for k, v := range sv {

  32. tmp[k] = v

  33. }

  34. x3[sk] = tmp

  35. }

  36. }

  37. }

  38. fmt.Printf("foo=%v\n", foo)

  39. fmt.Printf("x3=%v\n", x3)

  40. }

执行结果符合预期:

 
  1. foo=map[x1:map[s1:map[s2:0.1]] x2:map[s1:map[s2:0.5]]]

  2. x3=map[s1:map[s2:0.6]]

以上就是本文要说明的问题及避免写出相关bug代码的方法。
其实Go map的这个行为与Python中的dict行为非常类似,引入bug的原因都是由于它们的赋值行为是by reference,而新手理解不够深刻导致的。关于Python的类似问题,之前的一篇笔记有过说明,感兴趣的话可以去查看。

原文链接 : https://blog.csdn.net/slvher/article/details/44340531

猜你喜欢

转载自blog.csdn.net/yk200808/article/details/81082866