写在前面
我以前一直都分不清引用与值的区别,尤其是在之前初学 C 语言时,C 语言原始的语言设计让我对 它们两的理解雪上加霜。学了 Java 之后情况其实也没有多大的变化,主要是我当时以为看别人的博客 就是最好的学习方法。结果是 CSDN 上的很多人水平并不高,不加思考地接受他们的“二手知识” 是很有害的。还有一些人以复制粘贴别人的博客为乐,虽然注明了转载,没有版权问题;但是如果你转载 的文章很容易就可以搜索到,你复制粘贴的意义何在?更何况不少人复制粘贴的文章本身就可以看出来 没有什么水平,反而容易误导别人,让人觉得这个知识好像是什么公认的“真理”。我觉得,转载应该仅限于 记录那些不容易通过搜索引擎找到的(个人网站托管的博客等),并且有反复阅读的意义的好文章。
真正让我认识清楚引用与值的是王垠的一篇讲解 Java 有没有值类型的文章,垠大确实很有见解与洞察力, 这篇文章我读后感觉醍醐灌顶。后来我又找到了以前看到的一篇讲解引用传递与值传递的知乎文章,先前 我没有看懂,现在我则完全明白了。于是乎,我写下这篇文章来个全面的总结。
引用类型与值类型
首先当然是要讲清楚什么是引用,什么是值。
编程语言的语义(也就是你表面看到的语法)和它的具体实现其实是应该分开来研究的,这样就体现了计算机科学 中很重要的“抽象”的概念。但是经常有人喜欢把它们掺和在一起谈,比如说讨论 Java 中什么时候字符串的引用 可以用相等符号(==)比较,C 语言中纠结于 int 到底占几个字节。这样其实是不好的。不过 C/C++ 可能是个 特例,因为出于历史原因,它们和底层实现靠地太近了(我没说 modern C++)。所以就会有用 ++i 代替 i++, 用 NULL==ptr 代替 ptr==NULL 之类的奇怪问题;要是从语言语义上考虑,这不是很扯淡吗,语言设计和实现上的 问题居然要开发者来背锅,凭什么开发者非得了解语言的具体实现才能写出优秀的代码?
好了,扯远了,我想说的是要讲清楚今天的引用与值的概念就不得不把语义和实现放在一起讨论。因为引用类型 的出现是为了解决值类型在某些情况下的效率低下。
引用类型与值类型是对变量而言的。变量就是名字、符号、标签,它不是实际内存单元。
var s http.Response // 声明一个叫 s 符号,它没有内存空间 s = new http.Response() // 现在 s 用来标识一个内存单元 // 很多语言允许这样写,你可以把它当成是以上两个操作的语法糖 var s = new http.Response() // 还有一些语言在变量声明时提供默认初始化,这是为了防止直接使用无对应内存单元的变量(符号)导致未定义行为
那么值类型的变量拥有或者说标识的内存单元存放的是一个值本身;而引用类型的变量标识的内存单元存放的是 一个值的位置信息,可以是单纯的地址(裸指针),还可以携带一些类型信息。
符号表 | 内存空间 ____ a ----> | 45 | 值类型 实际内存单元 ________ _________ b ----> |0xc09812|====>| "hello" | 裸指针 ________ _________ c ----> |0xc09845|====>| "hello" | 携带类型信息 | string | 引用的内存单元 实际内存单元
一定要注意符号对内存单元的箭头和引用关系的箭头是不同的。程序中实际数据结构可能占据很大的内存空间, 引用类型允许传递变量时只传递或复制引用的内存单元从而降低程序运行时开销,提高效率。之后会详细提到。
Java 在语义上只有引用类型而没有值类型,这里在垠大的文章中提到过。虽然官方说基本类型是值类型,但是你完全 可以把它们看成是具体实现的区别。在语义上,int 类型的外特性和 class 类型完全一致。之所以这样,是因为 Java 不允许你操作引用的内存单元,对符号的一切操作最后都反应在实际内存单元上,你无法觉察到引用类型与值类型的区别。 而有一些语言,如 C/C++ ,允许你操作引用的内存单元。
Person a = get_person(); Person* ptr = &a; // 值类型变量 a 和引用类型变量 ptr 都可以访问实际内存单元 符号表 | 内存空间 ___________ a -------------------> | "LiHua" | 实际内存单元 ________ | 26 | ptr ----> |0xc09812|====> |13355268288| 引用的内存单元 ptr++; 符号表 | 内存空间 ___________ a -------------------> | "LiHua" | 实际内存单元 | 26 | ________ |13355268288| ptr ----> |0xc09813|==> ??? 引用的内存单元的值被修改
其实在我画的模型上还可以探讨类型系统的问题。
可以认为 C 语言把类型信息放在符号上,不同类型的变量在一起操作可能会触发编译错误,但是 可以强制转换变量的类型来按其他类型解释内存单元,如用 char* 强制转换进行对象序列化。
write_to_file((char*)get_person());
而另外一些语言,如 Python,Lua,把变量只看成是标签,类型信息放在实际内存单元上。
x = 6 x = (1,2,3) // 类型信息不在符号 x 上,不会触发类型错误
引用传递与值传递
这主要是函数传参时的问题,但是为了更清楚地理解它,我们从赋值符号(=)的语义说起。
STEP 1 int a; // 声明符号 符号表 | 内存空间 a 实际的语言实现可能把 a 标识给任意的内存单元,或者初始化一个值为类型默认零值的内存单元让 a 标识。 STEP 2 a = 45; // 分配空间 符号表 | 内存空间 ____ a ----> | 45 | STEP 3 int b = a; // 新声明的符号 b 符号表 | 内存空间 ____ a ----> | 45 | ____ b ----> | 45 |
很显然,对于值类型来说,赋值符号(=)的语义是:赋值号右侧表达式求值(45 求值是 45 本身; a 求值是符号所标识的内存单元的值,也是 45),然后用该值初始化一个内存单元并让赋值号左侧的符号标识它。
然后我们来看一下引用类型的情况。
STEP 1 Node a; // 还是声明符号 符号表 | 内存空间 a STEP 2 a = new Node(); // 可能分配巨大的实际空间,但是同时初始化了一个内存单元存放引用,符号只能标识引用 符号表 | 内存空间 ________ _________ a ----> |0xc09845|====>| 45 | | Node | | 60 | 可能是很复杂的结构, | "email" | 占据大量的内存空间 | ... | STEP 3.A Node b = a; // 引用空间复制一份并让 b 来标识 符号表 | 内存空间 ________ _________ a ----> |0xc09845|====>| 45 | | Node | | 60 | 可能是很复杂的结构, ________ | "email" | 占据大量的内存空间 b ----> |0xc09845|====>| ... | | Node | STEP 3.B Node c =a ; // 让符号 b 来标识同一块引用的内存单元 符号表 | 内存空间 ________ _________ a ----> |0xc09845|====>| 45 | b ----> | Node | | 60 | 可能是很复杂的结构, | "email" | 占据大量的内存空间 | ... |
可以看到引用类型变量之间赋值可能会有不同的语义。但它们初始化时赋值号的语义(STEP 2)还是 相同的。STEP 3.A 就是值传递,而 STEP 3.B 是引用传递。很显然,“引用类型对应引用传递,值类型 对应值传递”这种说法是不对的。 Java 使用的是 STEP 3.A 的语义,所以 Java 只有值传递,值传递 发生在引用类型之间时叫做引用副本传递。
Java 中 String 类初始化时赋值号的语义很微妙,具体参考我之前的一篇博客。 这个属于具体实现引起的差异。你只需要确保不使用相等号(==)来比较 String 对象,这样在语义上就没问题了。
函数传参道理其实是一样的,可以看成是实参向形参赋值。
读者可以自己理解一下。下面直接上 Java 的代码示例,
这样应该就可以把问题彻底说清楚了。
// 先定义一个 Person 类作为实验对象 public class Person{ private String name; public int age; public Person(String name,int age){ this.name=name; this.age=age; } public void setAge(int age){ this.age=age; } } public class Main{ public static void main(String[] args) { var p=new Person("LiHua",18); System.out.println(p.age); // 输出 18 pass(p); System.out.println(p.age); // 输出 22 } private static void pass(Person p_copy){ p_copy.setAge(22); // p_copy.age=22; // 写法是等价的 System.out.println(p_copy.age); // 输出 22 } } // 上面 main 函数传递完引用 p 后,main 函数自己的 p 最终指向的实际内存单元发生了变化, // 但是这不是引用传递,看下面的示例。 public class Main{ public static void main(String[] args) { var p=new Person("LiHua",18); System.out.println(p.age); // 输出 18 pass(p); System.out.println(p.age); // 输出 18 } private static void pass(Person p_copy){ p_copy=new Person("Lihua", 18); p_copy.age=22; System.out.println(p_copy.age); // 输出 22 } } // 如果 Java 是引用传递的话,那么 main 函数传递完引用 p 后的输出也应该是 22 才对。 // 但是事实上,无论你怎么做,实参 p 标识它的引用单元这一标识关系是无法改变的。
显而易见,引用副本传递也会引起副作用,毕竟形参也是可以修改实际内存单元的。但是,引用传递的副作用 应该会更严重一些。编写代码时要考虑到这些因素。
结语
我画的模型不是绝对的,赋值号与初始化的语义也要结合实际的编程语言去考虑;但是分析方法是类似的,
语义与具体实现分开的思想也是不变的。最重要的是,读者要有自己的思考,不能看别人怎么说你就怎么想,
直接把别人写的当成什么宝贵的知识点记下来。知识应当永远是简单的,不是因为真理本来面目如此,
而是人类乐于接受的真理长相如此。
如果你发现编程语言的某个知识点很复杂,可能不是你理解能力不强,
而是你理解时没把问题抽象开或者干脆语言设计者水平不够。
2020 年 03 月 20 日 更新 最近转载了 Craig Stuntz 的一篇博客, 讲解编程语言中的“相等性”(Equality)讲的很好,如果感兴趣也可以看一看。
最后附上GitHub:https://github.com/gonearewe