Go的函数传递详解,一篇文章搞明白Golang的值传递。

继上篇文章后,继续来探讨下面的几个问题:

  1. 函数传参中值传递、指针传递与引用传递到底有什么不一样?
  2. 为什么说 slicemapchannel 是引用类型?
  3. Go中 slice 在传入函数时到底是不是引用传递?如果不是,在函数内为什么能修改其值?

In a function call, the function value and arguments are evaluated in the usual order. After they are evaluated, the parameters of the call are passed by value to the function and the called function begins execution.
文档地址:https://golang.org/ref/spec#Calls

官方文档已经明确说明:Go里边函数传参只有值传递一种方式,为了加强自己的理解,再来把每种传参方式进行一次梳理。

值传递

值传递是指在调用函数时将实际参数复制一份传递到函数中,这样在函数中如果对参数进行修改,将不会影响到实际参数。

概念总给人一种教科书的感觉,写点代码验证下。

1
2
3
4
5
6
7
8
9
func main() {
a := 10
fmt.Printf("%#v\n", &a) // (*int)(0xc420018080)
vFoo(a)
}

func vFoo(b int) {
fmt.Printf("%#v\n", &b) // (*int)(0xc420018090)
}

注释内容是我机器的输出,你如果运行会得到不一样的输出

根据代码来解释下,所谓的值传递就是:实参 a 在传递给函数 vFoo 的形参 b 后,在 vFoo 的内部,b 会被当作局部变量在栈上分配空间,并且完全拷贝 a 的值。

代码执行后,我们看到的结果便是:a、b拥有完全不同的内存地址, 说明他们虽然值相同(b拷贝的a,值肯定一样),但是分别在内存中不同的地方,也因此在函数 vFoo 内部如果改变 b 的值,a 是不会受到影响的。

funcCall

图中左侧是还未调用时,内存的分配,右侧是调用函数后内存分别分配的变量。这里需要注意,就算vFoo的参数名字是a,实参与形参也分别有自己的内存空间,因为参数的名字仅仅是给程序员看的,上篇文章已经说清楚了。

指针传递

形参为指向实参地址的指针,当对形参的指向操作时,就相当于对实参本身进行的操作。

是不是云里雾里的?还是通过代码结合来分析所谓的指针传递。

1
2
3
4
5
6
7
8
9
10
11
12
func main() {
a := 10
pa := &a
fmt.Printf("value: %#v\n", pa) // value: (*int)(0xc420080008)
fmt.Printf("addr: %#v\n", &pa) // addr: (**int)(0xc420088018)
pFoo(pa)
}

func pFoo(p * int) {
fmt.Printf("value: %#v\n", p) // value: (*int)(0xc420080008)
fmt.Printf("addr: %#v\n", &p) // addr: (**int)(0xc420088028)
}

定义了一个变量 a,并把地址保存在指针变量 pa 里边了。按照我们定的结论,Go中只有值传递,那么指针变量pa传给函数的形参p后,形参将会是它在栈上的一份拷贝,他们本身将各自拥有不同的地址,但是二者的值是一样的(都是变量a的地址)。上面的注释部分是我程序运行后的结果,pa 与 p 的地址各自互不相关,说明在参数传递中发生了值拷贝。

在函数 pFoo 中,形参 p 的地址与实参 pa 的地址并不一样,但是他们在内存中的值都是变量 a 的地址,因此可以通过指针相关的操作来改变a的值。
funcCall

图中 &a 表示a的地址,值为: 0xc420080008

引用传递

所谓引用传递是指在调用函数时将实际参数的地址传递到函数中,那么在函数中对参数所进行的修改,将影响到实际参数。

由于 Go 里边并不存在引用传递,我们常常看到说 Go 中的引用传递也是针对:SliceMapChannel 这几种类型(这是个错误观点),因此为了解释清楚引用传递,先劳烦大家看一段 C++ 的代码(当然非常简单)。

1
2
3
4
5
6
7
8
9
10
11
12
void rFoo(int & ref) {
printf("%p\n", &ref);// 0x7ffee5aef768
}

int main() {
int a = 10;
printf("%p\n", &a);// 0x7ffee7307768
int & b = a;
printf("%p\n", &b);// 0x7ffee5aef768
rFoo(b);
return 0;
}

这里就是简单的在main中定义一个引用,然后传给函数 rFoo,那么来看看正统的引用传递是什么样的?

这里 b 是 a 的别名(引用,不清楚的可以看我上篇文章),因此a、b必定具备相同的地址。那么按照引用传递的定义,实参 b 传给形参 ref 之后,ref 将是 b 的别名(也即a、b、ref都是同一个变量),他们将拥有相同地址。通过在 rFoo 函数中的打印信息,可以看到三者具有完全形同的地址,这是所谓的引用传递。

Go中没有引用传递

Go中函数调用只有值传递,但是类型引用有引用类型,他们是:slicemapchannel。来看看官方的说法:

There’s a lot of history on that topic. Early on, maps and channels were syntactically pointers and it was impossible to declare or use a non-pointer instance. Also, we struggled with how arrays should work. Eventually we decided that the strict separation of pointers and values made the language harder to use. Changing these types to act as references to the associated, shared data structures resolved these issues. This change added some regrettable complexity to the language but had a large effect on usability: Go became a more productive, comfortable language when it was introduced.

大概意思是说:最开始用的是指针语法,由于种种原因改成了引用,但是这个引用与C++的引用是不同的,它是共享关联数据的结构。关于这个问题的深入讨论我会放到 slice 相关文章中进行讨论,现在回到今天讨论的主题。

那么Go的引用传递源起何处?我觉得让大家误解的是,map、slice、channel这类引用类型在传递到函数内部,可以在函数内部对它的值进行修改而引起的误会。

针对这种三种类型是 by value 传递,我们用 slice 来进行验证。

1
2
3
4
5
6
7
8
9
10
11
12
13
func main() {
arr := [5]int{1, 3, 5, 6, 7}
fmt.Printf("addr:%p\n", &arr)// addr:0xc42001a1e0
s1 := arr[:]
fmt.Printf("addr:%p\n", &s1)// addr:0xc42000a060

changeSlice(s1)
}

func changeSlice(s []int) {
fmt.Printf("addr:%p\n", &s)// addr:0xc42000a080
fmt.Printf("addr:%p\n", &s[0])// addr:0xc42001a1e0
}

代码中定义了一个数组 arr,然后用它生成了一个slice。如果go中存在引用传递,形参 s 的地址应该与实参 s1 一样(上面c++的证明),通过实际的情况我们发现它们具备完全不同的地址,也就是传参依然发生了拷贝——值传递。

但是这里有个奇怪的现象,大家看到了 arr 的地址与 s[0] 有相同的地址,这也就是为什么我们在函数内部能够修改 slice 的原因,因为当它作为参数传入函数时,虽然 slice 本身是值拷贝,但是它内部引用了对应数组的结构,因此 s[0] 就是 arr[0] 的引用,这也就是能够进行修改的原因。

funcCall

小结

  • Go 中函数传参仅有值传递一种方式;
  • slicemapchannel都是引用类型,但是跟c++的不同;
  • slice能够通过函数传参后,修改对应的数组值,是因为 slice 内部保存了引用数组的指针,并不是因为引用传递。

接下来的文章尝试解析下:
slice 为什么一定要用 make 进行初始话,它初始化做了哪些事情?它每次动态扩展容量的时候进行了什么操作?