說說不知道的Golang中引數傳遞

qcloud發表於2019-01-16

> 本文由雲 + 社群發表

導言

幾乎每一個 C++ 開發人員,都被面試過有關於函式引數是值傳遞還是引用傳遞的問題,其實不止於 C++,任何一個語言中,我們都需要關心函式在引數傳遞時的行為。在 golang 中存在著 map、channel 和 slice 這三種內建資料型別,它們極大的方便著我們的日常 coding。然而,當這三種資料結構作為引數傳遞的時的行為是如何呢?本文將從這三個內建結構展開,來介紹 golang 中引數傳遞的一些細節問題。

背景

首先,我們直接的來看一個簡短的示例,下面幾段程式碼的輸出是什麼呢?

//demo1
package main

import "fmt"

func test_string(s string){
    fmt.Printf("inner: %v, %v\n",s, &s)
    s = "b"
    fmt.Printf("inner: %v, %v\n",s, &s)
}

func main() {
    s := "a"
    fmt.Printf("outer: %v, %v\n",s, &s)
    test_string(s)
    fmt.Printf("outer: %v, %v\n",s, &s)
}

上文的程式碼段,嘗試在函式 test_string() 內部修改一個字串的數值,通過執行結果,我們可以清楚的看到函式 test_string() 中入參的指標地址發生了變化,且函式外部變數未被內部的修改所影響。因此,很直接的一個結論呼之欲出:golang 中函式的引數傳遞採用的是:值傳遞

//output
outer: a, 0x40e128
inner: a, 0x40e140
inner: b, 0x40e140
outer: a, 0x40e128

那麼是不是到這兒就回答完,本文就結束了呢?當然不是,請再請看看下面的例子:當我們使用的引數不再是 string,而改為 map 型別傳入時,輸出結果又是什麼呢?

//demo2
package main

import "fmt"

func test_map(m map[string]string){
    fmt.Printf("inner: %v, %p\n",m, m)
    m["a"]="11"
    fmt.Printf("inner: %v, %p\n",m, m)
}

func main() {

    m := map[string]string{
        "a":"1",
        "b":"2",
        "c":"3",
    }

    fmt.Printf("outer: %v, %p\n",m, m)
    test_map(m)
    fmt.Printf("outer: %v, %p\n",m, m)
}

根據我們前文得出的結論,按照值傳遞的特性,我們毫無疑問的猜想:函式外兩次輸出的結果應該是相同的,同時地址應該不同。然而,事實卻正是相反:

//output
outer: map[a:1 b:2 c:3], 0x442260
inner: map[a:1 b:2 c:3], 0x442260
inner: map[a:11 b:2 c:3], 0x442260
outer: map[b:2 c:3 a:11], 0x442260

沒錯,在函式 test_map() 中對 map 的修改再函式外部生效了,而且函式內外列印的 map 變數地址竟然一樣。做技術開發的人都知道,在原始碼世界中,如果地址一樣,那就必然是同一個東西,也就是說:這儼然成為了一個引用傳遞的特性了。

兩個示例程式碼的結果竟然截然相反,如果上述的內容讓你產生了疑惑,並且你希望徹底的瞭解這過程中發生了什麼。那麼請閱讀完下面的內容,跟隨作者一起從原始碼透過現象看本質。本文接下來的內容,將對 golang 中的 map、channel 和 slice 三種內建資料結構在作為函式引數傳遞時的行為進行分析,從而完整的解析 golang 中函式傳遞的行為。

迷惑人心的 Map

Golang 中的 map,實際上就是一個 hashtable,在這兒我們不需要了解其詳細的實現結構。回顧一下上文的例子我們首先通過 make() 函式(運算子:=是 make() 的語法糖,相同的作用)初始化了一個 map 變數,然後將變數傳遞到 test_map() 中操作。

眾所周知,在任何語言中,傳遞指標型別的引數才可以實現在函式內部直接修改內容,如果傳遞的是值本身的,會有一次拷貝發生(此時函式內外,該變數的地址會發生變化,通過第一個示例可以看出),因此,在函式內部的修改對原外部變數是無效的。但是,demo2 示例中的變數卻完全沒有拷貝發生的跡象,那麼,我們是否可以大膽的猜測,通過 make() 函式建立出來的 map 變數會不會實際上是一個指標型別呢?這時候,我們便需要來看一下原始碼了:

// makemap implements Go map creation for make(map[k]v, hint).
// If the compiler has determined that the map or the first bucket
// can be created on the stack, h and/or bucket may be non-nil.
// If h != nil, the map can be created directly in h.
// If h.buckets != nil, bucket pointed to can be used as the first bucket.
func makemap(t *maptype, hint int, h *hmap) *hmap {
    if hint < 0 || hint > int(maxSliceCap(t.bucket.size)) {
        hint = 0
    }
    ...

上面是 golang 中的 make() 函式在 map 中通過 makemap() 函式來實現的程式碼段,可以看到,與我們猜測一致的是:makemap() 返回的是一個 hmap 型別的指標*hmap。也就是說:test_map(map) 實際上等同於 test_map(*hmap)。因此,在 golang 中,當 map 作為形參時,雖然是值傳遞,但是由於 make() 返回的是一個指標型別,所以我們可以在函式哪修改 map 的數值並影響到函式外。

我們也可以通過一個不是很恰當的反例來證明這點:

//demo3
package main

import "fmt"

func test_map2(m map[string]string){
    fmt.Printf("inner: %v, %p\n",m, m)
    m = make(map[string]string, 0)
    m["a"]="11"
    fmt.Printf("inner: %v, %p\n",m, m)
}

func main() {
    var m map[string]string//未初始化

    fmt.Printf("outer: %v, %p\n",m, m)
    test_map2(m)
    fmt.Printf("outer: %v, %p\n",m, m)
}

由於在函式 test_map2() 外僅僅對 map 變數 m 進行了宣告而未初始化,在函式 test_map2() 中才對 map 進行了初始化和賦值操縱,這時候,我們看到對於 map 的更改便無法反饋到函式外了。

//output
outer: map[], 0x0
inner: map[], 0x0
inner: map[a:11], 0x442260
outer: map[], 0x0

跟風的 Channel

在介紹完 map 型別作為引數傳遞時的行為後,我們再來看看 golang 的特殊型別:channel 的行為。還是通過一段程式碼來來入手:

//demo4
package main

import "fmt"


func test_chan2(ch chan string){
    fmt.Printf("inner: %v, %v\n",ch, len(ch))
    ch<-"b"
    fmt.Printf("inner: %v, %v\n",ch, len(ch))
}

func main() {
    ch := make(chan string, 10)
    ch<- "a"

    fmt.Printf("outer: %v, %v\n",ch, len(ch))
    test_chan2(ch)
    fmt.Printf("outer: %v, %v\n",ch, len(ch))
}

結果如下,我們看到,在函式內往 channel 中塞入數值,在函式外可以看到 channel 的 size 發生了變化:

//output
outer: 0x436100, 1
inner: 0x436100, 1
inner: 0x436100, 2
outer: 0x436100, 2

在 golang 中,對於 channel 有著與 map 類似的結果,其 make() 函式實現原始碼如下:

func makechan(t *chantype, size int) *hchan {
    elem := t.elem
  ...

也就是 make() chan 的返回值為一個 hchan 型別的指標,因此當我們的業務程式碼在函式內對 channel 操作的同時,也會影響到函式外的數值。

與眾不同的 Slice

對於 golang 中 slice 的行為,可以總結一句話:與眾不同。首先,我們來看下 golang 中對於 slice 的 make實現程式碼

func makeslice(et *_type, len, cap int) slice {
  ...

我們發現,與 map 和 channel 不同的是,sclie 的 make 函式返回的是一個內建結構體型別 slice 的物件,而並非一個指標型別,其中內建 slice 的資料結構如下:

type slice struct {
    array unsafe.Pointer
    len   int
    cap   int
}

也就是說,如果採用 slice 在 golang 中傳遞引數,在函式內對 slice 的操作是不應該影響到函式外的。那麼,對於下面的這段示例程式碼,執行的結果又是什麼呢?

//demo5
package main

import "fmt"

func main() {

    sl := []string{
        "a",
        "b",
        "c",
    }

    fmt.Printf("%v, %p\n",sl, sl)
    test_slice(sl)
    fmt.Printf("%v, %p\n",sl, sl)
}


func test_slice(sl []string){
    fmt.Printf("%v, %p\n",sl, sl)
    sl[0] = "aa"
    //sl = append(sl, "d")
    fmt.Printf("%v, %p\n",sl, sl)
}

通過執行結果,我們看到,在函式內部對 slice 中的第一個元素的數值修改成功的返回到了 test_slice() 函式外層!與此同時,通過列印地址,我們發現也顯示了是同一個地址。到了這兒,似乎又一個奇怪的現象出現了:makeslice() 返回的是值型別,但是當該數值作為引數傳遞時,在函式內外的地址卻未發生變化,儼然一副指標型別。

//output
[a b c], 0x442260
[a b c], 0x442260
[aa b c], 0x442260
[aa b c], 0x442260

這時候,我們還是迴歸原始碼,回顧一下上面列出的 golang 內部 slice 結構體的特點。沒錯,細心地讀者可能已經發現,內部 slice 中的第一個元素用來存放資料的結構是個指標型別,一個指向了真正的存放資料的指標!因此,雖然指標拷貝了,但是指標所指向的地址卻未更改,而我們在函式內部修改了指標所指向的地方的內容,從而實現了對元素修改的目的了。

讓我們再進階一下上面的示例,將註釋的那行程式碼開啟:

sl = append(sl, "d")

再重新執行上面的程式碼,得到的結果又有了新的變化:

//output
[a b c], 0x442280
[a b c], 0x442280
[aa b c d], 0x442280
[aa b c], 0x442280

函式內我們修改了 slice 中一個已有元素,同時向 slice 中 append 了另一個元素,結果在函式外部:

  • 修改的元素生效了;
  • append 的元素卻消失了。

其實這就是由於 slice 的結構引起的了。我們都知道 slice 型別在 make() 的時候有個 len 和 cap 的可選引數,在上面的內部 slice 結構中第二和第三個成員變數就是代表著這倆個引數的含義。我們已知原因,資料部分由於是指標型別,這就決定了在函式內部對 slice 資料的修改是可以生效的,因為值傳遞進去的是指向資料的指標。而同一時刻,表示長度的 len 和容量的 cap 均為 int 型別,那麼在傳遞到函式內部的就僅僅只是一個副本,因此在函式內部通過 append 修改了 len 的數值,但卻影響不到函式外部 slice 的 len 變數,從而,append 的影響便無法在函式外部看到了。

解釋到這兒,基本說清了 golang 中 map、channel 和 slice 在函式傳遞時的行為和原因了,但是,喜歡提問的讀者可能一直覺得有哪兒是怪怪的,這個時候我們來完整的整理一下已經的關於 slice 的資訊和行為:

  1. makeslice() 出來的一定是個結構體物件,而不是指標;
  2. 函式內外列印的 slice 地址一致;
  3. 函式體內對 slice 中元素的修改在函式外部生效了;
  4. 函式體內對 slice 進行 append 操作在外部沒有生效;

沒錯了,對於問題 1、3 和 4 我們應該都已經解釋清楚了,但是,關於第 2 點為什麼函式內外對於這三個內建型別變數的地址列印卻是一致的?我們已經更加確定了 golang 中的引數傳遞的確是值型別,那麼,造成這一現象的唯一可能就是出在列印函式 fmt.Printf() 中有些小操作了。因為我們是通過%p 來列印地址資訊的,為此,我們需要關注的是 fmt 包中 fmtPointer():

func (p *pp) fmtPointer(value reflect.Value, verb rune) {
    var u uintptr
    switch value.Kind() {
    case reflect.Chan, reflect.Func, reflect.Map, reflect.Ptr, reflect.Slice, reflect.UnsafePointer:
        u = value.Pointer()
    default:
        p.badVerb(verb)
        return
    }
    ...
}

我們發現在 fmtPointer() 中,對於 map、channel 和 slice,都被當成了指標來處理,通過 Pointer() 函式獲取對應的值的指標。我們知道 channel 和 map 是因為 make 函式返回的就已經是指標了,無可厚非,但是對於 slice 這個非指標,在 value.Pointer() 是如何處理的呢?

// If v's Kind is Slice, the returned pointer is to the first
// element of the slice. If the slice is nil the returned value
// is 0.  If the slice is empty but non-nil the return value is non-zero.
func (v Value) Pointer() uintptr {
    // TODO: deprecate
    k := v.kind()
    switch k {
    case Chan, Map, Ptr, UnsafePointer:
        return uintptr(v.pointer())
    case Func:
        ...
    case Slice:
        return (*SliceHeader)(v.ptr).Data
    }
    ...
}

果不其然,在 Pointer() 函式中,對於 Slice 型別的資料,返回的一直是指向第一個元素的地址,所以我們通過 fmt.Printf() 中%p 來列印 Slice 的地址,其實列印的結果是內部儲存陣列元素的首地址,這也就解釋了問題 2 中為什麼地址會一致的原因了。

總結

通過上述的一系列總結,我們可以很高興的確定的是:在 golang 中的傳參一定是值傳遞了!

然而 golang 隱藏了一些實現細節,在處理 map,channel 和 slice 等這些內建結構的資料時,其實處理的是一個指標型別的資料,也是因此,在函式內部可以修改(部分修改)資料的內容。

但是,這些修改得以實現的原因,是因為資料本身是個指標型別,而不是因為 golang 採用了引用傳遞,注意二者的區別哦~

此文已由作者授權騰訊雲 + 社群在各渠道釋出

獲取更多新鮮技術乾貨,可以關注我們騰訊雲技術社群-雲加社群官方號及知乎機構號

更多原創文章乾貨分享,請關注公眾號
  • 說說不知道的Golang中引數傳遞
  • 加微信實戰群請加微信(註明:實戰群):gocnio

相關文章