一切皆按值傳遞

水目沾發表於2019-04-05

Java 中只有按值傳遞

  "Java 中只有按值傳遞",作為初學者初看到這幾個字有點不敢相信,無數次通過函式改變過物件,無數次跟同事說 Java 在傳物件的時候是按引用傳遞。後來細細想想,之所以以為 Java 傳物件是按引用傳遞是因為其中有很多概念都沒理清楚,與 C++ 中的搞混了。從 C++ 轉 Java 的時候將 C++ 中的知識點對映到 Java 沒錯,這有利於 C++ 轉 Java 的人更快的學習 Java。但一旦對映錯誤就很容易形成固定思維。

  在 C++ 和 Java 中都有引用的概念,但他們完全不是同一個東西。Java 中的引用更類似 C++ 的指標,C++ 的引用在 Java 中並無對應概念。在 C++ 中有按值傳遞、按指標傳遞和按引用傳遞三種,而在 Java 中沒有 C++ 引用和指標的概念,只保留了按值傳遞。

  為了更好的說明 Java 中只有按值傳遞,先來看看 Java 的資料型別,Java 的資料型別分為基本資料型別和引用型別,其中:

  • 基本型別包括 byte/short/int/long/float/double/char/boolean 八種,基本型別在記憶體中地址中儲存的即本身的值,其一般都在棧上分配。

  • 引用型別指向一個物件,它與 C++ 的指標非常相似。但 C++ 的指標可以指向基本型別和類物件,而 Java 的引用只能指向類(列舉、介面等)物件。Java 中物件本身在堆上分配,而引用型別在棧上分配,其記憶體地址中儲存的是物件在堆中的地址。兩種型別在記憶體中的佈局如下:

一切皆按值傳遞
  上圖可以一目瞭然的看出基本型別與引用型別的區別,基本型別資料即本身,引用型別僅僅是引用。下面通過一個例子來看下基本型別和引用型別在引數傳遞中所表現的不同:

 1 class MyInteger {
 2     int value;
 3 }
 4 
 5 public class TestReference {
 6 
 7     public static void changeBasic(int arg) {
 8         arg = 2;
 9     }
10 
11     public static void changeReference(MyInteger arg) {
12         arg.value = 2;
13     }
14 
15     public static void main(String[] args) {
16 
17         int basicTypeA = 1;
18 
19         MyInteger referenceTypeA = new MyInteger();
20         referenceTypeA.value = 1;
21 
22         System.out.println("呼叫 changeBasic 之前 basicTypeA 的值 "+ basicTypeA);
23         changeBasic( basicTypeA);
24         System.out.println("呼叫 changeBasic 之後 basicTypeA 的值 "+ basicTypeA);
25 
26         System.out.println("呼叫 changeReference 之前 referenceTypeA 的值 "+ referenceTypeA.value);
27         changeReference( referenceTypeA);
28         System.out.println("呼叫 changeReference 之後 referenceTypeA 的值 "+ referenceTypeA.value);
29     }
30 }
複製程式碼

  執行結果如下:

一切皆按值傳遞
  可以看出基本型別 int 的變數 basicTypeA 在 changeBasic 呼叫後值並沒有發生改變,而引用型別 MyInteger 的變數 referenceTypeA 在呼叫 changeReference 後發生了改變。這裡就比較容易誤導讀者以為:Java 基本型別是按值傳遞而引用型別是按引用傳遞(暫且這麼定義)。

  其實不然,按值傳遞的意思想必大家都知道:傳遞的是值的的拷貝,比如上面程式碼中的呼叫 changeBasic(basicTypeA) 時,arg 是 basicTypeA 的一個拷貝,所以無論對 arg 做任何操作都不影響 basicTypeA 變數本身。而呼叫 changeReference(referenceTypeA) ,arg 也是 referenceTypeA 的一個拷貝,但是由於 arg 和 referenceTypeA 都是引用型別且他們指向同一物件,所以通過 arg 修改物件,referenceTypeA 也能看到。兩種型別變數在記憶體中呼叫過程如下:

一切皆按值傳遞

  所以可以看出無論是基本型別還是引用型別,都是按值傳遞。只是由於它們在記憶體中所表示的內容不同,最後表現出來的結果也有所不同。同理,在 C++ 中的按值傳遞、按指標傳遞和按引用傳遞理論上都可以歸為按值傳遞(其實這個歸類在學 C++ 的時候就歸納出來了,只是後來反而忘了)。

對"引用"進行按值傳遞的坑

  Java 的引用類似於 C++ 的指標,但是 C++ 的物件(不包括基本型別)傳遞提供了物件本身直接傳遞和指標傳遞兩種方式(引用方式不談),而 Java 物件只有對引用進行傳遞這一種,不存在直接將物件本身進行傳遞。

  • 物件本身進行傳遞的好處是傳遞的都是物件的拷貝,在函式中對拷貝的物件做任何修改都不會改變原物件。但是如果傳遞物件非常大,而且呼叫很頻繁會影響效能。
  • 物件的引用(或者指標)傳遞的好處是隻需要拷貝一個引用(或者指標)大小的資料即可,且可以在呼叫的函式中改變原物件內容。缺點是容易挖坑。

  在 C++ 中以上兩種傳遞方式可以自行選擇,而 Java 裡面只有第 2 種方式,凡事有利有弊,有時候我們並不想在函式中改變原物件的內容,這裡我就踩過一個坑,我們的專案中有個物件通過管道傳遞的流程如下:

一切皆按值傳遞

  funcA 與 funcB 是兩個不同的人負責的,一次升級後 funcB 的負責人發現在函式中獲取的物件 X 內容不對,一開始還以為是傳遞物件 X 的介面出現了錯誤便是一頓排查,知道最後才發現物件 X 是在升級後在 funcA 中被修改了,浪費了不少時間。當然這個架構的流程設計的不合理是主要原因(只需要在分發的時候講物件 X 做手動拷貝即可避免上述問題),但是不不影響我們丟擲 Java 只能對引用進行傳遞的弊端。

  在呼叫鏈較長、各種 for/while 迴圈中很容易就犯了上述錯誤,解決方案當然就是手動拷貝物件,Java 中拷貝物件有以下兩種方式:

  • 實現Cloneable介面並重寫Object類中的clone()方法。
  • 實現Serializable介面,通過物件的序列化和反序列化實現克隆,可以實現真正的深度克隆。

  其中第二種方式能避免深淺拷貝的問題,但呼叫比較耗時。第一種也能避免深淺拷貝但是需要自己手動去寫相應的程式碼,如果巢狀較深,程式碼將非常複雜。至於深淺拷貝的問題可以自行百度,其本質還是因為只是將物件的引用進行了傳遞而導致的一些問題。

相關文章