1. 程式人生 > >【GoLang筆記】例項分析GoLang built-in資料結構map的賦值引用行為

【GoLang筆記】例項分析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),部分註釋摘出如下:

// This file contains the implementation of Go's map type.
//
// The map is just a hash table.  The data is arranged
// into an array of buckets.  Each bucket contains up to
// 8 key/value pairs.  The low-order bits of the hash are
// used to select a bucket.  Each bucket contains a few
// high-order bits of each hash to distinguish the entries
// within a single bucket.
//
// If more than 8 keys hash to a bucket, we chain on
// extra buckets.
//
// When the hashtable grows, we allocate a new array
// of buckets twice as big.  Buckets are incrementally
// 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的指標。
struct Hmap 
{
    // Note: the format of the Hmap is encoded in ../../cmd/gc/reflect.c and
    // ../reflect/type.go.  Don't change this structure without also changing that code!
    uintgo  count;        // # live cells == size of map.  Must be first (used by len() builtin)
    uint32  flags;
    uint32  hash0;        // hash seed
    uint8   B;            // log_2 of # of buckets (can hold up to LOAD * 2^B items)
    uint8   keysize;      // key size in bytes
    uint8   valuesize;    // value size in bytes
    uint16  bucketsize;   // bucket size in bytes

    byte    *buckets;     // array of 2^B Buckets. may be nil if count==0.
    byte    *oldbuckets;  // previous bucket array of half the size, non-nil only when growing
    uintptr nevacuate;    // progress counter for evacuation (buckets less than this have been evacuated)
};
2. map type is reference type
先看下面一段簡單程式碼:
// demo.go
package main

import "fmt"

func main() {
    foo := make(map[string]string)
    foo["foo"] = "foo_v"
    bar := foo
    bar["bar"] = "bar_v"

    fmt.Printf("foo=%v, ptr_foo=%v\n", foo, &foo)
    fmt.Printf("bar=%v, ptr_bar=%v\n", bar, &bar)
}
編譯並執行:
$ go build demo.go
$ ./demo
輸出結果如下:
foo=map[foo:foo_v bar:bar_v], ptr_foo=0xc210000018
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的程式碼感受下。

// bug version: mapref.go
package main

import (
    "fmt"
)

func main() {
    foo := make(map[string]map[string]map[string]float32)
    foo_s12 := map[string]float32{"s2": 0.1}
    foo_s1 := map[string]map[string]float32{"s1": foo_s12}
    foo["x1"] = foo_s1
    foo_s22 := map[string]float32{"s2": 0.5}
    foo_s2 := map[string]map[string]float32{"s1": foo_s22}
    foo["x2"] = foo_s2

    x3 := make(map[string]map[string]float32)
    for _, v := range foo {
        for sk, sv := range v {
            if _, ok := x3[sk]; ok {
                for tmpk, tmpv := range sv {
                    if _, ok := x3[sk][tmpk]; ok {
                        x3[sk][tmpk] += tmpv
                    } else {
                        x3[sk][tmpk] = tmpv
                    }
                }
            } else {
                x3[sk] = sv  ## 注意這裡,map的賦值是個引用行為!
            }
        }
    }
    fmt.Printf("foo=%v\n", foo)
    fmt.Printf("x3=%v\n", x3)
}
上述程式碼的目的是對一個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的值。
下面是上述程式碼的執行結果:
foo=map[x1:map[s1:map[s2:0.6]] x2:map[s1:map[s2:0.5]]]
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後的程式碼。
// bug fix version: mapref.go
package main

import (
    "fmt"
)

func main() {
    foo := make(map[string]map[string]map[string]float32)
    foo_s12 := map[string]float32{"s2": 0.1}
    foo_s1 := map[string]map[string]float32{"s1": foo_s12}
    foo["x1"] = foo_s1
    foo_s22 := map[string]float32{"s2": 0.5}
    foo_s2 := map[string]map[string]float32{"s1": foo_s22}
    foo["x2"] = foo_s2

    x3 := make(map[string]map[string]float32)
    for _, v := range foo {
        for sk, sv := range v {
            if _, ok := x3[sk]; ok {
                for tmpk, tmpv := range sv {
                    if _, ok := x3[sk][tmpk]; ok {
                        x3[sk][tmpk] += tmpv
                    } else {
                        x3[sk][tmpk] = tmpv
                    }
                }
            } else {
                // handel "deep copy" manually if necessary
                tmp := make(map[string]float32)
                for k, v := range sv {
                    tmp[k] = v
                }
                x3[sk] = tmp
            }
        }
    }
    fmt.Printf("foo=%v\n", foo)
    fmt.Printf("x3=%v\n", x3)
}
執行結果符合預期:
foo=map[x1:map[s1:map[s2:0.1]] x2:map[s1:map[s2:0.5]]]
x3=map[s1:map[s2:0.6]]
以上就是本文要說明的問題及避免寫出相關bug程式碼的方法。
其實Go map的這個行為與Python中的dict行為非常類似,引入bug的原因都是由於它們的賦值行為是by reference,而新手理解不夠深刻導致的。關於Python的類似問題,之前的一篇筆記有過說明,感興趣的話可以去檢視。 【參考資料】
1. Go source code - src/pkg/runtime/hashmap.c
2. The Go Programming Language Specification - Map types
3. Effective Go - Maps
4. Go Slices: usage and internals
5. Go maps in action 
6. Go FAQ - Why are maps, slices, and channels references while arrays are values?

============================ EOF ========================