go語言引數傳遞到底是傳值還是傳引用

Sunshine-鬆發表於2020-11-08

前言

哈嘍,大家好,我是asong。今天女朋友問我,小松子,你知道Go語言引數傳遞是傳值還是傳引用嗎?哎呀哈,我竟然被瞧不起了,我立馬一頓操作,給他講的明明白白的,小丫頭片子,還是太嫩,大家且聽我細細道來~~~。​

實參與形引數

我們使用go定義方法時是可以定義引數的。比如如下方法:

func printNumber(args ...int)

這裡的args就是引數。引數在程式語言中分為形式引數和實際引數。

形式引數:是在定義函式名和函式體的時候使用的引數,目的是用來接收呼叫該函式時傳入的引數。

實際引數:在呼叫有參函式時,主調函式和被調函式之間有資料傳遞關係。在主調函式中呼叫一個函式時,函式名後面括號中的引數稱為“實際引數”。

舉例如下:

func main()  {
 var args int64= 1
 printNumber(args)  // args就是實際引數
}

func printNumber(args ...int64)  { //這裡定義的args就是形式引數
	for _,arg := range args{
		fmt.Println(arg) 
	}
}

什麼是值傳遞

值傳遞,我們分析其字面意思:傳遞的就是值。傳值的意思是:函式傳遞的總是原來這個東西的一個副本,一副拷貝。比如我們傳遞一個int型別的引數,傳遞的其實是這個引數的一個副本;傳遞一個指標型別的引數,其實傳遞的是這個該指標的一份拷貝,而不是這個指標指向的值。我們畫個圖來解釋一下:

什麼是引用傳遞

學習過其他語言的同學,對這個引用傳遞應該很熟悉,比如C++使用者,在C++中,函式引數的傳遞方式有引用傳遞。所謂引用傳遞是指在呼叫函式時將實際引數的地址傳遞到函式中,那麼在函式中對引數所進行的修改,將影響到實際引數。

golang是值傳遞

我們先寫一個簡單的例子驗證一下:

func main()  {
 var args int64= 1
 modifiedNumber(args) // args就是實際引數
 fmt.Printf("實際引數的地址 %p\n", &args)
 fmt.Printf("改動後的值是  %d\n",args)
}

func modifiedNumber(args int64)  { //這裡定義的args就是形式引數
	fmt.Printf("形參地址 %p \n",&args)
	args = 10
}

執行結果:

形參地址 0xc0000b4010 
實際引數的地址 0xc0000b4008
改動後的值是  1

這裡正好驗證了go是值傳遞,但是還不能完全確定go就只有值傳遞,我們在寫一個例子驗證一下:

func main()  {
 var args int64= 1
 addr := &args
 fmt.Printf("原始指標的記憶體地址是 %p\n", addr)
 fmt.Printf("指標變數addr存放的地址 %p\n", &addr)
 modifiedNumber(addr) // args就是實際引數
 fmt.Printf("改動後的值是  %d\n",args)
}

func modifiedNumber(addr *int64)  { //這裡定義的args就是形式引數
	fmt.Printf("形參地址 %p \n",&addr)
	*addr = 10
}

執行結果:

原始指標的記憶體地址是 0xc0000b4008
指標變數addr存放的地址 0xc0000ae018
形參地址 0xc0000ae028 
改動後的值是  10

所以通過輸出我們可以看到,這是一個指標的拷貝,因為存放這兩個指標的記憶體地址是不同的,雖然指標的值相同,但是是兩個不同的指標。

通過上面的圖,我們可以更好的理解。我們宣告瞭一個變數args,其值為1,並且他的記憶體存放地址是0xc0000b4008,通過這個地址,我們就可以找到變數args,這個地址也就是變數args的指標addr。指標addr也是一個指標型別的變數,它也需要記憶體存放它,它的記憶體地址是多少呢?是0xc0000ae018。 在我們傳遞指標變數addrmodifiedNumber函式的時候,是該指標變數的拷貝,所以新拷貝的指標變數addr,它的記憶體地址已經變了,是新的0xc0000ae028。所以,不管是0xc0000ae018還是0xc0000ae028,我們都可以稱之為指標的指標,他們指向同一個指標0xc0000b4008,這個0xc0000b4008又指向變數args,這也就是為什麼我們可以修改變數args的值。

通過上面的分析,我們就可以確定go就是值傳遞,因為我們在modifieNumber方法中列印出來的記憶體地址發生了改變,所以不是引用傳遞,實錘了奧兄弟們,證據確鑿~~~。等等,好像好落下了點什麼,說好的go中只有值傳遞呢,為什麼chanmapslice型別傳遞卻可以改變其中的值呢?白著急,我們依次來驗證一下。

slice也是值傳遞嗎?

先看一段程式碼:

func main()  {
 var args =  []int64{1,2,3}
 fmt.Printf("切片args的地址: %p\n",args)
 modifiedNumber(args)
 fmt.Println(args)
}

func modifiedNumber(args []int64)  {
	fmt.Printf("形參切片的地址 %p \n",args)
	args[0] = 10
}

執行結果:

切片args的地址: 0xc0000b8000
形參切片的地址 0xc0000b8000 
[10 2 3]

哇去,怎麼回事,光速打臉呢,這怎麼地址都是一樣的呢?並且值還被修改了呢?怎麼回事,作何解釋,你個渣男,欺騙我感情。。。不好意思走錯片場了。繼續來看這個問題。這裡我們沒有使用&符號取地址符轉換,就把slice地址列印出來了,我們在加上一行程式碼測試一下:

func main()  {
 var args =  []int64{1,2,3}
 fmt.Printf("切片args的地址: %p \n",args)
 fmt.Printf("切片args第一個元素的地址: %p \n",&args[0])
 fmt.Printf("直接對切片args取地址%v \n",&args)
 modifiedNumber(args)
 fmt.Println(args)
}

func modifiedNumber(args []int64)  {
	fmt.Printf("形參切片的地址 %p \n",args)
	fmt.Printf("形參切片args第一個元素的地址: %p \n",&args[0])
	fmt.Printf("直接對形參切片args取地址%v \n",&args)
	args[0] = 10
}

執行結果:

切片args的地址: 0xc000016140 
切片args第一個元素的地址: 0xc000016140 
直接對切片args取地址&[1 2 3] 
形參切片的地址 0xc000016140 
形參切片args第一個元素的地址: 0xc000016140 
直接對形參切片args取地址&[1 2 3] 
[10 2 3]

通過這個例子我們可以看到,使用&操作符表示slice的地址是無效的,而且使用%p輸出的記憶體地址與slice的第一個元素的地址是一樣的,那麼為什麼會出現這樣的情況呢?會不會是fmt.Printf函式做了什麼特殊處理?我們來看一下其原始碼:

fmt包,print.go中的printValue這個方法,擷取重點部分,因為`slice`也是引用型別,所以會進入這個`case`case reflect.Ptr:
		// pointer to array or slice or struct? ok at top level
		// but not embedded (avoid loops)
		if depth == 0 && f.Pointer() != 0 {
			switch a := f.Elem(); a.Kind() {
			case reflect.Array, reflect.Slice, reflect.Struct, reflect.Map:
				p.buf.writeByte('&')
				p.printValue(a, verb, depth+1)
				return
			}
		}
		fallthrough
	case reflect.Chan, reflect.Func, reflect.UnsafePointer:
		p.fmtPointer(f, verb)

p.buf.writeByte('&')這行程式碼就是為什麼我們使用&列印地址輸出結果前面帶有&的語音。因為我們要列印的是一個slice型別,就會呼叫p.printValue(a, verb, depth+1)遞迴獲取切片中的內容,為什麼列印出來的切片中還會有[]包圍呢,我來看一下printValue這個方法的原始碼:

case reflect.Array, reflect.Slice:
//省略部分程式碼
} else {
			p.buf.writeByte('[')
			for i := 0; i < f.Len(); i++ {
				if i > 0 {
					p.buf.writeByte(' ')
				}
				p.printValue(f.Index(i), verb, depth+1)
			}
			p.buf.writeByte(']')
		}

這就是上面fmt.Printf("直接對切片args取地址%v \n",&args)輸出直接對切片args取地址&[1 2 3]的原因。這個問題解決了,我們再來看一看使用%p輸出的記憶體地址與slice的第一個元素的地址是一樣的。在上面的原始碼中,有這樣一行程式碼fallthrough,代表著接下來的fmt.Poniter也會被執行,我看一下其原始碼:

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
	}
...... 省略部分程式碼
// 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:
		if v.flag&flagMethod != 0 {
 ....... 省略部分程式碼

這裡我們可以看到上面有這樣一句註釋:If v’s Kind is Slice, the returned pointer is to the first。翻譯成中文就是如果是slice型別,返回slice這個結構裡的第一個元素的地址。這裡正好解釋上面為什麼fmt.Printf("切片args的地址: %p \n",args)fmt.Printf("形參切片的地址 %p \n",args)列印出來的地址是一樣的,因為args是引用型別,所以他們都返回slice這個結構裡的第一個元素的地址,為什麼這兩個slice結構裡的第一個元素的地址一樣呢,這就要在說一說slice的底層結構了。

我們看一下slice底層結構:

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

slice是一個結構體,他的第一個元素是一個指標型別,這個指標指向的是底層陣列的第一個元素。所以當是slice型別的時候,fmt.Printf返回是slice這個結構體裡第一個元素的地址。說到底,又轉變成了指標處理,只不過這個指標是slice中第一個元素的記憶體地址。

說了這麼多,最後再做一個總結吧,為什麼slice也是值傳遞。之所以對於引用型別的傳遞可以修改原內容的資料,這是因為在底層預設使用該引用型別的指標進行傳遞,但也是使用指標的副本,依舊是值傳遞。所以slice傳遞的就是第一個元素的指標的副本,因為fmt.printf緣故造成了列印的地址一樣,給人一種混淆的感覺。

map也是值傳遞嗎?

mapslice一樣都具有迷惑行為,哼,渣女。map我們可以通過方法修改它的內容,並且它沒有明顯的指標。比如這個例子:

func main()  {
	persons:=make(map[string]int)
	persons["asong"]=8

	addr:=&persons

	fmt.Printf("原始map的記憶體地址是:%p\n",addr)
	modifiedAge(persons)
	fmt.Println("map值被修改了,新值為:",persons)
}

func modifiedAge(person map[string]int)  {
	fmt.Printf("函式裡接收到map的記憶體地址是:%p\n",&person)
	person["asong"]=9
}

看一眼執行結果:

原始map的記憶體地址是:0xc00000e028
函式裡接收到map的記憶體地址是:0xc00000e038
map值被修改了,新值為: map[asong:9]

先喵一眼,哎呀,實參與形參地址不一樣,應該是值傳遞無疑了,等等。。。。map值怎麼被修改了?一臉疑惑。。。。。

為了解決我們的疑惑,我們從原始碼入手,看一看什麼原理:

//src/runtime/map.go
// 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 {
	mem, overflow := math.MulUintptr(uintptr(hint), t.bucket.size)
	if overflow || mem > maxAlloc {
		hint = 0
	}

	// initialize Hmap
	if h == nil {
		h = new(hmap)
	}
	h.hash0 = fastrand()

從以上原始碼,我們可以看出,使用make函式返回的是一個hmap型別的指標*hmap。回到上面那個例子,我們的func modifiedAge(person map[string]int)函式,其實就等於func modifiedAge(person *hmap),實際上在作為傳遞引數時還是使用了指標的副本進行傳遞,屬於值傳遞。在這裡,Go語言通過make函式,字面量的包裝,為我們省去了指標的操作,讓我們可以更容易的使用map。這裡的map可以理解為引用型別,但是記住引用型別不是傳引用。

chan是值傳遞嗎?

老樣子,先看一個例子:

func main()  {
	p:=make(chan bool)
	fmt.Printf("原始chan的記憶體地址是:%p\n",&p)
	go func(p chan bool){
		fmt.Printf("函式裡接收到chan的記憶體地址是:%p\n",&p)
		//模擬耗時
		time.Sleep(2*time.Second)
		p<-true
	}(p)

	select {
	case l := <- p:
		fmt.Println(l)
	}
}

再看一看執行結果:

原始chan的記憶體地址是:0xc00000e028
函式裡接收到chan的記憶體地址是:0xc00000e038
true

這個怎麼回事,實參與形參地址不一樣,但是這個值是怎麼傳回來的,說好的值傳遞呢?白著急,鐵子,我們像分析map那樣,再來分析一下chan。首先看原始碼:

// src/runtime/chan.go
func makechan(t *chantype, size int) *hchan {
	elem := t.elem

	// compiler checks this but be safe.
	if elem.size >= 1<<16 {
		throw("makechan: invalid channel element type")
	}
	if hchanSize%maxAlign != 0 || elem.align > maxAlign {
		throw("makechan: bad alignment")
	}

	mem, overflow := math.MulUintptr(elem.size, uintptr(size))
	if overflow || mem > maxAlloc-hchanSize || size < 0 {
		panic(plainError("makechan: size out of range"))
	}

從以上原始碼,我們可以看出,使用make函式返回的是一個hchan型別的指標*hchan。這不是與map一個道理嘛,再次回到上面的例子,實際我們的fun (p chan bool)fun (p *hchan)是一樣的,實際上在作為傳遞引數時還是使用了指標的副本進行傳遞,屬於值傳遞。

是不是到這裡,基本就可以確定go就是值傳遞了呢?還剩最後一個沒有測試,那就是struct,我們最後來驗證一下struct

struct就是值傳遞

沒錯,我先說答案,struct就是值傳遞,不信你看這個例子:

func main()  {
	per := Person{
		Name: "asong",
		Age: int64(8),
	}
	fmt.Printf("原始struct地址是:%p\n",&per)
	modifiedAge(per)
	fmt.Println(per)
}

func modifiedAge(per Person)  {
	fmt.Printf("函式裡接收到struct的記憶體地址是:%p\n",&per)
	per.Age = 10
}

我們發現,我們自己定義的Person型別,在函式傳參的時候也是值傳遞,但是它的值(Age欄位)並沒有被修改,我們想改成10,發現最後的結果還是8

前文總結

兄弟們實錘了奧,go就是值傳遞,可以確認的是Go語言中所有的傳參都是值傳遞(傳值),都是一個副本,一個拷貝。因為拷貝的內容有時候是非引用型別(int、string、struct等這些),這樣就在函式中就無法修改原內容資料;有的是引用型別(指標、map、slice、chan等這些),這樣就可以修改原內容資料。

是否可以修改原內容資料,和傳值、傳引用沒有必然的關係。在C++中,傳引用肯定是可以修改原內容資料的,在Go語言裡,雖然只有傳值,但是我們也可以修改原內容資料,因為引數是引用型別。

有的小夥伴會在這裡還是懵逼,因為你把引用型別和傳引用當成一個概念了,這是兩個概念,切記!!!

出個題考驗你們一下

歡迎在評論區留下你的答案~~~

既然你們都知道了golang只有值傳遞,那麼這段程式碼來幫我分析一下吧,這裡的值能修改成功,為什麼使用append不會發生擴容?

func main() {
	array := []int{7,8,9}
	fmt.Printf("main ap brfore: len: %d cap:%d data:%+v\n", len(array), cap(array), array)
	ap(array)
	fmt.Printf("main ap after: len: %d cap:%d data:%+v\n", len(array), cap(array), array)
}

func ap(array []int) {
	fmt.Printf("ap brfore:  len: %d cap:%d data:%+v\n", len(array), cap(array), array)
  array[0] = 1
	array = append(array, 10)
	fmt.Printf("ap after:   len: %d cap:%d data:%+v\n", len(array), cap(array), array)
}

後記

好啦,這一篇文章到這就結束了,我們下期見~~。希望對你們有用,又不對的地方歡迎指出,可新增我的golang交流群,我們一起學習交流。

結尾給大家發一個小福利吧,最近我在看[微服務架構設計模式]這一本書,講的很好,自己也收集了一本PDF,有需要的小夥可以到自行下載。獲取方式:關注公眾號:[Golang夢工廠],後臺回覆:[微服務],即可獲取。

我翻譯了一份GIN中文文件,會定期進行維護,有需要的小夥伴後臺回覆[gin]即可下載。

翻譯了一份Machinery中文文件,會定期進行維護,有需要的小夥伴們後臺回覆[machinery]即可獲取。

我是asong,一名普普通通的程式猿,讓gi我一起慢慢變強吧。我自己建了一個golang交流群,有需要的小夥伴加我vx,我拉你入群。歡迎各位的關注,我們下期見~~~

推薦往期文章:

相關文章