Go参数传递

导语

在C++中,函数的参数传递有两种,值传递和引用传递,值传递中形参的修改不会导致实参的修改,而引用传递中修改形参则会同时改变实参的值。Go的参数传递方式则与C++有比较大的区别,需要深入理解其原理,否则容易踩进坑里。

传递方式

首先需要明确的一点是,Go中的参数传递都是值传递,即在传递参数时形参是对实参的拷贝,修改形参的值不会改变实参。但是在实际使用中,Go的类型为slice和map等的变量在参数传递中的值却可以被“修改”,这是为什么呢?这就需要我们根据Go中基本数据类型的实现逐个理解。

简单数据类型

对于简单数据类型,如int,string以及指针类型等,我们很容易理解函数的形参是实参的拷贝。请看下面的例子:

1
2
3
4
5
6
7
8
9
10
11
12
func update(inside int) {
inside = 2
fmt.Printf("inside value = %v\n", inside)
fmt.Printf("inside address = %p\n", &inside)
}

func main() {
outside := 1
update(outside)
fmt.Printf("outside value = %v\n", outside)
fmt.Printf("outside address = %p\n", &outside)
}

运行结果为

1
2
3
4
inside value = 2
inside address = 0xc000100018
outside value = 1
outside address = 0xc000100010

很容易看出实参outside的地址和形参inside的地址不同,所以outside的值没有改变。

稍有不同的是指针类型,运行下面的代码

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
func update(inside *int) {
*inside = 2
fmt.Printf("inside value = %v\n", inside)
fmt.Printf("inside pointed value = %v\n", *inside)
fmt.Printf("inside address = %p\n", &inside)
}

func main() {
num := 1
outside := &num
update(outside)
fmt.Printf("outside value = %v\n", outside)
fmt.Printf("outside pointed value = %v\n", *outside)
fmt.Printf("outside address = %p\n", &outside)
}

结果为

1
2
3
4
5
6
inside value = 0xc000100010
inside pointed value = 2
inside address = 0xc000102020
outside value = 0xc000100010
outside pointed value = 2
outside address = 0xc000102018

可以发现inside依然是outside的拷贝,即inside和outside指向的是同一块内存空间,所以修改inside指向的值,outside指向的值也会一起改变。

数组类型

数组元素作为函数参数时,与简单类型一样,会把整个数组拷贝一份。

1
2
3
4
5
6
7
8
9
10
11
12
func update(inside [3]int) {
inside[2] = 4
fmt.Printf("inside value = %v\n", inside)
fmt.Printf("inside address = %p\n", &inside)
}

func main() {
outside := [3]int{1, 2, 3}
update(outside)
fmt.Printf("outside value = %v\n", outside)
fmt.Printf("outside address = %p\n", &outside)
}

以上代码运行结果为

1
2
3
4
inside value = [1 2 4]
inside address = 0xc000014040
outside value = [1 2 3]
outside address = 0xc000014020

可以看到数组outside的起始地址和inside的起始地址不同,即inside是outside的拷贝,修改inside的元素不会改变outside对应的元素。

切片类型

切片类型slice是Go语言特有的类型,可以很方便的操作数组。我们首先来看slice的底层结构

1
2
3
4
5
6
// runtime/slice.go
type slice struct {
array unsafe.Pointer // 指针
len int // 长度
cap int // 容量
}

可以看到slice本质上是一个结构体,其中array是其底层数组的地址,len是slice可访问该数组的长度,cap是数组的长度。现在我们使用slice来进行传参:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
func update(inside []int) {
inside[2] = 4
fmt.Printf("inside value = %v\n", inside)
fmt.Printf("inside address = %p\n", &inside)
fmt.Printf("inside first element address = %p\n", &inside[0])
}

func main() {
outside := []int{1, 2, 3}
update(outside)
fmt.Printf("outside value = %v\n", outside)
fmt.Printf("outside address = %p\n", &outside)
fmt.Printf("outside first element address = %p\n", &outside[0])
}

结果为

1
2
3
4
5
6
inside value = [1 2 4]
inside address = 0xc00000c0c0
inside first element address = 0xc000014020
outside value = [1 2 4]
outside address = 0xc00000c0a0
outside first element address = 0xc000014020

可以看到inside的地址和outside不同,而inside第一个元素的地址和outside第一个元素的地址相同,这是因为inside是outside的拷贝,即inside中array指针的值和outside相同。虽然修改inside的元素后outside的值也被修改了,但是实际上修改的是inside和outside指向的数组,inside和outside的值并没有被修改。

再看另一种情况,当slice的长度发生变化时:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
func update(inside []int) {
inside = append(inside, 4)
fmt.Printf("inside value = %v\n", inside)
fmt.Printf("inside address = %p\n", &inside)
fmt.Printf("inside first element address = %p\n", &inside[0])
}

func main() {
outside := []int{1, 2, 3}
update(outside)
fmt.Printf("outside value = %v\n", outside)
fmt.Printf("outside address = %p\n", &outside)
fmt.Printf("outside first element address = %p\n", &outside[0])
}

结果为

1
2
3
4
5
6
inside value = [1 2 3 4]
inside address = 0xc0000ae060
inside first element address = 0xc0000b2030
outside value = [1 2 3]
outside address = 0xc0000ae040
outside first element address = 0xc0000be000

这时候看起来会很迷惑,outside的值并没有随着inside一起改变,这又是什么原因呢?因为slice在扩容的时候会把原来的底层数组拷贝一份,将array指针指向这个新的数组,所以扩容后inside元素的地址和outside不再相同,修改inside的元素也就不会影响outside的元素了。

map和chan类型

以map为例,先看下面的代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
func update(inside map[int64]string) {
inside[1] = "world"
fmt.Printf("inside value = %v\n", inside)
fmt.Printf("inside address = %p\n", &inside)
}

func main() {
outside := make(map[int64]string, 0)
outside[1] = "hello"
update(outside)
fmt.Printf("outside value = %v\n", outside)
fmt.Printf("outside address = %p\n", &outside)
}

运行结果为

1
2
3
4
inside value = map[1:world]
inside address = 0xc000082020
outside value = map[1:world]
outside address = 0xc000082018

可以看到inside[1]修改后,outside[1]的值也被修改了,但是inside和outside的地址却不一样,说明还是值传递,而inside和outside很有可能是指针。

我们查看map的make函数源码

1
2
3
func makemap(t *maptype, hint int64, h *hmap, bucket unsafe.Pointer) *hmap {
// ...
}

可以看出make(map[int64]string, 0)返回的确实是hmap类型的指针,func update(inside map[int64]string)其实就是func update(inside *hmap),这样就可以理解map类型的变量作为函数参数传递时为何实参的值也会被修改了。

同样的,chan类型与map相似,其make函数也是返回一个指针,源码如下:

1
2
3
func makechan(t *chantype, size int64) *hchan {
// ...
}

所以chan在函数参数传递中与map有类似的表现,这里不再赘述。

结构体

对于结构体struct作为参数传递的情况,我们来看下面的例子:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
func update(inside Person) {
inside.Card.Id = "456"
inside.Age = 20
fmt.Printf("inside Card value = %v[address=%p]\n", inside.Card, inside.Card)
fmt.Printf("inside Age value = %v\n", inside.Age)
fmt.Printf("inside address = %p\n", &inside)
}

func main() {
outside := Person {
Card: &IdCard{
Id: "123",
Time: "2020-01-01",
},
Age: 18,
}
update(outside)
fmt.Printf("outside Card value = %v[address=%p]\n", outside.Card, outside.Card)
fmt.Printf("outside Age value = %v\n", outside.Age)
fmt.Printf("outside address = %p\n", &outside)
}

运行结果为

1
2
3
4
5
6
inside Card value = &{456 2020-01-01}[address=0xc00009e000]
inside Age value = 20
inside address = 0xc00009c050
outside Card value = &{456 2020-01-01}[address=0xc00009e000]
outside Age value = 18
outside address = 0xc00009c040

可以看出struct类型也是值传递,即inside是将outside值拷贝一份,保存在新的内存地址。在拷贝过程中,指针Card只是拷贝了指针的值而没有分配新的内存空间,即发生的是浅拷贝,因此inside.Card和outside.Card指向的是同一块内存空间,所以修改inside.Card的值同时也修改了。

小结

最终我们可以确认的是Go语言中所有的传参都是值传递,形参都是实参一个拷贝。当拷贝的内容是非引用类型(int、string、struct等),函数中无法修改原内容数据,需要注意是struct的拷贝是浅拷贝;当拷贝的内容是引用类型(指针、map、slice、chan等),就可以修改原内容数据。

是否可以修改原内容数据,和传值、传引用没有必然的关系。在C++中,传引用肯定是可以修改原内容数据的,在Go语言里,虽然只有传值,但是我们也可以修改原内容数据,因为参数是引用类型。


Go参数传递
http://yunan.xyz/2022/06/16/Go参数传递/
作者
yunan
发布于
2022年6月16日
许可协议