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

今天,嘗試談下 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。此時的內存的分佈如下:

變量 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


歡迎關注我的公衆號。

本篇文章由一文多發平臺ArtiPub自動發佈
發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章