Java函数的传参机制——你真的了解吗?

这篇博客的灵感来源于我参加的一次面试,面试官与我比较深入的讨论了Java函数传参的问题,当时与他讨论了好半天,不知道是面试官自己没彻底弄清楚还是因为他故意想深入考察我对这个知识点的了解程度。Java函数的传参机制是一个非常基础的知识点,说难不难,但是想要彻底弄清楚也没那么容易,刚到公司时,发现部分同事对这个机制了解得不是很清楚,于是给他们仔细地讲了讲,他们听了之后有些恍然大悟,所以我猜测可能会有一些有经验的Java工程师对此知识点的理解也会比较模糊。鉴于此,作为我的第一篇技术型的博文,在此把自己的理解写出来,欢迎大家批评指正。
  作为一名Java工程师,我对C++也有一些了解,个人觉得Java与C++有着很多的共同之处,所以在讨论Java的函数传参机制时,我想先以一道比较经典的C++试题作为引子。

题目一:请用C++实现swap函数,交换两个整数类型的值。

这是一道C++初学者经常会碰到的一个问题,先看下面的这种实现方式

int main(int argc, char *argv){
    int a = 1, b=2;
    swap(a,b);
}
void swap(int a, int b){
    int temp = a;
    a = b;
    b = temp
}

很显然,只要稍有点C++基础的程序员都会知道上述实现方式达不到目的,因为在函数参数的传递过程中,形参只是将实参的值拷贝了一份,在接下来的函数体中,所有的赋值操作都是针对于形参的,而对实参的值不会有任何的改变,也就是说在调用了swap函数之后,a的值仍为1,b的值仍为2,并未达到交换他们的值的目的。
  接下来另一种实现方式

int main(int argc, char *argv){
    int a = 1, b=2;
    swap(&a,&b);
}
void swap(int* a, int* b){
    int temp = *a;
    *a = *b;
    *b = temp;
}

这一次的实现是对的,设计的函数的形参为两个指针变量,然后将两个变量的指针作为参数传入,在函数实现体的内部,将两个指针的指向的变量进行赋值,改变了引用a和b指向的地址的值,从而达到了交换a、b的值的目的。
  以上面这道简单的C++的试题作为背景,我们将其延伸到Java语言上来,来一道新的题目:

题目二:请用Java实现swap函数,交换两个整数类型的值。

实现方式一:

public static void main(String[] args){
    int a = 1, b=2;
    swap(a,b);
    System.out.print("a="+ a + ", b="+ b);
}
void swap(int a, int b){
    int temp = a;
    a = b;
    b = temp;
}

很显然,这种实现方式不对,错误原因与上面C++的第一种实现方式类似,就不再赘述。既然这种方式不对,那么我们能否借鉴C++的第二种实现方式呢?想必很多Java学习者都听过这样一些论断:“Java里面的引用与C++里面的指针类似”、“Java里面处处是指针”等。暂且不管这些论断是否正确,我们借鉴这样一种思想尝试着实现上面的swap函数:既然用基本类型的变量实现swap函数,那么我们改用包装类型,由于包装类型传入的是引用,而引用与C++的指针类似,因此有可能实现swap函数。下面尝试第二种方式:

public static void main(String[] args){
    Integer a = 1, b = 2;
    swap(a,b);
    System.out.print("a="+ a + ", b="+ b);
}
void swap(Integer a, Integer b){
    Integer temp = a;
    a = b;
    b = temp;
}

请仔细思考上面的代码,我们通过调用swap函数是否实现了a,b两个整数的交换?
  运行代码之后,我们发现a的值仍旧为1,b的值仍旧为2,两个变量的值依然没有被交换。这种方式居然也行不通,那原因是啥呢?是由于Java里的引用跟C++的里面的指针有区别,还是由于Java与C++的函数传参机制不同呢?先不忙着解答这个问题,待我稍后揭开其中的原因。
  既然第二种方式使用的是不可变的整型包装类Integer达不到目的,那么我们使用尝试着使用非final类AtomicInteger,下面我们看代码(实现方式三):

public static void main(String[] args){  
    AtomicInteger a = 1, b = 2;
    swap(a,b);
    System.out.print("a="+ a + ", b="+ b);
}
void swap(Integer a, Integer b){  
    AtomicInteger temp = a;
    a = b;
    b = temp;
}

运行代码之后我们很遗憾地发现,这一次我们仍旧没有成功地交换a、b两个整数的值。最后请看下面几行代码(实现方式四):

public static void main(String[] args){  
    AtomicInteger a = 1, b = 2;
    swap(a,b);
    System.out.print("a="+ a + ", b="+ b);
}
void swap(AtomicInteger a, AtomicInteger b){  
    int temp = a.get();
    a.set(b.get());
    b.set(temp);
}

这一次我们神奇地发现,a、b两个整数发生了交换!真是不容易呀,折腾半天终于用Java语言实现了两个整数的交换。看到这里可能有人已经按捺不住了,说我扯了这么多也就是用Java实现了一个swap函数而已,也没有讲明白Java函数的传参机制啊。稍安勿躁,下面请听我细细道来其中原委。
  首先,我想讲一下Java语言中变量的存储机制。在Java中,我们声明并初始化一个变量的时候,会产生一个基本类型或者对象的引用以及其这个基本类型或者对象本身,而我们代码中看到的变量就是这个引用,无论何时我们想调用这个基本类型或者对象的时候我们只能通过其引用来间接调用。这里其实就告诉了大家Java里的引用与C++里面的指针的不通之处。对于C++的指针,我们可以通过对一个指针变量进行*操作,然后进行赋值(也就是上面的 *a = *b;*b = temp这两行代码),来改变这个指针变量指向的地址的真实数据,而Java里面的引用则不同,它没有*操作,对Java引用的赋值操作只会将这个引用指向一个新的对象,原来的对象不变。因此,改变Java对象的唯一方法(反序列化等非常规方法除外)就是通过引用调用这个对象的能够改变自身属性的方法(比如AtomicInteger的set方法)。
  在上面基本事实作为前提条件下,我们来一个个来分析上面四段Java代码,来解读Java函数的传参机制。对于实现方式一,基本类型作为函数参数进行传递,传递的是值(其实我更倾向于另外一种说法,下文中会提到),也就是把实参的值拷贝一份传给形参,之后在函数体中所有的操作都是在形参上,永远不会改变实参的值。关于实现方式二,通过对象进行传递,其实传递的是引用,也就是将对象的引用拷贝一份传给形参,形参引用和实参引用指向的是同一个对象,但是在函数体中,我们所有的操作都只是对形参引用进行赋值运算,而对应用的赋值运算只会让引用重新指向另一个对象,因此函数体只是将这些拷贝出来的引用重新指向其它对象,并没有改变这些对象的值,也没有改变原来的实参引用指向的对象,因此该函数并没有达到交换两个整数的目的。实现方式三,其原理跟实现方式三完全一致,唯一的区别就是将Integer对象换成了AtomicInteger对象,其根本思路没变,所以也没有达到交换的目的。对于实现方式四,由于函数参数也是对象,按照前面的说法,传递的也是引用,因此形参引用a与实参引用a指向的是同一个对象,形参引用b与实参引用b指向的也是同一个对象,而在函数体中,分别通过调用了形参a和形参b的set方法来改变了对象本身,因此也同时改变了实参引用a和实参引用b指向的对象,达到了交换a、b两个整数的目的。一言以蔽之,Java函数传递时,基本类型传递的是值,对象类型传递的是引用,无论是基本类型还是对象类型,在函数体中没有改变对象的操作的话原来对象就不会改变!
  对于上面的解释和总结,可能有人会问:你说对象传递的是引用,那要是我们的实参是一个匿名对象,没有引用,那怎么把引用传递给形参呢?比如将第四段Java代码改为如下方式:

public static void main(String[] args){
    AtomicInteger a = 1;  
    swap(a, new AtomicInteger(2));  
    System.out.print("a="+ a);  
}  
void swap(AtomicInteger a, AtomicInteger b){  
    int temp = a.get();  
    a.set(b.get());  
    b.set(temp);  
}

其中的new AtomicInteger(2)是怎么将引用传递给b的呢?实际上Java中所有的东西在JVM内部都是编号,名字只是给人看的,匿名对象没有名字但有编号,传递参数时外面是没给它取名字,但被调用的方法中是有个形参数和它对应起来的,这样在被调用的方法内部它还是有名字的。另外,所以的匿名的东西,不管是匿名类还是匿名对象,在编译的那一刻就已经给了一个临时的名字,比如 $1, $2 这样的名字,所以匿名的对象也是通过引用传递给形参的,在编译的时候会给它指定一个引用。
  还记得我在上面解释第一段代码的时候加了一个括号的说明吗?我更加倾向的是那种说法是啥呢?如果有兴趣的话请先阅读王垠的这篇文章——《Java有值类型吗?》。假设各位已经接受文Java没有值类型的观点,Java基本类型也是一种引用类型,那么Java函数的参数传递可以用更加简单的一句话来概括——Java函数参数传递的是引用!至于为什么,请大家结合王垠的那篇文章以及我前面的分析进行思考。
  最后,出个小题,测试一下大家是否真正的理解了前面讲的知识点。答案会在以后给出。

题目三:在下面的代码块中func函数执行完毕后,主函数中的list1和list2中分别有哪些元素呢?(请用JDK1.7以上的版本运行)
public static void main(String[] args){
    List list1 = new ArrayList<>();
    List list2 = new ArrayList<>();
    list1.add(0);
    list2.add(0);
    func(list1,list2);
}
void func(List list1,List list2){
    list1 = new ArrayList<>();
    list1.add(1);
    list2.add(1);
    list2 = new ArrayList<>();
    list1.add(2);
    list2.add(2);
}

你可能感兴趣的:(Java)