這是一個建立於 的文章,其中的資訊可能已經有所發展或是發生改變。
傳參和傳引用的問題
很多非官方的文檔和教材(包括一些已經出版的圖書), 對Go語言的傳參和引用的講解 都有很多問題. 導致眾多Go語言新手對Go的函數參數傳參有很多誤解.
而傳參和傳引用是程式設計語言的根本問題, 如果這個問題理解錯誤可能會導致很多問題.
傳slice不是傳引用!
首先, Go語言的函數調用參數全部是傳值的, 包括 slice/map/chan 在內所有類型, 沒有傳引用的說法.
具體請看Go語言的規範:
After they are evaluated, the parameters of the call are passed by value to the function and the called function begins execution.
from: http://golang.org/ref/spec#Calls
什麼叫傳引用?
比如有以下代碼:
var a ObjectdoSomething(a) // 修改a的值print(a)
如果函數doSomething修改a的值, 然後print列印出來的也是修改後的值, 那麼就可以認為doSomething是通過引用的方式使用了參數a.
為什麼傳slice不是傳引用?
我們構造以下的代碼:
func main() { a := []int{1,2,3} fmt.Println(a) modifySlice(a) fmt.Println(a)}func modifySlice(data []int) { data = nil}
其中modifySlice修改了切片a, 輸出結果如下:
[1 2 3][1 2 3]
說明a在調用modifySlice前後並沒有任何變化, 因此a必然是傳值的!
為什麼很多人誤以為slice是傳引用呢?
可能是FAQ說slice是參考型別, 但並不是傳引用!
下面這個代碼可能是錯誤的根源:
func main() { a := []int{1,2,3} fmt.Println(a) modifySliceData(a) fmt.Println(a)}func modifySliceData(data []int) { data[0] = 0}
輸出為:
[1 2 3][0 2 3]
函數modifySliceData確實通過參數修改了切片的內容.
但是請注意: 修改通過函數修改參數內容的機制有很多, 其中傳參數的地址就可以修改參數的值(其實是修改參數中指標指向的資料), 並不是只有引用一種方式!
傳指標和傳引用是等價的嗎?
比如有以下代碼:
func main() { a := new(int) fmt.Println(a) modify(a) fmt.Println(a)}func modify(a *int) { a = nil}
輸出為:
0xc0100000000xc010000000
可以看出指標a本身並沒有變化. 傳指標或傳地址也只能修改指標指向的記憶體的值, 並不能改變指標本身在值.
因此, 函數參數傳傳指標也是傳值的, 並不是傳引用!
所有類型的函數參數都是傳值的!
包括slice/map/chan等基礎類型和自訂的類型都是傳值的.
但是因為slice和map/chan底層結構的差異, 又導致了它們傳值的影響並不完全等同.
重點歸納如下:
- GoSpec: the parameters of the call are passed by value!
- map/slice/chan 都是傳值, 不是傳引用
- map/chan 對應指標, 和引用類似
slice 是結構體和指標的混合體
slice 含 values/count/capacity 等資訊, 是按值傳遞
- slice 中的 values 是指標, 按值傳遞
按值傳遞的 slice 只能修改values指向的資料, 其他都不能修改
以指標或結構體的角度看, 都是值傳遞!
那Go語言有傳引用的說法嗎?
Go語言其實也是有傳引用的地方的, 但是不是函數的參數, 而是閉包對外部環境是通過引用訪問的.
查看以下的代碼:
func main() { a := new(int) fmt.Println(a) func() { a = nil }() fmt.Println(a)}
輸出為:
0xc010000000<nil>
因為閉包是通過引用的方式使用外部環境的a變數, 因此可以直接修改a的值.
比如下面2段代碼的輸出是截然不同的, 原因就是第二個代碼是通過閉包引用的方式輸出i變數:
for i := 0; i < 5; i++ { defer fmt.Printf("%d ", i) // Output: 4 3 2 1 0}fmt.Printf("\n") for i := 0; i < 5; i++ { defer func(){ fmt.Printf("%d ", i) } () // Output: 5 5 5 5 5}
像第二個代碼就是於閉包引用導致的副作用, 迴避這個副作用的辦法是通過參數傳值或每次閉包構造不同的臨時變數:
// 方法1: 每次迴圈構造一個臨時變數 ifor i := 0; i < 5; i++ { i := i defer func(){ fmt.Printf("%d ", i) } () // Output: 4 3 2 1 0}// 方法2: 通過函數參數傳參for i := 0; i < 5; i++ { defer func(i int){ fmt.Printf("%d ", i) } (i) // Output: 4 3 2 1 0}
什麼是參考型別, 和指標有何區別/聯絡 ?
在Go語言的官方FAQ中描述, maps/slices/channels 是參考型別, 數組是實值型別:
Why are maps, slices, and channels references while arrays are values?
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.
from: http://golang.org/doc/faq#references
我個人理解, 參考型別和指標在底層實現上是一樣的. 但是參考型別在文法上隱藏了顯示的指標操作. 參考型別和函數參數的傳引用/傳值並不是一個概念.
我們知道 maps/slices/channels 在底層雖然隱含了指標, 但是使用中並沒有需要使用指標的文法. 但是引用記憶體畢竟是基於指標實現, 因此就必須依賴 make/new 之類的函數才能構造出來. 當然它們都支援字面值文法構造, 但是本質上還是需要一個構造的過程的.
要用好Go語言的參考型別, 必須要瞭解一些底層的結構(特別是slice的混合結構).
我們可以自己給Go語言類比一個參考型別. 我們可以將實值型別特定的數群組類型定義為一個參考型別(同時提供一個建構函式):
type RefIntArray2 *[2]intfunc NewRefIntArray2() RefIntArray2 { return RefIntArray2(new([2]int))}
這樣我們就可以將 RefIntArray2 當作參考型別來使用.
func main() { refArr2 := NewRefIntArray2() fmt.Println(refArr2) modifyRefArr2(refArr2) fmt.Println(refArr2)}func modifyRefArr2(arr RefIntArray2) { arr[0] = 1}
輸出為:
&[0 0]&[1 0]
之所以選擇數組作為例子, 是因為Go語言的數組指標可以直接用[]訪問的文法糖. 所以, 參考型別一般都是底層指標實現, 只是在上層加上的文法糖而已.
注: 本節根據 @hooluupog 和 @LoongWong 的評論做的補充.
總結
- 函數參數傳值, 閉包傳引用!
- slice 含 values/count/capacity 等資訊, 是按值傳遞
- 按值傳遞的 slice 只能修改values指向的資料, 其他都不能修改
- slice 是結構體和指標的混合體
- 參考型別和傳引用是兩個概念