【GoLang筆記】例項分析GoLang built-in資料結構map的賦值引用行為
阿新 • • 發佈:2019-01-25
備註1:本文旨在介紹Go語言中map這個內建資料結構的引用行為,並用例項來說明如何避免這種引用行為帶來的“副作用”。
備註2:文末列出的參考資料均來自GoLang.org官方文件,需翻牆訪問。
目前已經清楚,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的指標。
先看下面一段簡單程式碼:
可見,正如Go maps in action一文中提到的,Go的map型別是引用型別(Map types are reference types)。關於Go語言的設計者們為何要把map設計成reference type,可以參考Go FAQ在這裡的解釋。
新手需要特別注意這種引用行為,下面開始用例項來說明。
下面是上述程式碼的執行結果:
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後的程式碼。
其實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?
備註2:文末列出的參考資料均來自GoLang.org官方文件,需翻牆訪問。
1. map internals
map是go中內建的資料結構,關於其語法規則,可以檢視language specification中這裡的說明,或者檢視Effective Go中關於Maps的說明,此處略過。
map的底層是用hashmap實現的(底層hashmap原始碼路徑為src/pkg/runtime/hashmap.c),部分註釋摘出如下:
這段註釋除表明map底層確實是hashmap實現的外,還解釋了hashmap部分實現細節。此外,原始碼中還包含遍歷map的處理細節以及一個map性能的小實驗,可以檢視原始碼檔案瞭解。// 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,那麼語法層面的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
具體而言,map這個資料結構在內部維護了一個指標,該指標指向一個真正存放資料的hashmap。參考Go官網部落格的文章Go Slices: usage and internals關於slice內部結構的說明,再結合map底層hashmap.c原始碼片段(注意下面摘出的Hmap結構體定義中的count和buckets欄位,而oldbuckets只在map rehash時有用),可以看出map內部確實維護著map元素的count和指向hashmap的指標。
2. map type is reference typestruct 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) };
先看下面一段簡單程式碼:
// 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 ========================