golang 参数传递和引用类型

635 阅读4分钟

cpp的值传递和引用传递

熟悉cpp的同学应该知道,定义函数时,如果形参是引用类型,那么传过来的实参就是一个引用,可以通过引用修改原数据的信息,而如果形参不是引用类型,那么传过来的就是调用处的一个拷贝。我们把传引用的场景叫做引用传递,传拷贝的场景叫做值传递。

事实上,cpp中的引用本质上就是指针,而展现给我们的是引用类型,则是编译器为我们进行了封装。参考c++中“引用”的底层实现原理详解。本文仅考虑引用在参数传递的场景,对引用的其他使用不做研究。

golang的参数传递

首先我们明确一个事情,在go中,所有参数传递都是值传递,对基本类型和struct来说,这个很好理解,就是复制了数据到了被调函数中,在被调函数中对形参的修改不会影响原函数中的变量内容。对指针来说,可能稍微有些难以理解,在传递指针到被调函数时,其实有另一个指针变量复制了原来的指针变量的值,即两个指针指向了同一个地方。在新的修改指针指向位置的数据时,原来的指针也能感受到。因此指针也是值传递的。

但是我们知道,在golang中,有几种类型被认为是引用类型,包括slice,map,chan。这几种类型在传参后,对内容的修改也可以反应到原函数中,那这几种类型也是引用传递吗?

简单验证一下,结果发现map和chan可以感受到在其他函数中的修改,但是slice只能感受到元素的修改,不能感受到长度的变化。

原理

事实上,在golang中,map和chan底层是简单的指针类型,只是在上层做了封装,对map的任何操作,其实都是在操作*hmap指针,chan也是一样。因此,对这两种类型的操作可以认为是传了引用,但事实上,我们是复制了指针到被调函数中,两个函数中的map或chan的地址并不相同。

对slice,情况稍微有些复杂。事实上,通过 arr = append(arr, a)这种写法我们也大概能猜到,slice代表一个struct。在运行时,slice等于一个包含数据地址,长度,容量三个元素的struct,因此,如果slice被传到另一个函数里,这个struct会被复制,在刚被复制时,被调函数中的slice和原函数中的slice容量长度及底层数据一致,可以认为是同一个slice,甚至在被调函数中修改slice元素时,原函数都能感知到。但是一旦我们在被调函数中进行数据的增删操作,原函数中的len和cap都不会发生改变,又因为被调函数修改了底层指向的数据,对原函数中的slice来说,可能是发生了一些莫名其妙的修改。

进一步,如果在被调函数中发生了扩容,被调函数中的slice会指向一块新的内存,同时它会复制原来的数据到新的内存去,并且更新cap。这时候可以认为这两个slice已经完全无关了。对gc而言,他们也会各自持有自己的内存部分直到生命周期结束(如果被调函数没有返回该slice,那么在被调函数返回后,新申请的内存部分会被gc回收)。这也是为什么slice的append方法需要把结果返回回来用原来的变量接住。如果不用原来的变量接住,可能会导致两个slice指向同一片内存,造成不一致。

原因

这里可能很多人会跟我有相同的疑问,为什么slice不做成和map一样的指针类型,而是要非要做成这种传值的方式呢?这里我也思考了很久不清楚。目前的想法是,map和chan完全持有自己的数据,不会和其他数据结构共享自己的数据,因此,在任何修改map的地方,都可以认为是所有map的生效处都认同的修改。也即,每个修改都清楚自己会影响到所有地方,但是slice并不是唯一持有底层数据的,在一个数组上可以轻松定义很多个slice,在这种情况下,对数组内容的修改应当反应到其他的slice中,但是对slice的容量的和大小的修改不应该被其他slice感知。因此,slice被实现成了struct类型,和map/chan并不完全相同。