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

本文由雲+社區發表

導言

幾乎每一個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採用了引用傳遞,注意二者的區別哦~

此文已由作者授權騰訊雲+社區在各渠道發佈

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

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章