您的位置:首页 > 编程语言 > Java开发

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

2016-03-16 19:47 591 查看
先强调这个问题前半句是真命题。说问题逻辑有问题,说一切都是值传递,都是没理解什么叫引用传递和值传递。

虽然这个问题根本就没有在问“Java是不是值传递”,但是看完其它答案发现,如果不先解释清楚到底什么是值传递,什么是引用传递,后面的好处也无从谈起。只关心好处的请拉到最后。

第一种误解是:Java是引用传递。(这么理解的人,大体会解释说Java的形参是对象的引用所以才叫引用传递。这个解释的错误在于:引用传递这个词不是这个意思,这个词是形容调用方式,而不是参数本质的类型的。所以,即使有人因为明白引用本身也是个值,然后觉得Java其实是值传递了,这种理解也是错的。你这种理解,叫“传递的是值”,而非“值传递”。后面展开。)

第二种误解是:值类型是值传递,引用类型用的是引用传递。

第三种误解是:认为所有的都是值传递,因为引用本质上也是个值,本质就是个指针嘛。

第四种误解是:常出现在C++程序员中,声明的参数是引用类型的,就是引用传递;声明的参数是一般类型或指针的就是值传递。(也有人把指针归为引用传递,其实它比较特殊,无论你归哪边都是错的。)

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

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

在函数调用过程中,调用方提供实参,这些实参可以是常量:

Call(1);

也可以是变量:

Call(x);

也可以是他们的组合:

Call(2 * x + 1);

也可以是对其它函数的调用:

Call(GetNumber());

但是所有这些实参的形式,都统称为表达式(Expression)。求值(Evaluation)即是指对这些表达式的简化并求解其值的过程。

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

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

<img src="https://pic4.zhimg.com/9d4d1d25add61af4442cae8069651e67_b.jpg" data-rawwidth="524" data-rawheight="101" class="origin_image zh-lightbox-thumb" width="524" data-original="https://pic4.zhimg.com/9d4d1d25add61af4442cae8069651e67_r.jpg">


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

&lt;img src="https://pic1.zhimg.com/47590cd61b19a99dbe227b470e016fa0_b.jpg" data-rawwidth="474" data-rawheight="73" class="origin_image zh-lightbox-thumb" width="474" data-original="https://pic1.zhimg.com/47590cd61b19a99dbe227b470e016fa0_r.jpg"&gt;


这里的改变不是指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++为例:

void ByValue(int a)
{
a = a + 1;
}

void ByRef(int& a)
{
a = a + 1;
}

void ByPointer(int* a)
{
*a = *a + 1;
}
int main(int argv, char** args)
{
int v = 1;
ByValue(v);
ByRef(v);

// Pass by Reference
ByPointer(&v);

// Pass by Value
int* vp = &v;
ByPointer(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++引入了引用,它的求值策略可以确定是Pass by reference。于是C++的一个奇葩的地方来了,它语言本身(模拟的不算,什么都能模拟)支持Call by value和Call by reference两种求值策略,但是却提供了三种语法去做这俩事儿。

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

不过如果观察一下void ByRef(int& a)和void ByPointer(int* a)所生成的汇编代码,会发现在一定条件下其实是一样的。都是这个样子:

; 12   : {

push	ebp
mov	ebp, esp
sub	esp, 192				; 000000c0H
push	ebx
push	esi
push	edi
lea	edi, DWORD PTR [ebp-192]
mov	ecx, 48					; 00000030H
mov	eax, -858993460				; ccccccccH
rep stosd

; 13   : 	*a = *a + 1;

mov	eax, DWORD PTR _a$[ebp]
mov	ecx, DWORD PTR [eax]
add	ecx, 1
mov	edx, DWORD PTR _a$[ebp]
mov	DWORD PTR [edx], ecx


调用方的代码也是一样的。代码就不贴了。

这两种传递方式说完了,下面回到正题说好处。问题中“这种”指代不明,且认为是Java。

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

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

作者:Hugo Gu

链接:https://www.zhihu.com/question/20628016/answer/28970414

来源:知乎

著作权归作者所有。商业转载请联系作者获得授权,非商业转载请注明出处。
内容来自用户分享和网络整理,不保证内容的准确性,如有侵权内容,可联系管理员处理 点击这里给我发消息
标签: