关于java的参数传递(值传递、引用传递和传值、传引用等)
2018年01月28日 02:16:17 ZytheMoon 阅读数:776
所谓参数传递就是用函数调用所给出的实参(实际参数)向函数定义所给出的形参(形式参数)设置初始值的过程。基本的有三种参数分别为:
(1)传值:
(2)传址(即是传指针)
(3)传引用
以上这些都是根据参数的类型来分别的,是指传递的东西是什么,而不是指传递过程,但是在传递过程中也有和它们比较混淆的名词,这就是是值传递和引用传递,总体上函数调用可以分为两类,是根据传递时的过程来区分为:值传递与引用传递。这个值传递和引用传递实际与传递的东西无关。
一般对于值传递和引用传递会有这么几种错误理解:第一种是:Java是引用传递,会理解为Java的形参是对象的引用所以才叫引用传递。问题在于引用传递这个词不是这个意思而是形容调用方式而不是参数本质的类型的。所以即使有人因为明白引用本身也是个值,然后觉得Java其实是值传递了,虽然答案是对的其实这种理解也是错的。这种理解叫“传递的是值”而非“值传递”,在下面会解释这个问题。然后第二种是:值类型是值传递,引用类型用的是引用传递。第三种是:认为所有的都是值传递,因为引用本质上也是个值,本质就是个指针嘛。第四种是:常出现在C++程序员中,声明的参数是引用类型的,就是引用传递;声明的参数是一般类型或指针的就是值传递。也有人把指针归为引用传递,因为它比较特殊,所以归为哪边都不合适。
值传递与引用传递,在计算机领域是专有名词。值传递和引用传递,属于函数调用时参数的求值策略,按值调用表示方法接收的是调用者提供的值,按引用调用表示方法接收的是调用者提供的变量地址,一个方法可以修改传递引用所对应的变量值,而不能修改传递值调用所对应的变量值,按...调用(call by)是用来描述各种语言中方法参数的传递方式,这是对调用函数时,求值和传值的方式的描述,而非传递的内容的类型(内容指:是值类型还是引用类型,是值还是指针)。值类型/引用类型,是用于区分两种内存分配方式,值类型在调用栈上分配,引用类型在堆上分配。一个描述内存分配方式,一个描述参数求值策略,两者之间无任何依赖或约束关系。
所以:
值传递:方法调用时,实际参数把它的值传递给对应的形式参数,函数接收的是原始值的一个copy,此时内存中存在两个相等的基本类型,即实际参数和形式参数,后面方法中的操作都是对形参这个值的修改,不影响实际参数的值。被调函数的形式参数作为被调函数的局部变量处理,即在堆栈中开辟了内存空间以存放由主调函数放进来的实参的值,从而成为了实参的一个副本。值传递的特点是被调函数对形式参数的任何操作都是作为局部变量进行,不会影响主调函数的实参变量的值。
引用传递:方法调用时,实际参数的引用被传递给方法中相对应的形式参数,函数接收的是被引用的内存地址,在方法执行中形参和实参作用等同,指向同一块内存地址,方法执行中对引用的操作将会影响到实际对象。被调函数的形式参数虽然也作为局部变量在堆栈中开辟了引用空间,但是这时已经相当于是主调函数放进来的实参变量的别名。被调函数对形参的任何操作都被处理成间接寻址,即通过堆栈中存放的地址访问主调函数中的实参变量。所以被调函数对形参做的任何操作都影响了主调函数中的实参变量。就是说此时如果对目标对象进行修改内存中的数据也会改变。
在java中方法参数传递方式是按值传递。如果参数是基本类型,传递的是基本类型的字面量值的拷贝。如果参数是引用类型,传递的是该参量所引用的对象在堆中地址值的拷贝。传值的方式传引用。 或者说传值的方式传地址。
在函数调用过程中,调用方提供实参,这些实参可以是常量:Call(1);也可以是变量:Call(x);也可以是他们的组合:Call(2 * x + 1);也可以是对其它函数的调用:Call(GetNumber());但是所有这些实参的形式,都统称为表达式。求值即是指对这些表达式的简化并求解其值的过程。
求值策略(值传递和引用传递)的关注的点在于,这些表达式在调用函数的过程中,求值的时机、值的形式的选取等问题。求值的时机,可以是在函数调用前,也可以是在函数调用后,由被调用者自己求值。
而且,除了值传递和引用传递,还有一些其它的求值策略。这些求值策略的划分依据是:求值的时机(调用前还是调用中)和值本身的传递方式。
求值策略 | 求值时间 | 传值方式 |
值传递 | 调用前 | 值的结果(是原值的副本) |
引用传递 | 调用前 | 原值(原始对象) |
名传递 | 调用后 | 与值无关的一个名 |
下表列出了一些二者在行为表象上的区别。
值传递 | 引用传递 | |
根本区别 | 会创建副本 | 不创建副本 |
所以 | 函数中无法改变原始对象 | 函数中可以改变原始对象 |
这里的改变是指把一个变量指向另一个对象,而不是指仅仅改变属性或是成员什么的,比如Java,所以说Java是Pass by value,原因是它调用时进行Copy,实参不能指向另一个对象,而不是因为被传递的东西本质上是个Value,这么讲计算机上什么不是Value。
所以这些行为就像在上面提到的与参数类型是值类型还是引用类型无关。对于值传递,无论是值类型还是引用类型,都会在调用栈上创建一个副本,不同的是对于值类型而言,这个副本就是整个原始值的复制。而对于引用类型而言,由于引用类型的实例在堆中,在栈上只有它的一个引用(一般情况下是指针),其副本也只是这个引用的复制,而不是整个原始对象的复制。这便引出了值类型和引用类型的最大区别:值类型用做参数会被复制,但是很多人误以为这个区别是值类型的特性,其实这是值传递带来的效果,和值类型本身没有关系只是最终结果是这样。
求值策略定义的是函数调用时的行为,并不对具体实现方式做要求,但是指针由于其汇编级支持的特性,成为实现引用传递方式的首选。但是纯理论上,你完全可以不用指针,比如用一个全局的参数名到对象地址的HashTable来实现引用传递,只是这样效率太低,所以根本没有哪个编程语言会这样做。所以对于Java的函数调用方式最准确的描述是:参数藉由值传递方式,传递的值是个引用。在字面上与Java总是传值的事实冲突,于是对于Java,Python、Ruby、JavaScript等语言使用的这种求值策略,起了一个更贴切名字,叫Call by sharing即共享传参。
在上面对于传递的参数种类和参数的传递方式进行了分析,现在看一下在传递参数种类的不同前提下,传递参数的过程会有什么变化:
传值:是把实参的值赋值给行参那么对行参的修改,不会影响实参的值函数参数压栈的是参数的副本,任何的修改是在副本上作用,没有作用在原来的变量上。
传地址:是传值的一种特殊方式,只是他传递的是引用地址,不是普通的基本类型,那么传地址以后实参和行参都指向同一个对象,但是压栈的是指针变量的副本,当你对指针解指针操作时其值是指向原来的那个变量所以对原来变量操作。
传引用:真正的以引用别名的方式传递参数,传递以后行参和实参都是同一个对象只是他们名字不同而已,对行参的修改将影响实参的值,压栈的是引用别名的副本。由于引用是指向某个变量的,对引用的操作其实就是对他指向的变量的操作。(作用和传指针一样,只是引用少了解指针的草纸)
前面讨论了各种求值策略的内涵。下面以C++的代码为例看一个例子来区分开函数调用的行为和函数传递的值的区别:
#include <iostream>
using namespace std;
void ByValue(int a)
{
a = a + 1;
}
void ByRef(int& a)
{
a = a + 1;
}
void ByPointer1(int* a)
{
int b=0;
a = &b;
}
void ByPointer2(int* a)
{
int b=0;
*a = b;
}
int main(int argc, const char * argv[]) {
int v = 1;
ByValue(v);
cout<<v<<endl;
ByRef(v);
cout<<v<<endl;
// Pass by Value
int *vp = &v;
ByPointer1(vp);
cout<<*vp<<endl;
// Pass by Reference
ByPointer2(&v);
cout<<v<<endl;
return 0;
}
Main函数里的前两种方式没有什么好说,第一个是值传递,第二个函数是引用传递,但是后面两种,同一个函数,一次调用是Call by reference, 一次是Call by value。因为:ByPointer(vp)没有改变vp其实是做了一次复制,所以改变的只是复制没有改变vp的指向,而ByPointer(&v)改变了v。你可能认为这传递的其实是v的地址,而ByPointer无法改变v的地址,所以这是Call by value。但是v的地址是个纯数据在调用的方代码中并不存在,对于调用者而言只有v的确被ByPointer函数改了,这个结果,正是Call by reference的行为。从行为考虑,才是求值策略的本意。如果把所有东西都抽象成值,从数据考虑问题,那根本就没有必要引入求值策略的概念去混淆视听。
C语言不支持引用,只支持指针,但是如上文所见,使用指针的函数,不能通过签名明确其求值策略。所以C++引入了引用,它的求值策略可以确定是Pass by reference。于是C++的语言本身支持Call by value和Call by reference两种求值策略,但是却提供了三种语法去做这俩事儿。而C#的设计就相对合理,函数声明里,有ref/out,就是引用传递,没有ref/out,就是值传递,与参数类型无关。
而对于之上的程序就和其他的程序一样运行永远都是在栈中进行的,因而参数传递时,只存在传递基本类型和对象引用的问题。不会直接传对象本身。所以Java在方法调用传递参数时,因为没有指针,所以它都是进行传值调用(这点可以参考C的传值调用)。因此,很多书里面都说Java是进行传值调用,这点没有问题,而且也简化的C中复杂性。但是传引用的错觉是如何造成的呢?在运行栈中,基本类型和引用的处理是一样的都是传值,所以如果是传引用的方法调用,也同时可以理解为“传引用值”的传值调用,即引用的处理跟基本类型是完全一样的。但是当进入被调用方法时,被传递的这个引用的值,被程序解释(或者查找)到堆中的对象,这个时候才对应到真正的对象。如果此时进行修改,修改的是引用对应的对象,而不是引用本身,即:修改的是堆中的数据。所以这个修改是可以保持的了。
Java中程序员无法直接操作指针,对于传值还是传地址很模糊,但我们知道java中变量分为两类,一类是基本变量,一类是引用。其实当我们把实参a,b传进函数后,就相当于把a,b的值分别传给了函数用于接收a,b的局部变量,那么不管是传进去引用还是值,实参a,b的值都不会被改变,因为操作的时函数中接收a,b传递的值的局部变量,函数执行完毕,这两个局部变量也就不存在了。那为什么传进去引用可以修改a,b的值呢,这是不违反我上面说的原理的,传引用相当于把a,b的内存地址传递进函数,函数的局部变量接收了a,b的地址,a,b的引用地址是无法在函数内部被改变的,但是a,b指向的值是可以改变的。
Java中无论传值还是传引用都是传的参数的副本,副本只在函数内部有效。而当传引用时,传进去的是自己副本的地址,地址无法被改变但是地址指向的值可以被改变。所以说java中都可以看做是传值,普通类型参数传递的是参数的值的副本,在函数内部值修改副本,无法对原始数据产生影响,当引用作为参数时,传递的是引用类型的地址,函数内部接收引用的地址的副本,对引用地址所指向的值可以进行修改,但引用的地址是无法修改的,相当于引用地址的值无法修改。String和StringBuffer作为参数为什么不同,相信了解String特性的都知道,String生成实例之后他的值无法修改,如果对它进行修改会产生新的对象,所以String的地址传入函数内部,函数内部对它指向的值进行操作,最终却生成了另外一个对象,而上面所说无法对原String的地址修改,所以对于新生成的String对象无法影响原来的String对象,并且它的生命周期只在函数内部。
在说说当参数类型不同而且参数传递过程也不同的互相混杂情况下的效率问题:
从传递效率上:这里所说传递效率是调用被调函数的代码将实参传递到被调函数体内的过程。这个效率不能一概而论。对于内建的int char short long float等4字节或以下的数据类型而言,实际上传递时也只需要传递1-4个字节,而使用指针传递时在32位cpu中传递的是32位的指针,4个字节,都是一条指令,这种情况下值传递和指针传递的效率是一样的,而传递double long long等8字节的数据时,在32位cpu中,其传值效率比传递指针要慢,因为8个字节需要2次取完。而在64位的cpu上,传值和传址的效率是一样的。再说引用传递,这个要看编译器具体实现,引用传递最显然的实现方式是使用指针,这种情况下与指针的效率是一样的,而有些情况下编译器是可以优化的,采用直接寻址的方式,这种情况下,效率比传值调用和传址调用都要快,与上面说的采用全局变量方式传递的效率相当。再说自定义的数据类型,class struct定义的数据类型。这些数据类型在进行传值调用时生成临时对象会执行构造函数,而且当临时对象销毁时会执行析构函数,如果构造函数和析构函数执行的任务比较多,或者传递的对象尺寸比较大,那么传值调用的消耗就比较大。这种情况下,采用传址调用和采用传引用调用的效率大多数下相当,正如上面所说,某些情况下引用传递可能被优化,总体效率稍高于传址调用。
从执行效率上:这里所说的执行效率,是指在被调用的函数体内执行时的效率。因为传值调用时,当值被传到函数体内,临时对象生成以后,所有的执行任务都是通过直接寻址的方式执行的,而指针和大多数情况下的引用则是以间接寻址的方式执行的,所以实际的执行效率会比传值调用要低。如果函数体内对参数传过来的变量进行操作比较频繁,执行总次数又多的情况下,传址调用和大多数情况下的引用参数传递会造成比较明显的执行效率损失。所以具体的执行效率要结合实际情况,通过比较传递过程的资源消耗和执行函数体消耗之和来选择哪种情况比较合适。而就引用传递和指针传递的效率上比,引用传递的效率始终不低于指针传递,所以从这种意义上讲,在c++中进行参数传递时优先使用引用传递而不是指针。
结合上面的分析,关于值传递和引用传递可以得出这样的结论:
(1)基本数据类型传值,对形参的修改不会影响实参;
(2)引用类型传引用,形参和实参指向同一个内存地址(同一个对象),所以对参数的修改会影响到实际的对象;
(3)String, Integer, Double等immutable的类型特殊处理,可以理解为传值,最后的操作不会修改实参对象。
传值:是把实参的值赋值给行参那么对行参的修改,不会影响实参的值函数参数压栈的是参数的副本,任何的修改是在副本上作用,没有作用在原来的变量上。
传地址:是传值的一种特殊方式,只是他传递的是引用地址,不是普通的基本类型,那么传地址以后实参和行参都指向同一个对象,但是压栈的是指针变量的副本,当你对指针解指针操作时其值是指向原来的那个变量所以对原来变量操作。
传引用:真正的以引用别名的方式传递参数,传递以后行参和实参都是同一个对象只是他们名字不同而已,对行参的修改将影响实参的值,压栈的是引用别名的副本。由于引用是指向某个变量的,对引用的操作其实就是对他指向的变量的操作。(作用和传指针一样,只是引用少了解指针的草纸)
在最后说几个在参数传递过程中一些共同的有用的技术:
1. const关键字:当你的参数是作为输入参数时,你总不希望你的输入参数被修改,否则有可能产生逻辑错误,这时可以在声明函数时在参数前加上const关键字,防止在实现时意外修改函数输入,对于使用你的代码的程序员也可以告诉他们这个参数是输入,而不加const关键字的参数也可能是输出。
2. 默认值:给参数添加一个默认值是一个很方便的特性,这样你就可以定义一个具有好几个参数的函数,然后给那些不常用的参数一些默认值,客户代码如果认为那些默认值正是他们想要的,调用函数时只需要填一些必要的实参就行了,这样就省去了重载好几个函数的麻烦。
3.参数顺序。当同个函数名有不同参数时,如果有相同的参数尽量要把参数放在同一位置上,以方便客户端代码。