Golang中函式傳參存在引用傳遞嗎?

大愚Talk發表於2018-06-11

繼上篇文章後,繼續來探討下面的幾個問題:

  1. 函式傳參中值傳遞、指標傳遞與引用傳遞到底有什麼不一樣?
  2. 為什麼說 slicemapchannel 是引用型別?
  3. Go中 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. 文件地址:https://golang.org/ref/spec#Calls

官方文件已經明確說明:Go裡邊函式傳參只有值傳遞一種方式,為了加強自己的理解,再來把每種傳參方式進行一次梳理。

值傳遞

值傳遞是指在呼叫函式時將實際引數複製一份傳遞到函式中,這樣在函式中如果對引數進行修改,將不會影響到實際引數。

概念總給人一種教科書的感覺,寫點程式碼驗證下。

func main() {
	a := 10
	fmt.Printf("%#v\n", &a) // (*int)(0xc420018080)
	vFoo(a)
}

func vFoo(b int) {
	fmt.Printf("%#v\n", &b) // (*int)(0xc420018090)
}
複製程式碼

註釋內容是我機器的輸出,你如果執行會得到不一樣的輸出

根據程式碼來解釋下,所謂的值傳遞就是:實參 a 在傳遞給函式 vFoo 的形參 b 後,在 vFoo 的內部,b 會被當作區域性變數在棧上分配空間,並且完全拷貝 a 的值。

程式碼執行後,我們看到的結果便是:a、b擁有完全不同的記憶體地址, 說明他們雖然值相同(b拷貝的a,值肯定一樣),但是分別在記憶體中不同的地方,也因此在函式 vFoo 內部如果改變 b 的值,a 是不會受到影響的。

funcCall

圖中左側是還未呼叫時,記憶體的分配,右側是呼叫函式後記憶體分別分配的變數。這裡需要注意,就算vFoo的引數名字是a,實參與形參也分別有自己的記憶體空間,因為引數的名字僅僅是給程式設計師看的,上篇文章已經說清楚了。

指標傳遞

形參為指向實參地址的指標,當對形參的指向操作時,就相當於對實參本身進行的操作。

是不是雲裡霧裡的?還是通過程式碼結合來分析所謂的指標傳遞。

func main() {
	a := 10
	pa := &a
	fmt.Printf("value: %#v\n", pa) // value: (*int)(0xc420080008)
	fmt.Printf("addr: %#v\n", &pa) // addr: (**int)(0xc420088018)
	pFoo(pa)
}

func pFoo(p * int) {
	fmt.Printf("value: %#v\n", p) // value: (*int)(0xc420080008)
	fmt.Printf("addr: %#v\n", &p) // addr: (**int)(0xc420088028)
}
複製程式碼

定義了一個變數 a,並把地址儲存在指標變數 pa 裡邊了。按照我們定的結論,Go中只有值傳遞,那麼指標變數pa傳給函式的形參p後,形參將會是它在棧上的一份拷貝,他們本身將各自擁有不同的地址,但是二者的值是一樣的(都是變數a的地址)。上面的註釋部分是我程式執行後的結果,pa 與 p 的地址各自互不相關,說明在引數傳遞中發生了值拷貝。

在函式 pFoo 中,形參 p 的地址與實參 pa 的地址並不一樣,但是他們在記憶體中的值都是變數 a 的地址,因此可以通過指標相關的操作來改變a的值。

funcCall

圖中 &a 表示a的地址,值為: 0xc420080008

引用傳遞

所謂引用傳遞是指在呼叫函式時將實際引數的地址傳遞到函式中,那麼在函式中對引數所進行的修改,將影響到實際引數。

由於 Go 裡邊並不存在引用傳遞,我們常常看到說 Go 中的引用傳遞也是針對:SliceMapChannel 這幾種型別(這是個錯誤觀點),因此為了解釋清楚引用傳遞,先勞煩大家看一段 C++ 的程式碼(當然非常簡單)。

void rFoo(int & ref) {
    printf("%p\n", &ref);// 0x7ffee5aef768
}

int main() {
    int a = 10;
	  printf("%p\n", &a);// 0x7ffee7307768
    int & b = a;
    printf("%p\n", &b);// 0x7ffee5aef768
    rFoo(b);
    return 0;
}
複製程式碼

這裡就是簡單的在main中定義一個引用,然後傳給函式 rFoo,那麼來看看正統的引用傳遞是什麼樣的?

這裡 b 是 a 的別名(引用,不清楚的可以看我上篇文章),因此a、b必定具備相同的地址。那麼按照引用傳遞的定義,實參 b 傳給形參 ref 之後,ref 將是 b 的別名(也即a、b、ref都是同一個變數),他們將擁有相同地址。通過在 rFoo 函式中的列印資訊,可以看到三者具有完全形同的地址,這是所謂的引用傳遞。

Go中沒有引用傳遞

Go中函式呼叫只有值傳遞,但是型別引用有引用型別,他們是:slicemapchannel。來看看官方的說法:

There's a lot of history on that topic. Early on, maps and channels were syntactically pointers and it was impossible to declare or use a non-pointer instance. Also, we struggled with how arrays should work. Eventually we decided that the strict separation of pointers and values made the language harder to use. Changing these types to act as references to the associated, shared data structures resolved these issues. This change added some regrettable complexity to the language but had a large effect on usability: Go became a more productive, comfortable language when it was introduced.

大概意思是說:最開始用的是指標語法,由於種種原因改成了引用,但是這個引用與C++的引用是不同的,它是共享關聯資料的結構。關於這個問題的深入討論我會放到 slice 相關文章中進行討論,現在回到今天討論的主題。

那麼Go的引用傳遞源起何處?我覺得讓大家誤解的是,map、slice、channel這類引用型別在傳遞到函式內部,可以在函式內部對它的值進行修改而引起的誤會。

針對這種三種型別是 by value 傳遞,我們用 slice 來進行驗證。

func main() {
	arr := [5]int{1, 3, 5, 6, 7}
	fmt.Printf("addr:%p\n", &arr)// addr:0xc42001a1e0
	s1 := arr[:]
	fmt.Printf("addr:%p\n", &s1)// addr:0xc42000a060

	changeSlice(s1)
}

func changeSlice(s []int) {
	fmt.Printf("addr:%p\n", &s)// addr:0xc42000a080
	fmt.Printf("addr:%p\n", &s[0])// addr:0xc42001a1e0
}
複製程式碼

程式碼中定義了一個陣列 arr,然後用它生成了一個slice。如果go中存在引用傳遞,形參 s 的地址應該與實參 s1 一樣(上面c++的證明),通過實際的情況我們發現它們具備完全不同的地址,也就是傳參依然發生了拷貝——值傳遞。

但是這裡有個奇怪的現象,大家看到了 arr 的地址與 s[0] 有相同的地址,這也就是為什麼我們在函式內部能夠修改 slice 的原因,因為當它作為引數傳入函式時,雖然 slice 本身是值拷貝,但是它內部引用了對應陣列的結構,因此 s[0] 就是 arr[0] 的引用,這也就是能夠進行修改的原因。

funcCall

小結

  • Go 中函式傳參僅有值傳遞一種方式;
  • slicemapchannel都是引用型別,但是跟c++的不同;
  • slice能夠通過函式傳參後,修改對應的陣列值,是因為 slice 內部儲存了引用陣列的指標,並不是因為引用傳遞。

接下來的文章嘗試解析下: slice 為什麼一定要用 make 進行初始話,它初始化做了哪些事情?它每次動態擴充套件容量的時候進行了什麼操作?

相關文章