关于 GoLang 何时使用引用的研究

2024/09/03
Note

太长不看,直接看总结

前言

由于我原本是一个搞 Java 的,未来想要转型搞 GoLang。结果在使用的时候发现一个对我这种搞 Java 的非常难以理解的情况:

type Object struct { data int } func updateObj(obj Object) { obj.data++ } func main() { var obj Object // expect 0 fmt.Println(obj.data) updateObj(obj) // expect 1 fmt.Println(obj.data) }
go

"正常情况"下,这段代码应该依次打印 01. 但是实际却是:

执行结果\

可以发现输出了两个 0.

可以发现 GoLang 里面参数传递没有和 Java 一样那么无脑。。。很显然,这里直接把对象复制了一遍然后传给了函数,如果大家对 C/C++ 稍微了解过的话,就可以发现这个逻辑是一样的:调用函数是值传递

什么是引用

这里我直接说结论了,对于一个变量来说,它有两个关键属性:

  • 地址

例如下面的代码:

var obj Object var objRef = &obj
go

obj 来说,它的值为结构体的数据,这里为了方便我们称它为 a, obj 的地址这里假设为 b。那么对于 objRef 来说,它的值就是 b,地址就是内存中的另外一块地址。

示意图

如上图所示,蓝色方框里面代表变量当前的值。

对于 obj 的值具体是什么样的,个人猜测这里应该是一个 8 字节的指针指向结构体内存地址开始的位置(不一定都是全部表示开始位置,可能还会有其它信息),然后底层根据结构体大小信息读取相应范围内的数据,就能够表示一个结构体了。

但是我们在复制这个 的时候,不能仅只复制第一个 8 字节,也就是那个指针,也必须要把后面跟着的那一大块全部全部复制。

Important

这里只是我为了方便记忆根据个人经验写出来的!没有依据!没有依据!没有依据!

切片是否需要引用

再来看一个例子(Object结构体省略了):

func updateObj(arr []Object) { arr[0] = Object{data: 1} } func main() { arr := make([]Object, 1) fmt.Println(arr[0].data) updateObj(arr) fmt.Println(arr[0].data) }
go

输出:

0 1
log

可以发现切片使用函数传递后还能够影响原来的值。其实根据切片的结构就可以发现(internal/unsafeheader/unsafeheader.go):

type Slice struct { Data unsafe.Pointer Len int Cap int }
go

可以发现这个结构体里面还有一个指针指向了真正的数据。这一点让我想起当初刚学 C 语言的时候用 malloc 声明一串连续的内存地址然后用来当数组的时候。。。

所以我们将切片传给函数时,其实也复制了值,但是复制的没这么多,就只有结构体这三个字段,在 64 位系统上也就 24 字节。

所以切片你想用引用就用,但是一般的习惯是不用,因为也浪费不了多少空间,而且后面用的时候解引用也麻烦

除了切片外 stringmapchan 也可以这样使用。

真的不用引用吗

再来看个有意思的例子:

func updateObj(arr []Object) { arr[0].data++ } func main() { arr := make([]Object, 1) val := Object{data: 0} arr[0] = val updateObj(arr) fmt.Println(val.data) }
go

输出:

0
log

理论上这里应该输出 1,但是却输出了 0,这不是和我们之前得出的结论相违背吗?


不知道你还记不记得我之前说在 C 里面声明一串连续的内存地址,在这里,切片元素的类型是 Object,所以这一串内存中存的就是 Object 具体的值,而不是 val 的内存地址

如果你将 arr[0].data 打印,可以发现它的值确实自增了。

所以说我们将值添加到切片中时,也会发生值的复制

方法返回值

那么既然入参会复制值,那么返回值会怎么样呢?

type Object struct { data int data2 int data3 int } func createObj() Object { var obj = Object{data: 2} fmt.Printf("函数中的内存地址为: %p\n", &obj) return obj } func main() { r := createObj() fmt.Printf("函数返回后的内存地址为: %p\n", &r) fmt.Println(&r) }
go

输出:

函数中的内存地址为: 0xc0000ae018 函数返回后的内存地址为: 0xc0000ae000
go

可以发现两个内存地址相差 18, 转换为十进制,就是 24, 而我们的结构体也正好是 24 字节,说明返回时也发生了复制

总结

  1. 能用引用就用引用,不管是返回值还是方法参数,避免对象过多的复制。不需要担心垂悬引用,GoLang 会为方法进行逃逸分析,根据分析结果决定将对象创建在堆中还是在栈上。
  2. 切片、mapstringchan 可以不用引用直接传递。当然也可以用引用,区别不大。
  3. 在更新切片、map等第二点提到的数据结构前,应该将值更新至最新状态后再添加,因为每次添加到这些结构中都会发生一次复制。