golang slice傳參陷阱
golang slice傳參陷阱
起因
package main func SliceRise(s []int) { s = append(s, 0) for i := range s { s[i]++ } } func main() { s1 := []int{1, 2} s2 := s1 s2 = append(s2, 3) SliceRise(s1) SliceRise(s2) fmt.Println(s1, s2) } //A: [2,3][2,3,4] //B: [1,2][1,2,3] //C: [1,2][2,3,4] //D: [2,3,1][2,3,4,1]
起因是寢室裡的大佬在我幹大事的時候突然叫我看一道題,就是上面這段程式。於是我憤怒的馬上進行分析。這道題目來源於《Go專家程式設計》p14。我思考了很久,想不到一個解釋的通的答案。
答案是選C。
後面在研究這道題的時候,翹出了一個忽略的知識點。那就是關於slice在傳參
和append
時的一些陷阱。
slice的傳參
在初學golang的時候,我一直以為slice是引用傳遞而不是值傳遞,其實不然。
我們先來看一下官方對於這個問題的解釋:
In a function call, the function value and arguments are evaluated in
the usual order. After they are evaluated, the parameters of the call
are passed by value to the function and the called function begins
execution. The return parameters of the function are passed by value
back to the caller when the function returns.
譯文:
在函式呼叫中,函式值和引數按通常的順序計算。求值之後,呼叫的引數通過值傳遞給函式,被呼叫的函式開始執行。當函式返回時,函式的返回引數按值返回給呼叫者。
來源於:
https://golang.org/ref/spec#Calls
也就是說golang中其實是沒有所謂的引用傳遞的,只有值傳遞。那為什麼我們在函式中對slice進行修改時,有時候會影響到函式外部的slice呢?
這就要從slice的記憶體模型說起了,slice的記憶體模型其實非常簡單,就是一個結構體,裡面包含了三個欄位。第一個欄位是一個指向底層陣列的指標,第二個是slice的長度,第三個是底層陣列的大小。具體的可以看這裡:
type slice struct {
array unsafe.Pointer
len int
cap int
}
在傳遞引數的時候,其實是傳遞了一一個slice結構體,這個時候當然是值傳遞。我們來驗證一下:
package main
import "fmt"
func SliceRise(s []int) {
fmt.Printf("%p\n", &s)
s = append(s, 0)
for i := range s {
s[i]++
}
}
func main() {
s1 := []int{1, 2}
s2 := s1
s2 = append(s2, 3)
fmt.Printf("%p\n", &s1)
SliceRise(s1)
//SliceRise(s2)
//fmt.Println(s1, s2)
}
//輸出
//0xc000004078
//0xc000004090
通過計算可以知道slice結構體的大小為24byte,兩個地址之差剛好是24byte。
地址不同,所以兩個結構體不是同一個結構體。
然而結構體中的指標欄位卻包含了底層陣列的地址,這就使得函式中的slice和函式外的slice都指向了同一個底層陣列,這也就是有些時候,改變函式內部的slice也能影響到函式外部的slice的原因。
slice的擴容
有關擴容的詳細規則可以看這篇部落格:https://blog.csdn.net/qq_49723651/article/details/121267698?spm=1001.2014.3001.5501。
slice在append的時候,如果底層陣列的大小(cap)不夠了,就會發生擴容。發生擴容的時候,slice結構體的指標會指向一個新的底層陣列,然後把原來陣列中的元素拷貝到新陣列中,最後新增上append的新元素,就完成了擴容。
所以在這個時候,函式內部slice的改變是不會影響到函式外部slice的。因為此時,兩個結構體中的指標指向的底層陣列已經不相同了。
回到開始
然後我們回到最開始的這段程式碼:
package main
func SliceRise(s []int) {
s = append(s, 0)
for i := range s {
s[i]++
}
}
func main() {
s1 := []int{1, 2}
s2 := s1
s2 = append(s2, 3)
SliceRise(s1)
SliceRise(s2)
fmt.Println(s1, s2)
}
//A: [2,3][2,3,4]
//B: [1,2][1,2,3]
//C: [1,2][2,3,4]
//D: [2,3,1][2,3,4,1]
選C也就不難解釋了:
- 首先s1在初始化的時候,分配了一個底層陣列,
len=2
,cap=2
; - 將s1賦值給s2,兩者就指向了同一個底層陣列;
- s2發生擴容,因為cap不夠了,這個時候s2指向一個
新的底層陣列
,並且len=3
,cap=4
; - 然後呼叫兩次
SliceRise
函式; - s1作為引數進入函式時,發生了擴容,因為
cap
不夠了,所以新分配了一個底層陣列,這個時候,main函式中的s1與SliceRise
中的s1已經分道揚鑣了。所以main函式中的s1不會有任何改變; - s2作為引數進入函式時,同樣發生了擴容,但是
cap
還夠,所以不會分配新的底層陣列,接下來的所有改變都會影響到main函式中的s2; - 所以最終在main函式中,s1輸出[1,2],而s2輸出[2,3,4]。