1

图解Go的引用底层实现

 2 years ago
source link: https://i6448038.github.io/2021/07/17/reference-and-pointer/
Go to the source link to view the article. You can view the picture content, updated content and better typesetting reading experience. If the link is broken, please click the button below to view the snapshot at that time.

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

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

这一切要从变量说起。

什么是变量

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

variable.png

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

什么是引用

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

reference.png

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

什么是指针

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

pointer.png

GO中的引用和指针

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

#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中类似代码的例子:

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试试?毕竟网上的”资料”都是这么写的:
例如以下截图(只看标红部分就好):
sample.jpg
还有如下截图(只看标红部分就好):
sample2.jpg

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

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
}

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

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并赋值

variable_reference1.png

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

variable_reference2.png

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

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

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

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语言有引用类型吗?是不是感觉:也有、也没有了? 😝


About Joyk


Aggregate valuable and interesting links.
Joyk means Joy of geeK