一文理清 Go 引用的常見疑惑

波羅學發表於2019-09-28

本文首發於我的部落格,如果有用,歡迎點贊收藏,讓更多的朋友看到。

今天,嘗試談下 Go 中的引用。

之所以要談它,一方面是之前的我也有些概念混亂,想梳理下,另一方面是因為很多人對引用都有疑問。我經常會看到與引用有關的問題。

比如,什麼是引用?引用和指標有什麼區別?Go 中有引用型別嗎?什麼是值傳遞?址傳遞?引用傳遞?

在開始談論之前,我已經感覺到這必定是一個非常頭疼的話題。這或許就是學了那麼多語言,但沒有深入總結,從而導致的思維混亂。

前言

我的理解是,要徹底搞懂引用,得從型別和傳遞兩個角度分別進行思考。

從型別角度,型別可分為值型別和引用型別,一般而言,我們說到引用,強調的都是型別。

從傳遞角度,有值傳遞、址傳遞和引用傳遞,傳遞是在函式呼叫時才會提到的概念,用於表明實參與形參的關係。

引用型別和引用傳遞的關係,我嘗試用一句話概括,引用型別不一定是引用傳遞,但引用傳遞的一定是引用型別。

這幾句話,是我在使用各種語言的之後總結出來的,希望無誤吧,畢竟不能誤導他人。

是什麼

談到引用,就不得不提指標,而指標與引用是程式設計學習中老生常談的話題了。有些程式語言為了降低程式設計師的使用門檻,只有引用。而有些語言則是指標引用皆存在,如 C++ 和 Go。

指標,即地址的意思。

在程式執行的時候,作業系統會為每個變數分配一塊記憶體放變數內容,而這塊記憶體有一個編號,即記憶體地址,也就是變數的地址。現在 CPU 一般都是 64 位,因而,這個地址的長度一般也就是 8 個位元組。

引用,某塊記憶體的別名。

一般情況,都會這麼解釋引用。換句話說,引用代指某個記憶體地址,這句話真的是非常簡潔,同時也非常好理解。但在 Go 中,這句話看起來並不全面,具體後面解釋。

除了指標和引用,還有另外一個更廣泛的概念,值。談變數傳遞時,常會提到值傳遞、址傳遞和引用傳遞。從廣義上看,對大部分的語言而言,指標和引用都屬於值。而從狹義角度來說,則可分為值、址和引用。

相當繞人是不是?

我已經感覺到自己頭髮在掉了。其實,要想徹底搞清楚這些概念,還是得從本質出發。

值和指標

先來搞明白值與指標區別。

上一節在介紹指標的時候,提到了要注意變數的地址和內容的不同。為什麼要說這句話呢?

假設,我們定義一個 int 型別的變數 a,如下:

var a int = 1
複製程式碼

變數 a 的內容為 1,而變數內容是存在某個地址之中的。如何獲取變數地址呢?Go 中獲取變數地址的方法與 C/C++ 相同。程式碼如下:

var p = &a
複製程式碼

通過 & 獲取 a 的地址。同時,這裡還定義了一個新的變數 p 用於儲存變數 a 的地址。p 的型別為 int 指標,也就是變數 p 中的內容是變數 a 的地址。

如下程式碼輸出它們的地址:

var a = 1
var p = &a
fmt.Printf("%p\n", p)
fmt.Printf("%p\n", &p)
複製程式碼

我這裡的輸出結果是,變數 a 和 p 的地址分別為 0xc000092000 和 0xc00008c010。此時的記憶體的分佈如下:

一文理清 Go 引用的常見疑惑

變數 p 的內容是 a 的地址,因而可以說指標即是其他變數的內容,也是某個變數的地址。為什麼囉囉嗦嗦的說這些,因為在學習 C 語言,會單獨強調址的概念,但在 Go 中,指標相對弱化,也是歸於值型別之中。

引用的本質

前面說過,引用是某塊記憶體的別名。從字面理解,似乎表達的是引用型別變數中的內容是指標,這麼理解似乎也沒錯。既然如此,我自然而然地想到,怎麼將引用與指標關聯起來。

在 C/C++ 中,引用其實是編譯器實現的一個語法糖,經過彙編後,將會把引用操作轉化為了指標操作。這真的是別名啊,有種 define 預處理的感覺,只不過是彙編級別的。分享一篇 C++中“引用”的底層實現 的文章,有興趣仔細讀讀,我只是看了個大概。

而其他一些語言中,引用的本質其實是 struct 中包含指標,比如 Python。下面的 C 結構是 Python 中列表型別的底層結構。

typedef struct {
    PyObject_VAR_HEAD

    PyObject **ob_item;

    Py_ssize_t allocated;
} PyListObject;
複製程式碼

變數真正存放資料的地方在 **ob_item 中。結構中的其他兩個成員起輔助作用。

現在看來,引用的實現主要有兩種。一是 C++ 的思路,引用其實一種便於使用指標的語法糖,和我們想象中的別名含義一致。二是類似 Python 中的實現,底層結構中包含指向實際內容的指標。

當然,或許還有其他的實現方式,但核心應該是不變的。

引用傳遞

談到引用傳遞,就不得不提值傳遞,值傳遞的一般定義如下。

函式呼叫時,實參通過拷貝將自身內容傳遞給形參,形參實際上是實參值的一個拷貝,此時,針對函式中形參的任何操作,僅僅是針對實參的副本,不影響原始值的內容。

值傳遞中有一個特殊形式,如果傳遞引數的型別是指標,我們就會稱之為址傳遞,C 語言中就有值傳遞和址傳遞兩種說法。深究起來,C 中的址傳遞也屬於值傳遞,因為對指標型別而言,變數的值是指標,即傳遞的值也是指標。而 C 語言之所以強調址傳遞,我認為主要 C 這門底層語言對指標較為重視。

什麼是引用傳遞?

參考值傳遞的定義,實參地址在函式呼叫被傳遞給形參,針對形參的操作,影響到了實參,則可以認為是引用傳遞。

在我用過的語言中,支援引用傳遞的語言有 PHP 和 C++。

Go 的引用實現

Go 的引用型別有 slice、map 和 chan,實現機制採用的是前面提到的第二種方式,即結構體含指標成員。它們都可以使用內建函式 make 進行初始化。

原本我是想把這幾種引用型別的底層結構都貼出來,但發現這會干擾本文主題的理解。我們只看 slice 的結構,如下:

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

slice 的結構最簡單,包含三個成員,分別是切片的底層陣列地址、切片長度和容量大小。是否感覺與前面提到的 Python 列表的底層結構非常類似?

如果想了解 map 和 chan 的結構,可自行閱讀 go 的原始碼,runtime/slice.goruntime/map.goruntime/chan.go

如果不想研究原始碼,推薦閱讀饒大的 Go 深度解密系列文章,包括 深度解密Go語言之Slice深度解密Go語言之map深度解密Go語言之channel,這幾篇文章因為寫的都非常細且非常長,可能讀起來會比較考驗你的耐心。

Go 是值傳遞

按官方說法,Go 中只有值傳遞。原文如下:

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 calling function when the function returns.

重點是下面這句話。

After they are evaluated, the parameters of the call are passed by value to the function and the called function begins execution.

有點迷糊?最初我也迷糊,Go 不是有指標和引用型別嗎。但讀了一些文章,思考了許久,才徹底想明白。下面,我將嘗試為官方的說法找個合理的解釋。

為什麼說 Go 中沒有址傳遞

其實,這個問題前面已經解釋的很清楚了,指標只是值的一種特殊形式,C 語言是門非常底層的語言,常會涉及一些地址操作,會強調指標的特殊地位。但於 Go 而言,指標已經弱化了很多,Go 團隊可能也覺得沒有必要再單獨強調指標的地位。

為什麼說 Go 中沒有引用傳遞?

有人可能會說,Go 中明明有引用傳遞,按照引用傳遞的定義,可以非常容易就拿出一個例子反駁我。

package main

import "fmt"

func update(s []int) {
	s[1] = 10
}

func main() {
	a := []int{0, 1, 2, 3, 4}
	fmt.Println(a)
	update(a)
	fmt.Println(a)
}
複製程式碼

輸出結果如下:

[0 1 2 3 4]
[0 10 2 3 4]
複製程式碼

針對形參 s 的操作確實改變了實參 a 的值,似乎的確是引用傳遞。但我想說的是,針對形參的操作並非指的是針對形參中某個元素的操作。

看個 C++ 中引用的例子。

void update(int& s) {
	s = 10;
	printf("s address: %p\n", &s);
}

int main() {
	int a = 1;
	std::cout << a << std::endl;
	printf("a address: %p\n", &a);
	update(a);
	std::cout << a << std::endl;
}
複製程式碼

執行結果如下:

1
a address: 0x7fff5b98f21c
s address: 0x7fff5b98f21c
10
複製程式碼

針對 s 的操作確實改變了 a 的值。在 Go 中嘗試同樣的程式碼,如下:

func update(s []int) {
	s[1] = 10
	fmt.Printf("%p\n", &s)
}

func main() {
	a := []int{0, 1, 2, 3, 4}
	fmt.Println(a)
	fmt.Printf("%p\n", &a)
	update(a)
	fmt.Println(a)
}
複製程式碼

輸出如下:

[0 1 2 3 4]
0xc00000c060
0xc000098000
[0 10 2 3 4]
複製程式碼

非常遺憾,針對形參的賦值操作並沒有改變實參的值。基於此,得出結論是 slice 的傳遞並非引用傳遞。我比較喜歡的這種解釋方式,適合我個人的記憶理解,不知道是否有不妥的地方。

除此之外,介紹另外一種識別是否是引用傳遞的方式。

通過比較形參和實參地址確認,如果兩者地址相同,則是引用傳遞,不同則非引用傳遞。但因為 C++ 和 Go 引用的實現機制不同,理解起來會比較困難。我們也可以選擇只記結論。

這種方式的驗證非常簡單,我們在上面的 C++ 和 Go 的例子中已經輸出了形參和實參的地址,比較下即可得出結論。

總結

本文主要從引用的型別和傳遞兩個角度出發,深入淺出的分析了 Go 中的引用。

首先,引用型別和引用傳遞並沒有絕對的關係,不知道有多少人認為引用型別必然是引用傳遞。接著,我們討論了不同語言引用的實現機制,涉及到 C++、Python 和 Go。

文章的最後,解釋了一個常見的疑惑,為什麼說 Go 只有值傳遞。在此基礎上,文中提出了兩種方式,幫助識別一門語言是否支援引用傳遞。

相關閱讀

golang中哪些引用型別的指標在宣告時不用加&號,哪些在函式定義的形參和返回值型別中不用*號標註

Golang中的make(T, args)為什麼返回T而不是*T?

Go語言引數傳遞是傳值還是傳引用

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

C++ 引用 底層實現機制

The Go Programming Language Specification


歡迎關注我的公眾號。

一文理清 Go 引用的常見疑惑

相關文章