Go 學習筆記(65)— Go 中函數參數是傳值(函數參數爲數組、切片、map、chan、struct 等)

Go 語言中,函數參數傳遞採用是值傳遞的方式。所謂“值傳遞”,就是將實際參數在內存中的表示逐位拷貝到形式參數中。對於像整型、數組、結構體這類類型,它們的內存表示就是它們自身的數據內容,因此當這些類型作爲實參類型時,值傳遞拷貝的就是它們自身,傳遞的開銷也與它們自身的大小成正比。

但是像 stringslicemap 這些類型就不是了,它們的內存表示對應的是它們數據內容的“描述符”。當這些類型作爲實參類型時,值傳遞拷貝的也是它們數據內容的“描述符”,不包括數據內容本身,所以這些類型傳遞的開銷是固定的,與數據內容大小無關。這種只拷貝“描述符”,不拷貝實際數據內容的拷貝過程,也被稱爲“淺拷貝”。

不過函數參數的傳遞也有兩個例外,當函數的形參爲接口類型,或者形參是變長參數時,簡單的值傳遞就不能滿足要求了,這時 Go 編譯器會介入:對於類型爲接口類型的形參,Go 編譯器會把傳遞的實參賦值給對應的接口類型形參;對於爲變長參數的形參,Go 編譯器會將零個或多個實參按一定形式轉換爲對應的變長形參。

1. 函數傳參爲數組

package main

import "fmt"

func main() {
	srcArray := [3]string{"a", "b", "c"}
	fmt.Printf("srcArray address is %p\n", &srcArray) //	srcArray address is 0xc00005a150
	modify(srcArray)
	fmt.Printf("srcArray is %v\n", srcArray) //	srcArray is [a b c]

}

func modify(modifyArr [3]string) [3]string {
	fmt.Printf("modifyArr address is %p\n", &modifyArr) //	modifyArr address is 0xc00005a180
	modifyArr[1] = "x"
	fmt.Printf("modifyArr is %v\n", modifyArr) //	modifyArr is [a x c]
	return modifyArr
}

可以看到,函數傳參外面和函數裏面的參數的地址不相同,分別爲 0xc00005a1500xc00005a180 ,所以在函數內修改參數值並不會影響函數外面的原始參數。

所有傳給函數的參數值都會被複制,函數在其內部使用的並不是參數值的原值,而是它的副本。由於數組是值類型,所以每一次複製都會拷貝它,以及它的所有元素值

2. 函數傳參爲切片

package main

import "fmt"

func main() {
	srcSlice := []string{"a", "b", "c"}
	fmt.Printf("srcSlice address is %p\n", srcSlice) //	srcSlice address is 0xc00005a150
	modify(srcSlice)
	fmt.Printf("srcSlice is %v\n", srcSlice) // modifySlice is [a x c]

}

func modify(modifySlice []string) []string {
	fmt.Printf("modifySlice address is %p\n", modifySlice) // modifySlice address is 0xc00005a150
	modifySlice[1] = "x"
	fmt.Printf("modifySlice is %v\n", modifySlice) // srcSlice is [a x c]
	return modifySlice
}

可以看到,函數傳參外面和函數裏面的參數的地址相同,都爲 0xc00005a150,所以在函數內修改參數值會影響到原始參數值。

因爲這裏 srcSlice 本身就是指針地址,所以不需要再用 & 取地址,如果再加上 & 則爲指向指針的指針。

對於引用類型,比如:切片、字典、通道,像上面那樣複製它們的值,只會拷貝它們本身而已,並不會拷貝它們引用的底層數據。也就是說,這時只是淺表複製,而不是深層複製。以切片值爲例,如此複製的時候,只是拷貝了它指向底層數組中某一個元素的指針,以及它的長度值和容量值,而它的底層數組並不會被拷貝。

3. 函數傳參爲字典map

Go 語言中,任何創建 map 的代碼(不管是字面量還是 make 函數)最終調用的都是 runtime.makemap 函數。

小提示:用字面量或者 make 函數的方式創建 map,並轉換成 makemap 函數的調用,這個轉換是 Go 語言編譯器自動幫我們做的。

從下面的代碼可以看到,makemap 函數返回的是一個 *hmap 類型,也就是說返回的是一個指針,所以我們創建的 map 其實就是一個 *hmap

src/runtime/map.go

// makemap implements Go map creation for make(map[k]v, hint).
func makemap(t *maptype, hint int, h *hmap) *hmap{
  //省略無關代碼
}

這也是通過 map 類型的參數可以修改原始數據的原因,因爲它本質上就是個指針。

package main

import "fmt"

func main() {
	srcMap := map[string]int{"a": 1, "b": 2, "c": 3}
	fmt.Printf("srcMap address is %p\n", srcMap) //	srcMap address is 0xc00005a150
	modify(srcMap)
	fmt.Printf("srcMap is %#v\n", srcMap) // srcMap is map[string]int{"a":1, "b":2, "c":100}

}

func modify(modifyMap map[string]int) map[string]int {
	fmt.Printf("modifyMap address is %p\n", modifyMap) // modifyMap address is 0xc00005a150
	modifyMap["c"] = 100
	fmt.Printf("modifyMap is %#v\n", modifyMap) // modifyMap is map[string]int{"a":1, "b":2, "c":100}
	return modifyMap
}

從輸出結果可以看到,它們的內存地址一模一樣,所以纔可以修改原始數據。而且在打印指針的時候,直接使用的是變量 srcMapmodifyMap,並沒有用到取地址符 &,這是因爲它們本來就是指針,所以就沒有必要再使用 & 取地址了。

注意:這裏的 map 可以理解爲引用類型,但是它本質上是個指針,只是可以叫作引用類型而已。在參數傳遞時,它還是值傳遞,並不是其他編程語言中所謂的引用傳遞。

4. 函數傳參爲 channel

channel 也可以理解爲引用類型,而它本質上也是個指針。

通過下面的源代碼可以看到,所創建的 chan 其實是個 *hchan,所以它在參數傳遞中也和 map 一樣。

func makechan(t *chantype, size int64) *hchan {
    //省略無關代碼
}

嚴格來說,Go 語言沒有引用類型,但是我們可以把 mapchan 稱爲引用類型,這樣便於理解。除了 mapchan 之外,Go 語言中的函數、接口、slice 切片都可以稱爲引用類型。指針類型也可以理解爲是一種引用類型。

5. 函數傳參爲 struct

package main

import "fmt"

type Student struct {
	name string
	age  int
}

func main() {
	s := Student{name: "wohu", age: 20}
	fmt.Printf("s address is %p\n", &s) // s address is 0xc00000c060
	modify(s)
	fmt.Printf("s is %v\n", s) // s is {wohu 20}

}

func modify(stu Student) Student {
	fmt.Printf("stu address is %p\n", &stu) // stu address is 0xc00000c080
	stu.age = 30
	fmt.Printf("stu is %v\n", stu) // stu is {wohu 30}
	return stu
}

發現它們的內存地址都不一樣,這就意味着,在 modify 函數中修改的參數 stumain 函數中的變量 stu 不是同一個,這也是我們在 modify 函數中修改參數 stu,但是在 main 函數中打印後發現並沒有修改的原因。

導致這種結果的原因是 Go 語言中的函數傳參都是值傳遞。 值傳遞指的是傳遞原來數據的一份拷貝,而不是原來的數據本身。

modify 函數來說,在調用 modify 函數傳遞變量 stu 的時候,Go 語言會拷貝一個 stu 放在一個新的內存中,這樣新的 p 的內存地址就和原來不一樣了,但是裏面的 nameage 是一樣的,還是 wohu和 20。這就是副本的意思,變量裏的數據一樣,但是存放的內存地址不一樣。

除了 struct 外,還有浮點型、整型、字符串、布爾、數組,這些都是值類型。

指針類型的變量保存的值就是數據對應的內存地址,所以在函數參數傳遞是傳值的原則下,拷貝的值也是內存地址。現在對以上示例稍做修改,修改後的代碼如下:

package main

import "fmt"

type Student struct {
	name string
	age  int
}

func main() {
	s := Student{name: "wohu", age: 20}
	fmt.Printf("s address is %p\n", &s) // s address is 0xc00000c060
	modify(&s)
	fmt.Printf("s is %v\n", s) // s is {wohu 30}
}

func modify(stu *Student) *Student {
	fmt.Printf("stu address is %p\n", stu) // stu address is 0xc00000c060
	stu.age = 30
	fmt.Printf("stu is %v\n", *stu) // stu is &{wohu 30}
	return stu
}

所以指針類型的參數是永遠可以修改原數據的,因爲在參數傳遞時,傳遞的是內存地址。

注意:值傳遞的是指針,也是內存地址。通過內存地址可以找到原數據的那塊內存,所以修改它也就等於修改了原數據。

定義的普通變量 stustudent 類型的。在 Go 語言中,student 是一個值類型,而 &stu 獲取的指針是 *student 類型的,即指針類型。

總結:在 Go 語言中,函數的參數傳遞只有值傳遞,而且傳遞的實參都是原始數據的一份拷貝。如果拷貝的內容是值類型的,那麼在函數中就無法修改原始數據;如果拷貝的內容是指針(或者可以理解爲引用類型 mapchan 等),那麼就可以在函數中修改原始數據。

6. 其它示例

直接上代碼


package main

import "fmt"

func main() {
	// 示例1。
	array1 := [3]string{"a", "b", "c"}
	fmt.Printf("The array: %v\n", array1)
	array2 := modifyArray(array1)
	fmt.Printf("The modified array: %v\n", array2)
	fmt.Printf("The original array: %v\n", array1)
	fmt.Println()

	// 示例2。
	slice1 := []string{"x", "y", "z"}
	fmt.Printf("The slice: %v\n", slice1)
	slice2 := modifySlice(slice1)
	fmt.Printf("The modified slice: %v\n", slice2)
	fmt.Printf("The original slice: %v\n", slice1)
	fmt.Println()

	// 示例3。
	complexArray1 := [3][]string{
		[]string{"d", "e", "f"},
		[]string{"g", "h", "i"},
		[]string{"j", "k", "l"},
	}
	fmt.Printf("The complex array: %v\n", complexArray1)
	complexArray2 := modifyComplexArray(complexArray1)
	fmt.Printf("The modified complex array: %v\n", complexArray2)
	fmt.Printf("The original complex array: %v\n", complexArray1)
}

// 示例1。
func modifyArray(a [3]string) [3]string {
	a[1] = "x"
	return a
}

// 示例2。
func modifySlice(a []string) []string {
	a[1] = "i"
	return a
}

// 示例3。
func modifyComplexArray(a [3][]string) [3][]string {
	a[1][1] = "s"
	a[2] = []string{"o", "p", "q"}
	return a
}
  • 如果是進行一層修改,即數組的某個完整元素進行修改(指針變化),那麼原有數組不變;
  • 如果是進行二層修改,即數組中某個元素切片內的某個元素再進行修改(指針未改變),那麼原有數據也會跟着改變,傳參可以理解是淺copy,參數本身的指針是不同,但是元素指針相同,對元素指針所指向目的的操作會影響傳參過程中的原始數據;

7. 函數傳參爲地址

當變量被當做參數傳入調用函數時,是值傳遞,也稱變量的一個拷貝傳遞。如果傳遞過來的值是指針,就相當於把變量的地址作爲參數傳遞到函數內,那麼在函數內對這個指針所指向的內容進行修改,將會改變這個變量的值。如下邊示例代碼:

package main
import (
    "fmt"
)
func demo(str *string) {
    *str = "world"
}
func main() {
    var str = "hello"
    demo(&str)
    fmt.Println("str value is:", str)
}

輸出結果:

str value is: world

從上邊的輸出信息可知,str 變量地址當做參數傳入函數後,在函數中對地址所指向內容進行了修改,導致了變量 str 值發生了變化。這個過程能否說明函數調用傳遞的是指針,而不是變量的拷貝呢?下邊通過另一個例子來進行說明:

package main
import (
    "fmt"
)
var world = "hello wolrd"
func demo(str *string) {
    str = &world
    fmt.Println("str in demo func is:", *str)
}
func main() {
    var str = "hello"
    demo(&str)
    fmt.Println("str in main func is:", str)
}

輸出結果:

str in demo func is: hello wolrd
str in main func is: hello

上邊示例中,str 變量地址被作爲參數傳入到了函數 demo 中,在函數中對參數進行重新賦值,將 world 變量地址賦值給了參數,函數調用結束後,重新打印變量 str 值,發現值沒有被修改。

所以,在函數調用中,變量被拷貝了一份傳入函數,函數調用結束後,拷貝的值被丟棄。

如果拷貝的是變量的地址,那麼在函數內,其實是通過修改這個地址所指向內存中內容,從而達到修改變量值的目的,但是函數內並不能修改這個變量的地址,也就是 str 變量雖然將地址當做參數傳入到 demo 函數中,demo 函數中雖然對這個地址進行了修改,但是在函數調用結束後,拷貝傳遞進去並被修改的參數被丟棄,str 變量地址未發生變化。

8. 綜合示例

package main

import "fmt"

// 用於測試值傳遞效果的結構體,結構體是擁有多個字段的複雜結構。
type Data struct {
	complax  []int      // complax 爲整型切片類型,切片是一種動態類型,內部以指針存在。
	instance InnerData  // instance 成員以 InnerData 類型作爲 Data 的成員 。
	ptr      *InnerData // 將 ptr 聲明爲 InnerData 的指針類型
}

// 代表各種結構體字段
type InnerData struct {
	a int
}

// 值傳遞測試函數,該函數的參數和返回值都是 Data 類型。
// 在調用中, Data 的內存會被複制後傳入函數,當函數返回時,又會將返回值複製一次,
// 賦給函數返回值的接收變量。
func passByValue(inFunc Data) Data {
	// 輸出參數的成員情況
	fmt.Printf("inFunc value: %+v\n", inFunc)
	// 打印inFunc的指針
	fmt.Printf("inFunc ptr: %p\n", &inFunc)
	// 將傳入的變量作爲返回值返回,返回的過程將發生值複製。
	return inFunc
}

func main() {
	// 準備傳入函數的結構
	in := Data{
		complax: []int{1, 2, 3},
		instance: InnerData{
			5,
		},
		ptr: &InnerData{1},
	}
	// 輸入結構的成員情況
	fmt.Printf("in value: %+v\n", in)
	// 輸入結構的指針地址
	fmt.Printf("in ptr: %p\n", &in)
	// 傳入結構體,返回同類型的結構體
	out := passByValue(in)
	// 輸出結構的成員情況
	fmt.Printf("out value: %+v\n", out)
	// 輸出結構的指針地址
	fmt.Printf("out ptr: %p\n", &out)
}

輸出結果:

in value: {complax:[1 2 3] instance:{a:5} ptr:0xc0000180e8}
in ptr: 0xc000078150
inFunc value: {complax:[1 2 3] instance:{a:5} ptr:0xc0000180e8}
inFunc ptr: 0xc0000781e0
out value: {complax:[1 2 3] instance:{a:5} ptr:0xc0000180e8}
out ptr: 0xc0000781b0

從運行結果中發現:

  • 所有的 Data 結構的指針地址發生了變化,意味着所有的結構都是一塊新的內存,無論是將 Data 結構傳入函數內部,還是通過函數返回值傳回 Data 都會發生複製行爲 。
  • 所有的 Data 結構中的成員值都沒有發生變化,原樣傳遞,意味着所有參數都是值傳遞。
  • Data 結構的 ptr 成員在傳遞過程中保持 一致,表示指針在函數參數值傳遞中傳遞的只是指針值,不會複製指針指向的部分。

參考:
https://juejin.cn/post/6844903618890432520
https://www.zhihu.com/question/312356800/answer/739572672
https://segmentfault.com/q/1010000019965306/a-1020000019996800
https://www.flysnow.org/2018/02/24/golang-function-parameters-passed-by-value.html

https://blog.csdn.net/wohu1104/article/details/109661126

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