今天,尝试谈下 Go 中的引用。

之所以要谈它,一方面是之前的我也有些概念混乱,想梳理下,另一方面是因为很多人对引用都有疑问。我经常会看到与引用有关的问题。

比如,什么是引用?引用和指针有什么区别?Go 中有引用类型吗?什么是值传递?址传递?引用传递?

在开始谈论之前,我已经感觉到这必定是一个非常头疼的话题。这或许就是学了那么多语言,但没有深入总结,从而导致的思维混乱。

前言

我的理解是,要彻底搞懂引用,得从类型和传递两个角度分别进行思考。

从类型角度,类型可分为值类型和引用类型,一般而言,我们说到引用,强调的都是类型。

从传递角度,有值传递、址传递和引用传递,传递是在函数调用时才会提到的概念,用于表明实参与形参的关系。

引用类型和引用传递的关系,我尝试用一句话概括,引用类型不一定是引用传递,但引用传递的一定是引用类型。

这几句话,是我在使用各种语言的之后总结出来的,希望无误吧,毕竟不能误导他人。

是什么

谈到引用,就不得不提指针,而指针与引用是编程学习中老生常谈的话题了。有些编程语言为了降低程序员的使用门槛,只有引用。而有些语言则是指针引用皆存在,如 C++ 和 Go。

指针,即地址的意思。

在程序运行的时候,操作系统会为每个变量分配一块内存放变量内容,而这块内存有一个编号,即内存地址,也就是变量的地址。现在 CPU 一般都是 64 位,因而,这个地址的长度一般也就是 8 个字节。

引用,某块内存的别名。

一般情况,都会这么解释引用。换句话说,引用代指某个内存地址,这句话真的是非常简洁,同时也非常好理解。但在 Go 中,这句话看起来并不全面,具体后面解释。

除了指针和引用,还有另外一个更广泛的概念,值。谈变量传递时,常会提到值传递、址传递和引用传递。从广义上看,对大部分的语言而言,指针和引用都属于值。而从狭义角度来说,则可分为值、址和引用。

相当绕人是不是?

我已经感觉到自己头发在掉了。其实,要想彻底搞清楚这些概念,还是得从本质出发。

值和指针

先来搞明白值与指针区别。

上一节在介绍指针的时候,提到了要注意变量的地址和内容的不同。为什么要说这句话呢?

假设,我们定义一个 int 类型的变量 a,如下:

var a int = 1

变量 a 的内容为 1,而变量内容是存在某个地址之中的。如何获取变量地址呢?Go 中获取变量地址的方法与 C/C++ 相同。代码如下:

var p = &a

通过 & 获取 a 的地址。同时,这里还定义了一个新的变量 p 用于保存变量 a 的地址。p 的类型为 int 指针,也就是变量 p 中的内容是变量 a 的地址。

如下代码输出它们的地址:

var a = 1
var p = &a
fmt.Printf("%p\n", p)
fmt.Printf("%p\n", &p)

我这里的输出结果是,变量 a 和 p 的地址分别为 0xc000092000 和 0xc00008c010。此时的内存的分布如下:

一文理清 Go 引用的常见疑惑-LMLPHP

变量 p 的内容是 a 的地址,因而可以说指针即是其他变量的内容,也是某个变量的地址。为什么啰啰嗦嗦的说这些,因为在学习 C 语言,会单独强调址的概念,但在 Go 中,指针相对弱化,也是归于值类型之中。

引用的本质

前面说过,引用是某块内存的别名。从字面理解,似乎表达的是引用类型变量中的内容是指针,这么理解似乎也没错。既然如此,我自然而然地想到,怎么将引用与指针关联起来。

在 C/C++ 中,引用其实是编译器实现的一个语法糖,经过汇编后,将会把引用操作转化为了指针操作。这真的是别名啊,有种 define 预处理的感觉,只不过是汇编级别的。分享一篇 C++中“引用”的底层实现 的文章,有兴趣仔细读读,我只是看了个大概。

而其他一些语言中,引用的本质其实是 struct 中包含指针,比如 Python。下面的 C 结构是 Python 中列表类型的底层结构。

typedef struct {
    PyObject_VAR_HEAD

    PyObject **ob_item;

    Py_ssize_t allocated;
} PyListObject;

变量真正存放数据的地方在 **ob_item 中。结构中的其他两个成员起辅助作用。

现在看来,引用的实现主要有两种。一是 C++ 的思路,引用其实一种便于使用指针的语法糖,和我们想象中的别名含义一致。二是类似 Python 中的实现,底层结构中包含指向实际内容的指针。

当然,或许还有其他的实现方式,但核心应该是不变的。

引用传递

谈到引用传递,就不得不提值传递,值传递的一般定义如下。

函数调用时,实参通过拷贝将自身内容传递给形参,形参实际上是实参值的一个拷贝,此时,针对函数中形参的任何操作,仅仅是针对实参的副本,不影响原始值的内容。

值传递中有一个特殊形式,如果传递参数的类型是指针,我们就会称之为址传递,C 语言中就有值传递和址传递两种说法。深究起来,C 中的址传递也属于值传递,因为对指针类型而言,变量的值是指针,即传递的值也是指针。而 C 语言之所以强调址传递,我认为主要 C 这门底层语言对指针较为重视。

什么是引用传递?

参考值传递的定义,实参地址在函数调用被传递给形参,针对形参的操作,影响到了实参,则可以认为是引用传递。

在我用过的语言中,支持引用传递的语言有 PHP 和 C++。

Go 的引用实现

Go 的引用类型有 slice、map 和 chan,实现机制采用的是前面提到的第二种方式,即结构体含指针成员。它们都可以使用内置函数 make 进行初始化。

原本我是想把这几种引用类型的底层结构都贴出来,但发现这会干扰本文主题的理解。我们只看 slice 的结构,如下:

// slice
type slice struct {
	array unsafe.Pointer
	len   int
	cap   int
}

slice 的结构最简单,包含三个成员,分别是切片的底层数组地址、切片长度和容量大小。是否感觉与前面提到的 Python 列表的底层结构非常类似?

如果想了解 map 和 chan 的结构,可自行阅读 go 的源码,runtime/slice.goruntime/map.goruntime/chan.go

如果不想研究源码,推荐阅读饶大的 Go 深度解密系列文章,包括 深度解密Go语言之Slice深度解密Go语言之map深度解密Go语言之channel,这几篇文章因为写的都非常细且非常长,可能读起来会比较考验你的耐心。

Go 是值传递

按官方说法,Go 中只有值传递。原文如下:

重点是下面这句话。

有点迷糊?最初我也迷糊,Go 不是有指针和引用类型吗。但读了一些文章,思考了许久,才彻底想明白。下面,我将尝试为官方的说法找个合理的解释。

为什么说 Go 中没有址传递

其实,这个问题前面已经解释的很清楚了,指针只是值的一种特殊形式,C 语言是门非常底层的语言,常会涉及一些地址操作,会强调指针的特殊地位。但于 Go 而言,指针已经弱化了很多,Go 团队可能也觉得没有必要再单独强调指针的地位。

为什么说 Go 中没有引用传递?

有人可能会说,Go 中明明有引用传递,按照引用传递的定义,可以非常容易就拿出一个例子反驳我。

package main

import "fmt"

func update(s []int) {
	s[1] = 10
}

func main() {
	a := []int{0, 1, 2, 3, 4}
	fmt.Println(a)
	update(a)
	fmt.Println(a)
}

输出结果如下:

[0 1 2 3 4]
[0 10 2 3 4]

针对形参 s 的操作确实改变了实参 a 的值,似乎的确是引用传递。但我想说的是,针对形参的操作并非指的是针对形参中某个元素的操作。

看个 C++ 中引用的例子。

void update(int& s) {
	s = 10;
	printf("s address: %p\n", &s);
}

int main() {
	int a = 1;
	std::cout << a << std::endl;
	printf("a address: %p\n", &a);
	update(a);
	std::cout << a << std::endl;
}

执行结果如下:

1
a address: 0x7fff5b98f21c
s address: 0x7fff5b98f21c
10

针对 s 的操作确实改变了 a 的值。在 Go 中尝试同样的代码,如下:

func update(s []int) {
	s[1] = 10
	fmt.Printf("%p\n", &s)
}

func main() {
	a := []int{0, 1, 2, 3, 4}
	fmt.Println(a)
	fmt.Printf("%p\n", &a)
	update(a)
	fmt.Println(a)
}

输出如下:

[0 1 2 3 4]
0xc00000c060
0xc000098000
[0 10 2 3 4]

非常遗憾,针对形参的赋值操作并没有改变实参的值。基于此,得出结论是 slice 的传递并非引用传递。我比较喜欢的这种解释方式,适合我个人的记忆理解,不知道是否有不妥的地方。

除此之外,介绍另外一种识别是否是引用传递的方式。

通过比较形参和实参地址确认,如果两者地址相同,则是引用传递,不同则非引用传递。但因为 C++ 和 Go 引用的实现机制不同,理解起来会比较困难。我们也可以选择只记结论。

这种方式的验证非常简单,我们在上面的 C++ 和 Go 的例子中已经输出了形参和实参的地址,比较下即可得出结论。

总结

本文主要从引用的类型和传递两个角度出发,深入浅出的分析了 Go 中的引用。

首先,引用类型和引用传递并没有绝对的关系,不知道有多少人认为引用类型必然是引用传递。接着,我们讨论了不同语言引用的实现机制,涉及到 C++、Python 和 Go。

文章的最后,解释了一个常见的疑惑,为什么说 Go 只有值传递。在此基础上,文中提出了两种方式,帮助识别一门语言是否支持引用传递。

相关阅读

golang中哪些引用类型的指针在声明时不用加&号,哪些在函数定义的形参和返回值类型中不用*号标注

Golang中的make(T, args)为什么返回T而不是*T?

Go语言参数传递是传值还是传引用

Golang中函数传参存在引用传递吗?

C++ 引用 底层实现机制

The Go Programming Language Specification


欢迎关注我的公众号。

一文理清 Go 引用的常见疑惑-LMLPHP


本篇文章由一文多发平台ArtiPub自动发布
09-29 02:55