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#C...

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

值传递

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

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

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,实参与形参也分别有自己的内存空间,因为参数的名字仅仅是给程序员看的,上篇文章已经说清楚了。

指针传递

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

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

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++ 的代码(当然非常简单)。

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 来进行验证。

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 进行初始话,它初始化做了哪些事情?它每次动态扩展容量的时候进行了什么操作?


大愚Talk
记录自己技术生涯的所学、所思、所用的一切技术。

Life is short, code more!

3.2k 声望
4.6k 粉丝
0 条评论
推荐阅读
电商商品系统的演进分析
好久没有给大家带来新的知识分享了,2022的第一篇(是的,你没看错!第一篇)就给大家讲讲商品模型的演进过程吧!希望对大家做的工作有所帮助~~

大愚Talk4阅读 934

封面图
Golang 中 []byte 与 string 转换
string 类型和 []byte 类型是我们编程时最常使用到的数据结构。本文将探讨两者之间的转换方式,通过分析它们之间的内在联系来拨开迷雾。

机器铃砍菜刀22阅读 55.2k评论 1

年度最佳【golang】map详解
这篇文章主要讲 map 的赋值、删除、查询、扩容的具体执行过程,仍然是从底层的角度展开。结合源码,看完本文一定会彻底明白 map 底层原理。

去去100214阅读 11.1k评论 2

年度最佳【golang】GMP调度详解
Golang最大的特色可以说是协程(goroutine)了, 协程让本来很复杂的异步编程变得简单, 让程序员不再需要面对回调地狱, 虽然现在引入了协程的语言越来越多, 但go中的协程仍然是实现的是最彻底的. 这篇文章将通过分析...

去去100213阅读 11.3k评论 4

【已结束】SegmentFault 思否技术征文丨浅谈 Go 语言框架
亲爱的开发者们:我们的 11 月技术征文如期而来,这次主题围绕 「 Go 」 语言,欢迎大家来参与分享~征文时间11 月 4 日 - 11 月 27 日 23:5911 月 28 日 18:00 前发布中奖名单参与条件新老思否作者均可参加征文...

SegmentFault思否11阅读 4.7k评论 11

封面图
【Go微服务】开发gRPC总共分三步
之前我也有写过RPC相关的文章:《 Go RPC入门指南:RPC的使用边界在哪里?如何实现跨语言调用?》,详细介绍了RPC是什么,使用边界在哪里?并且用Go和php举例,实现了跨语言调用。不了解RPC的同学建议先读这篇文...

王中阳Go8阅读 3.7k评论 6

封面图
【golang】sync.WaitGroup详解
上一期中,我们介绍了 sync.Once 如何保障 exactly once 语义,本期文章我们介绍 package sync 下的另一个工具类:sync.WaitGroup。

去去100213阅读 30.2k评论 2

Life is short, code more!

3.2k 声望
4.6k 粉丝
宣传栏