引用传递和值传递总结

又一个从大学开始看了又看,每次看完就忘的经典问题,总结一下。文中示例代码无特殊说明默认使用 java 语言。

讨论之前先要搞清楚的问题

基本类型和引用类型的区别

基本类型(primitive types,也称值类型,基本/基础数据类型、原始数据类型):值就直接保存在变量中。基本数据类型的值是按值访问的。

引用类型(reference types):变量中保存的是实际对象的地址。一般称这种变量为”引用”,引用指向实际对象,实际对象中保存着内容。

1
2
int num = 10;
String str = "hello";

如图所示:

image-20181011190327208

赋值运算符(=)的作用

1
2
num = 20;
str = "java";

image-20181011191902401

对于基本类型 num ,赋值运算符会直接改变变量的值,原来的值被覆盖掉。

对于引用类型 str,赋值运算符会改变引用中所保存的地址,原来的地址被覆盖掉。但是原来的对象不会被改变。

如上图所示,”hello” 字符串对象没有被改变。(没有被任何引用所指向的对象是垃圾,会被垃圾回收器回收)。

本质上是求值策略问题

值传递与引用传递,在计算机领域是专有名词,如果你没有专门了解过,一般很难自行悟出其含义。而且在理解下面的解释时,请不要把任何概念往你所熟悉的语言功能上套。很容易产生误解。比如Reference,请当个全新的概念,它和C#引用类型中的引用,和C++的&,一点儿关系都没有。

求值策略(以下参考Hugo Gu在知乎的答案

值传递和引用传递,属于函数调用时参数的求值策略(wiki: Evaluation Strategy),这是对调用函数时,求值和传值的方式的描述,而非传递的内容的类型(内容指:是值类型还是引用类型,是值还是指针)。值类型/引用类型,是用于区分两种内存分配方式,值类型在调用栈上分配,引用类型在堆上分配。(不要问我引用类型里定义个值类型成员或反之会发生什么,这不在这个本文的讨论范畴内,而且你看完之后,你应该可以自己想明白)。一个描述内存分配方式,一个描述参数求值策略,两者之间无任何依赖或约束关系。

在函数调用过程中,调用方提供实参,这些实参可以是常量:Call(1);,也可以是变量:Call(x);,也可以是他们的组合:Call(2 * x + 1);,也可以是对其它函数的调用:Call(GetNumber());,但是所有这些实参的形式,都统称为表达式(Expression)。求值(Evaluation)即是指对这些表达式的简化并求解其值的过程。

求值策略(值传递和引用传递)的关注的点在于,这些表达式在调用函数的过程中,求值的时机、值的形式的选取等问题。求值的时机,可以是在函数调用前,也可以是在函数调用后,由被调用者自己求值。这里所谓调用后求值,可以理解为Lazy Load(延迟加载)或On Demand(一经请求)的一种求值方式。

而且,除了值传递和引用传递,还有一些其它的求值策略。这些求值策略的划分依据是:求值的时机(调用前还是调用中)和值本身的传递方式。详见下表:

img

看到这里的名传递,可能就有人联想到C++里的别名(alias),其实也是两码事儿。语言层直接支持名传递的语言很不主流,但是在C#中,名传递的行为可以用Func来模拟,说到这儿应该能大概猜出名传递的大致行为了。不过这不是重点,重点是值传递和引用传递。上面给出的传值方式的表述有些单薄,下表列出了一些二者在行为表象上的区别。

img

这里的改变不是指mutate(变动),而是change(改变),指把一个变量指向另一个对象,而不是指仅仅改变属性或是成员什么的(如Java,所以说Java是Pass by value,原因是它调用时Copy,实参不能指向另一个对象,而不是因为被传递的东西本质上是个Value,这么讲计算机上什么不是Value?所以如果探究传递的内容的类型,那么很容易陷入 “一切传引用其实本质上是传值“ 这种并不能解决问题无意义论战中)。

这些行为,与参数类型是值类型还是引用类型无关。对于值传递,无论是值类型还是引用类型,都会在调用栈上创建一个副本,不同是,对于值类型而言,这个副本就是整个原始值的复制。而对于引用类型而言,由于引用类型的实例在堆中,在栈上只有它的一个引用(一般情况下是指针),其副本也只是这个引用的复制,而不是整个原始对象的复制。

这便引出了值类型和引用类型(这不是在说值传递)的最大区别:值类型用做参数会被复制,但是很多人误以为这个区别是值类型的特性。其实这是值传递带来的效果,和值类型本身没有关系。只是最终结果是这样。

求值策略定义的是函数调用时的行为,并不对具体实现方式做要求,但是指针由于其汇编级支持的特性,成为实现引用传递方式的首选。但是纯理论上,你完全可以不用指针,比如用一个全局的参数名到对象地址的HashTable来实现引用传递,只是这样效率太低,所以根本没有哪个编程语言会这样做。(自己写来模拟玩玩的不算)

综上所述,对于Java的函数调用方式最准确的描述是:参数藉由值传递方式,传递的值是个引用。(句中两个“值”不是一个意思,第一个值是evaluation result(直译为求值结果,我觉得实际上是说值传递传递的是值的副本而不是值本身),第二个值是value content(值的内容))

由于这个描述太绕,而且在字面上与Java总是传引用的事实冲突。于是对于Java,Python、Ruby、JavaScript等语言使用的这种求值策略,起了一个更贴切名字,叫Call by sharing。这个名字诞生于40年前。

分语言讨论问题

C++

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
37
38
39
40
41
42
43
44
45
void ByValue(int a)
{
a = a + 1;
}

/*
指针运算符 &(取地址)返回变量的地址。例如 &a; 将给出变量的实际地址。
但是这里的 & 搭配int后不再是取地址符号,而是引用符号,变量的引用就是变量的别名。
讲的通俗一点就是另外一个名字,同样可以理解如果变量a是变量b的引用。
那么无论a,b中任何一个值改变,另外一个也相应的改变(a和b是一个内存地址)。
*/
void ByRef(int& a)
{
a = a + 1;
}

/*
指针运算符 *(指针)指向一个变量。例如,*var; 将指向变量 var。
而 int* a 则代表一个整型的指针,指针是一个变量,其值为另一个变量的地址。
即,内存位置的直接地址。Pointer即指针。
*/
void ByPointer(int* a)
{
*a = *a + 1;
}

int main(int argv, char** args)
{
int v = 1;

ByValue(v);
cout << v << endl; // 输出 1, v 没有改变

ByRef(v);
cout << v << endl; // 输出 2

// Pass by Reference
ByPointer(&v);
cout << v << endl; // 输出 3

// Pass by Value
int* vp = &v;
ByPointer(vp);
cout << v << endl; // 输出 4, 但是vp是参数, vp没有改变,仍然是值传递
}

Main函数里的前两种方式没有什么好说,第一个是值传递,第二个函数是引用传递,但是后面两种,同一个函数,一次调用是Call by reference, 一次是Call by value

ByPointer(vp); 没有改变vp,其实是无法改变(只能将指针指向别处,但是)。

ByPointer(&v); 改变了v。(你可能会说,这传递的其实是 v 的地址,而ByPointer无法改变 v 的地址,所以这是Call by value。这听上去可以自圆其说,但是 v 的地址,是个纯数据,在调用方的代码中并不存在,对于调用者而言,只有 v ,而 v 的确被ByPointer函数改了,这个结果,正是Call by reference的行为。从行为考虑,才是求值策略的本意。如果把所有东西都抽象成值,从数据考虑问题,那根本就没有必要引入求值策略的概念去混淆视听)。C++中指针的更多理解可以参考这里

C语言不支持引用,只支持指针,但是如上文所见,使用指针的函数,不能通过签名明确其求值策略。C++引入了引用,它的求值策略可以确定是Pass by reference。于是C++的一个奇葩的地方来了,它语言本身(模拟的不算,什么都能模拟)支持Call by value和Call by reference两种求值策略,但是却提供了三种语法去做这俩事儿。

C#的设计就相对合理,函数声明里,有ref/out,就是引用传递,没有ref/out,就是值传递,与参数类型无关。

Java

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
// 第一个例子:基本类型
void foo(int value) {
value = 100;
}
foo(num); // num 没有被改变

// 第二个例子:没有提供改变自身方法的引用类型
void foo(String text) {
text = "windows";
}
foo(str); // str 也没有被改变

// 第三个例子:提供了改变自身方法的引用类型
StringBuilder sb = new StringBuilder("iphone");
void foo(StringBuilder builder) {
builder.append("4");
}
foo(sb); // sb 被改变了,变成了"iphone4"。

// 第四个例子:提供了改变自身方法的引用类型,但是不使用,而是使用赋值运算符。
StringBuilder sb = new StringBuilder("iphone");
void foo(StringBuilder builder) {
builder = new StringBuilder("ipad");
}
foo(sb); // sb 没有被改变,还是 "iphone"。

同样的,前两个方法也没有什么好说的,原始值没有任何改变,Pass by value 没有任何问题。第三个例子,虽然sb被改变了,表面看是改变了原始对象,但是这种改变属于mutate(变动),而不是change(改变),也就是只改变了对象的属性,而没有把一个变量指向另一个对象。而第四个例子,虽然指向了新对象,但是不是这里改变的变量不是原始变量,而只是形参,也就是调用时产生的局部变量,所以归根结底,都属于Pass by value。

其实综合C++引用传递的实现来看,Java没有引用和指针这两种语法,也就无法实现Pass by Reference(引用传递)。不过鉴于Java中还有引用的概念,像上面讲的,就把这种调用方式或者叫求职策略称为 Call by sharing(共享调用)好了。

分享传递是值传递的特殊情况

刚才提到的,对于Java,Python、Ruby、JavaScript等语言使用的这种求值策略,还有一个更贴切名字,叫Call by sharing。by sharing(共享)策略在1974年为了CLU)编程语言被提出并被Barbara Liskov首次命名。这种策略也可以叫做”call by object(通过对象调用)” 或 “call by object-sharing(对象共享调用)“。

这个策略的主要区别是函数接收对象引用的副本(即参数的值不是直接别名,而是地址的副本)。此引用副本与形式参数关联,并且成为它的值。在这种情况下,为参数赋值新值不会影响传递的外部(在传递的是一个引用的情况下)。但是,由于形式参数仍然接收到地址,因此它可以访问原始对象的字段,并对其进行变更(mutate)。该策略假定大多数语言使用对象而不是原始值进行操作。

共享策略用于多种语言:Java,ECMAScript,Python,Ruby,Visual Basic等。

此外,在Python社区中,确切地使用了这个术语 — 通过共享。但在其他语言中,例如在Java,ECMAScript和其他语言中,这种策略也被称为值传递,意思是特殊的值 — 引用的副本。

结论

支持多种求值策略可以给语言带来更高的灵活性,但是同时也需要一个“灵活”的人来良好地驾驭。Java通过牺牲这种价值不大还可能带来问题的灵活性,带来了语言自身语法一致性、逻辑鲁棒性及更容易学习等多个好处。

不仅仅Java和C#,每个语言,在设计时都需要在这些特性间做出自己独特的取舍来体现自己的设计理念,并适应不同人,不同使用环境的要求。虽然说没有什么功能是一个语言可以做,而另一个语言做不了的。但是每个语言,都有它最适合的范畴与不适合的范畴。

参考链接

为什么 Java 只有值传递,但 C# 既有值传递,又有引用传递,这种语言设计有哪些好处?

Java 到底是值传递还是引用传递?

Evaluation strategy