图解Go的引用底层实现

2021-07-17

Go怎么可能有引用?得了吧~
有人要说了,那利用make()函数执行后得到的slice、map、channel等类型,不都是得到的引用吗?

我要说:那能叫引用吗?你能确定啥叫引用吗?
如果你有点迷糊,那么请听我往下讲:

这一切要从变量说起。

什么是变量

无论是引用变量还是指针变量,都是变量;那么,什么叫变量?
其实变量本质就是一块内存。通常,我们对计算机内存进行操作,最直接的方式就是:“计算机,在0x0201地址内存一个整数100,在0x00202地址存一个浮点数10.6,读取0x00203的数据…” 这种方式让机器来操作还行,如果直接写成代码让人看的话,这一堆“0x0201、0x0202…”难记的地址能把人给整崩溃了~
于是,聪明的人们想出了一种方法:把一堆难记的地址用其他人类可以方便读懂的方式来间接表示。例如:将“0x0201”的地址命名为“id”,将“0x0202”命名为“score”…然后,代码编译期间,再将”name”等人类能读懂的文字转化为真实的内存地址;于是,变量诞生了~

所以,其实每个变量都代表了一块内存,变量名是我们给那块儿内存起的一个别名,内存中存的值就是我们给变量赋的值。变量名在程序编译期间会直接转化为内存地址。

什么是引用

引用是指向另外一个变量的变量,或者说,叫一个已知变量的别名。

注意,引用和引用本身指向的变量对应的是同一块内存地址。引用本身也会在编译期间转化为真正的内存地址。当然咯,引用和它指向的变量在编译期间会转化为同一个内存地址。

什么是指针

指针本身也是一个变量,需要分配内存地址,但是内存地址中存的是另一个变量的内存地址。有点绕口,请看图:

GO中的引用和指针

我们先看看“正统”的引用的例子,在C++中(C中是没有引用的哈):

1
2
3
4
5
6
7
8
9
10
11
12
13
#include <stdio.h>

int main(void)
{

int i = 3;
int *ptr = &i;
int &ref = i;

printf("%p %p %p\n", &i, ptr, &ref);
// 打印出:0x7ffeeac553a8 0x7ffeeac553a8 0x7ffeeac553a8
return 0;
}

变量地址、引用地址、指针的值 均相同;符合常理

那我们再试试Go中类似代码的例子:

1
2
3
4
5
6
7
8
9
10
11
12
package main

import "fmt"

func main() {
i := 3
ref := i
ptr := &i

fmt.Println(fmt.Sprintf("%p %p %p", &i, &ref, ptr))
// 打印出 0xc000118000 0xc000118008 0xc000118000
}

变量i地址和指针ptr的值一样,这是符合预期的;但是:正如Go中没有特别的“引用符号”(C++中是int &ref = i;)一样,上述go代码中的ref压根就是个变量,根本不是引用。

可是,很多人不死心,是不是“实验对象”不对啊?代码中使用的是int整型,我们换做slicemap试试?毕竟网上的”资料”都是这么写的:
例如以下截图(只看标红部分就好):

还有如下截图(只看标红部分就好):

ok,那我们可以试试如下map的代码,看到底有没有引用:

1
2
3
4
5
6
7
8
9
10
11
12
13
package main

import "fmt"

func main(){
i := make(map[string]string)
i["key"]="value"

ref := i

fmt.Println(fmt.Sprintf("%p %p", &i, &ref))
// 打印出:0xc00010e018 0xc00010e020
}

哈哈!不对呀,如果是引用的话,打印的地址应该相同才对,但是现在不相同!所以不存在?
别着急,紧接着看下面的例子:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
package main

import "fmt"

func main(){
i := make(map[string]string)
i["key"]="value"

ref := i
ref["key"] = "value1"

fmt.Println(i["key"]) // 打印结果:value1
fmt.Println(ref["key"]) // 打印结果:value1

fmt.Println(fmt.Sprintf("%p %p", &i, &ref))
// 打印结果:0xc00000e028 0xc00000e030
}

能猜出来打印了什么吗?变量地址是不对,但是,但是值居然变了!ref变量可以“操控”i变量的内容!就和引用一样!

这就很奇怪了~ 咋回事儿呢?

我们细细研究一下mapslicechannel等具体实现(详情请看:我的其他文章 图解Go map底层实现图解Go slice底层实现图解Go channel底层实现)我们发现,这些类型的底层实现都是会有一个指针指向另外的存储地址,所以,在make函数创建了具体的类型实例后,实际上在内存空间中会开辟多个地址空间,而随着变量的赋值,指针引用的那个地址值也会跟着“复制”,因而其他变量可以改变原有变量的内容。

听着是不是有点绕?我们来看看图:

首先实例化了map并赋值

然后又赋值给了另外一个变量ref

由于对于指针变量的值而言,就是一个地址(程序实现上就是一串数字),所以,在赋值的时候,就“复制”了一串数字,但是,这串数字背后的含义确是另外一个地址,而地址的内容,恰恰就是map slice channel 等数据结构真正底层存储的数据!

所以,两变量因为同一个指针变量指向的内存,而产生了类似于“引用”的效果。假如实例化的类型数据中,没有指针属性,则不会产生这种“类引用”的效果:
例如如下代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
package main

import "fmt"

func main(){
i := 3

ref := i
ref = 4

fmt.Println(i, ref) // 打印输出:3 4

fmt.Println(fmt.Sprintf("%p %p", &i, &ref))
// 打印输出:0xc000016070 0xc000016078
}

可以将代码上述仔细看看能输出什么,不出意外的话你会发现:“类引用”效果消失了~

要想再次展现“类引用”效果,只要创建一个带有指针属性的类型即可,我们自己实现都可以,无需依赖Go基础库中的mapslicechannel

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
package main

import "fmt"

type Instance struct {
Name string
Data *int
}

func (i Instance) Store(num int) {
*(i.Data) = num
}

func (i Instance) Show() int{
return *(i.Data)
}



func main(){
data := 5

i := Instance{
Name:"hello",
Data:&data,
}

ref := i
ref.Store(7)

fmt.Println(i.Show(), ref.Show())
// 打印出:7 7

fmt.Println(fmt.Sprintf("%p %p", &i, &ref))
// 打印出:0xc0000a6018 0xc0000a6030
}

看看以上代码,是不是实现了“类引用”? 有人要说了map展示key值,slice展示某个下标的值,没有用方法呀?
这就不对了,其实map的展示key的值mapData[key]也好,更改值也好,slice展示下标值sliceArray[0]也好,更改值也好;背后底层实现也都是些“函数”和“方法”,只不过Go语言把这些函数和方法做成了语法糖,我们无感知罢了~

好了,现在我再问你:还敢说Go语言有引用类型吗?是不是感觉:也有、也没有了? 😝