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