1. 程式人生 > 其它 >golang slice傳參陷阱

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的長度,第三個是底層陣列的大小。具體的可以看這裡:

https://blog.csdn.net/qq_49723651/article/details/121267698?spm=1001.2014.3001.5501

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=2cap=2
  • 將s1賦值給s2,兩者就指向了同一個底層陣列;
  • s2發生擴容,因為cap不夠了,這個時候s2指向一個新的底層陣列,並且len=3cap=4
  • 然後呼叫兩次SliceRise函式;
  • s1作為引數進入函式時,發生了擴容,因為cap不夠了,所以新分配了一個底層陣列,這個時候,main函式中的s1與SliceRise中的s1已經分道揚鑣了。所以main函式中的s1不會有任何改變;
  • s2作為引數進入函式時,同樣發生了擴容,但是cap還夠,所以不會分配新的底層陣列,接下來的所有改變都會影響到main函式中的s2;
  • 所以最終在main函式中,s1輸出[1,2],而s2輸出[2,3,4]。