Go中slice作為引數傳遞的一些“坑”

DeenJun發表於2019-03-04

看到這個題目,可能大家都覺得是一個老生常談的月經topic了。一直以來其實把握一個“值傳遞”基本上就能理解各種情況了,不過最近遇到了更深一點的“小坑”,與大家分享一下。

首先還是從最簡單的說起,看下面程式碼:

func main() {
        a := []int{7,8,9}
        fmt.Printf("len: %d cap:%d data:%+v
", len(a), cap(a), a)
        ap(a)
        fmt.Printf("len: %d cap:%d data:%+v
", len(a), cap(a), a)
}

func ap(a []int) {
        a = append(a, 10)
}
複製程式碼

可以點選這裡執行程式碼,以上程式碼的輸出是什麼呢?

我這裡不賣關子了直接說,再呼叫ap函式進行append操作後,a依然是[]int{7,8,9}。原因很簡單,Go中沒有引用傳遞全是值傳遞,值傳遞意味著傳遞的是資料的拷貝。這句話新手可能稍微有點雲裡霧裡,而實際情況又比較詭異,比如說下面程式碼:

func main() {
        a := []int{7,8,9}
        fmt.Printf("len: %d cap:%d data:%+v
", len(a), cap(a), a)
        ap(a)
        fmt.Printf("len: %d cap:%d data:%+v
", len(a), cap(a), a)
}

func ap(a []int) {
        a[0] = 1
        a = append(a, 10)
}
複製程式碼

點選這裡執行程式碼,這時ap後再輸出a,會看到a[0]變成了1,但a的cap依然是3,看起來10並沒有被append進去?

這看起來就比較匪夷所思了,不是說值傳遞嗎,為什麼還是影響外部變數的值了呢?按理說要麼都變要麼都不變才說得過去啊。

這實際上並不是匪夷所思,因為Go和C不一樣,slice看起來像陣列,實際上是一個結構體,在原始碼中的資料結構是:

type slice struct {
	array unsafe.Pointer
	len   int
	cap   int
}
複製程式碼

這個結構體其實也很好理解,array是一個真正的陣列指標,指向一段連續記憶體空間的頭部,len和cap代表長度和容量。

換句話說,你看起來在程式碼裡傳參時寫的是ap(a []int),實際上在程式碼編譯期,這段程式碼變成了ap(a runtime.slice)

你可以嘗試這麼理解,把ap(a)替換成ap(array: 0x123, len: 3, cap: 3)。可以很明顯的看到,傳遞到ap函式的三個引數,僅僅是3個數值,並沒有和外部變數a建立任何引用關係。這便是值傳遞。

但是,你可能會疑惑,為什麼我改了a[0]的值,也會在外面體現呢?其實看到這裡你應該已經可以自己想明白了,因為array是一個地址值(比如0x123),這個地址傳入了ap函式,但是它代表的地址0x123和外部a的0x123是一個記憶體地址,這時候你修改a[0],實際上是修改0x123地址中存放的值,所以外部當然會受影響了。

舉個形象點的例子,假設你是火車站貨物管理員,你管理的是第1到第3節車廂(車廂是互通的)的裝卸貨貨。有一天你生病了,找個人(叫A)臨時來接手一下。但是火車的貨不是誰想碰就碰的,你得有證明才行。於是你把你手上的證明原件影印了一份給A,同時把第一節車廂的鑰匙給A。由於剛好那幾天比較忙,站長又讓A也負責第四節車廂,於是A也得到了車廂4的證明原件。一段時間後,你生病回來,你依然只有1到3節車廂的證件,你可以看到最近A在1到3車廂搞的事情,但是你沒有資格去4車廂。

以上例子應該可以很好的說明slice傳參的場景,記住,Go中只有值傳遞。

是不是就完事兒了呢?然而事情並沒有這麼簡單。最近我工作時就遇到這個問題了。按照上面的舉例,雖然你沒有資格去檢視4車廂,但是如果你好奇,你可以偷看啊,因為它們是連續的互通的,正如陣列也是一段連續的記憶體,於是就有這樣的程式碼:

func main() {
        a := []int{}
        a = append(a, 7,8,9)
        fmt.Printf("len: %d cap:%d data:%+v
", len(a), cap(a), a)
        ap(a)
        fmt.Printf("len: %d cap:%d data:%+v
", len(a), cap(a), a)
        p := unsafe.Pointer(&a[2])
        q := uintptr(p)+8
        t := (*int)(unsafe.Pointer(q))
        fmt.Println(*t)
}

func ap(a []int) {
        a = append(a, 10)
}
複製程式碼

點選這裡執行程式碼

雖然外部的cap和len並沒有改變,但是ap函式往同一段記憶體地址append了一個10,那我是不是可以用比較trick的方法去偷看呢?比如找到a[2]的地址,往後挪一個int的長度,就應該是ap函式新增的10了吧?這裡需要注意,Go官網的server是32位的,所以在go playground執行這段程式碼時,int是4位元組。

執行結果和我預想的一樣!

但是問題接踵而至

func main() {
        a := []int{7,8,9}
        fmt.Printf("len: %d cap:%d data:%+v
", len(a), cap(a), a)
        ap(a)
        fmt.Printf("len: %d cap:%d data:%+v
", len(a), cap(a), a)
        p := unsafe.Pointer(&a[2])
        q := uintptr(p)+8
        t := (*int)(unsafe.Pointer(q))
        fmt.Println(*t)
}

func ap(a []int) {
        a = append(a, 10)
}
複製程式碼

這段程式碼你再試試

這和上面一個例子唯一的區別就是slice一開始是用[]int{7,8,9}這種方式初始化。執行結果*t是3而不是10,這就比較困惑了。為啥?不是一段連續的記憶體空間嗎?

這裡其實涉及到的問題是slice的growth問題,當append時發現cap不夠了,會重新分配空間,具體原始碼參見 runtime/slice.go中的growslice函式。我這裡就不講太多細節,只講結果。當發生growslice時,會給slice重新分配一段更大的記憶體,然後把原來的資料copy過去,把slice的array指標指向新記憶體。也就是說,假如之前的資料是存放到記憶體地址 0x0 0x8 0x10,當不發生growslice,新append的數值會存到0x18,然而當發生growslice,以前的所有資料被copy到新的地址0x1000 0x1008 0x1010,新append的值放到0x1018了。

這時候你就可以理解為什麼有時候用unsafe能拿到資料,有時候拿不到了。或許你可以理解為什麼這個包叫做unsafe了。不過unsafe不是真的unsafe,是說如果你使用的姿勢不對就非常容易unsafe。但是如果姿勢優雅,其實很safe。對於slice操作,如果要使用unsafe,千萬記得關注cap是否傳送變化,它意味著記憶體的遷移

相關文章