Go 函數的 Map 型參數,會發生擴容后指向不同底層內存的事兒嗎?
本文轉載自微信公眾號「網管叨bi叨」,作者KevinYan11。轉載本文請聯系網管叨bi叨公眾號。
最近跟同事做項目,由于要在函數里向一個 Map 中寫入不少數據,這個 Map 是作為參數傳到函數里的。他問了我一個問題: “如果把 Map 作為函數參數傳遞,會不會像用 Slice 做參數時一樣詭異,是不是一定要把 Map 當成返回值返回才能讓函數外部的 Map 變量看到這里添加的數據”?
啥叫會不會像用 Slice 做參數時一樣詭異?同事沒有明說,其實我已經猜到他說的是什么意思了,說的應該是 Slice 的底層數組如果發生了擴容后會讓函數內外原本指向同一個底層數組的兩個 Slice 變量,分別指向兩個不同的底層數組。
最后就導致了函數內做的數據添加,但是函數外原來的 Slice 變量并沒有任何改變的詭異效果。光看字兒解釋起來有點難懂,舉個例子,有下面這樣一個程序。
func main() {
s := []int{1, 2, 3}
reverse(s)
fmt.Println(s)
}
func reverse(s []int) {
s = append(s, 999, 1000, 1001)
for i, j := 0, len(s)-1; i < j; i++ {
j = len(s) - (i + 1)
s[i], s[j] = s[j], s[i]
}
}
本來切片只有 3 個元素,分別是 1,2,3。我們把切片賦給了變量 s,然后用變量 s 作為參數傳給了函數 reverse 進行處理,函數 reverse 在反轉切片元素之前還給原來的切片先追加了幾個值,這就導致了切片發生擴容。因為切片實際上并不是一個指針類型,它的運行時類型表示是 SliceHeader。
type SliceHeader struct {
Data uintptr
Len int
Cap int
}
因為 Go 里邊有一切都是值傳遞的規則,所以切片作為參數時,會在函數內重新拷貝一個 SliceHeader 結構體,只不過結構體的 Data 指針一開始跟外部切片的指向是一樣的,都是同一個底層數據。
這就導致了函數內切片 SliceHeader 里的 Data 指針發生變化后,函數外原來的切片還是指向原來的底層數組。最后結果,打印函數外切片變量輸出的是 [1, 2, 3],但函數里邊的切片已經是 [1001, 1000, 999, 3, 2, 1] 了。
下面這個圖,展示了這個函數內外切片指向的底層數組發生變化的過程。
那么如果用 Map 當函數參數時,有這檔子破事兒嗎?誒,提到這我就要吐槽下這個一切都是傳值的設計了,把一些寫 Go 的程序員搞的戰戰兢兢,用 Map 和結構體指針當參數的時候也老琢磨底層會不會變。
當然我也不是寫 Go 的時候都盲目自信,一般書上、別人文章里寫的東西我在用的時候,如果不確定他們說的對不對,我都會寫個單測試一試。事后再找找解釋這些知識點的資料看看,自己解惑一下。
聊遠了,下面說下答案哈,如果用 Map 當函數參數,Map發生擴容后,函數內外的Map變量指向的底層內存仍是一致的。這是為什么呢?答案我是在《Go 語言設計與實現》哈希表這一章找到的,有書的可以翻開 75 頁看看。
如果沒有書的可以看文末的引用鏈接里貼的在線書籍地址。
關于 Map 的初始化是這么描述的
使用 make 創建哈希,Go 語言編譯器都會在類型檢查期間將它們轉換成 runtime.makemap,使用字面量初始化哈希也只是語言提供的輔助工具,最后調用的都是 runtime.makemap:
func makemap(t *maptype, hint int, h *hmap) *hmap {
mem, overflow := math.MulUintptr(uintptr(hint), t.bucket.size)
if overflow || mem > maxAlloc {
hint = 0
}
......
return h
}
通過上面的解釋和代碼我們了解到 Map 這個數據類型,在運行時實際上是一個 hmap類型的指針,只不過在我們寫代碼階段被隱藏起來了。
既然是一個 Map 類型的變量實際上是一個指針變量,這跟 Slice 就完全不同了,雖然指針作為函數參數時在 Go 里面也是按照值傳遞的,但是內外兩個指針是指向的同一個 hamp 結構所在的內存,hmap 結構里有很多字段,回答這里的問題,我們只需要知道 buckets 和 oldbuckets 這兩個指針類型的字段就行了。
type hmap struct {
count int
flags uint8
B uint8
noverflow uint16
hash0 uint32
buckets unsafe.Pointer
oldbuckets unsafe.Pointer
nevacuate uintptr
extra *mapextra
}
Go 的 Map中用于存儲鍵值對數據的結構--桶(bmap),對于bmap 我們不再深挖下去。
buckets 是指向桶數組的。當哈希表增長到需要擴容的時候,Go語言會將bucket數組的數量擴充一倍,產生一個新的bucket數組,老數據存放在 oldbuckets 指向的桶中,并在被訪問到時遷移到新桶中去。
這里雖然擴容導致 Map 有了新 bucket 數組的地址,但是這個地址是存在 hmap 的字段 buckets 上的,變更字段的值并不會影響 hmap本身的內存地址。
所以當 Map 由于函數內的操作發生擴容時,不會像上面例子里的 Slice 指向不同底層數組的詭異現象。
不知道大家有沒有看明白我這里的分析,這篇文章其實是我自己對思考問題的一個記錄,防止時間長了以后忘掉。傳值、傳引用這些在不同的語言里不一樣,對于像我們掌握了至少三門編程語言的男人:)也就只能靠寫寫筆記防止混淆啦。
(我相信絕大多數人的職業生涯是不能靠一門編程語言吃遍天的)
還有一點我是覺得 Go 的 Slice 使用起來確實要耗費的心智有點高,一不注意就容易踩坑,時間長了,搞的大家用 Map 和 指針當參數時也會先自我懷疑一下,希望這篇文章對解決掉你們的使用疑慮有一定幫助。
引用地址
Go 語言設計與實現 --哈希表 https://draveness.me/golang/docs/part2-foundation/ch03-datastructure/golang-hashmap