獅子的鬃毛--Java 的物件傳遞是 Call By Reference
傳說拔到獅子的鬃毛,你的 Java 程式行為就會符合預期……別再相信沒有根據的說法了!
在上一篇當中,我們提到了 C 語言當中,函式呼叫時他的參數存放的位置,以及 C 語言在傳遞參數給函式時的做法,也知道了 C 語言所有的參數傳遞都是 Call By Value,會把傳進函式的值複製一份到 Stack 上該函式的區域。
接下來的這一篇,我們就來看看在 Java 裡這些事情又是如何運作。
事實上,如果你去翻網路上的文章,甚至是一些書,他們很可能會在討論 Java 的參數傳遞時,告訴你類似下面的內容:
Java 的物件傳遞是 Call By Reference
拜託,下次當你看到一本寫 Java 的書告訴你 Java 的物件傳遞是 Call By Reference 的時候,請直接把那本書燒了;如果這是教你寫 Java 的老師告訴你的,就請他別再開 Java 的課來誤人子弟了。
因為在 Programming Language 理論理,Call By Reference 有很嚴謹的定義,而 Java 的參數傳遞從頭到尾都只有 Call By Value 這種方式。
所以在看這篇文章正文之前,請先跟著我唸五遍:
- 從盤谷開天闢地開始,Java 的參數傳遞就只有 Call By Value 一種
- 從盤谷開天闢地開始,Java 的參數傳遞就只有 Call By Value 一種
- 從盤谷開天闢地開始,Java 的參數傳遞就只有 Call By Value 一種
- 從盤谷開天闢地開始,Java 的參數傳遞就只有 Call By Value 一種
- 從盤谷開天闢地開始,Java 的參數傳遞就只有 Call By Value 一種
很多人會覺得 Java 傳遞參數時的行為很複雜難懂,就是因為誤以為 Java 的參數傳遞有很多種。
但當你看完這篇之後,就會發現不管你傳的是什麼東西,Java 的行為通通是相同的--所以請僅記,Java 的參數傳遞方法就只有 Call By Value 這唯一的一種。
一樣是整數的交換的函式
上一篇我們的第一個例子,講的是 C 語言的整數交換的範例,如果把他改寫成 Java 版的話,就會像下面這樣:
public class SwapInt
{
public static void swap(int x, int y)
{
int tmp = x;
x = y;
y = tmp;
}
public static void main(String [] args)
{
int x = 3;
int y = 5;
swap(x, y);
System.out.println("x = " + x + ", y = " + y);
}
}
如果你是從這系列的第一篇看起,一直看到現在,你現在應該知道兩件事了:
- 區域變數是存在 Stack 上
- 因為 Java 是 Call By Value,所以會把傳入的值複製一份到 swap() 的函式在 Stack 上被配置到的空間。
所以現在的你,也應該馬上可以回答出來,這個程式的執行結果是:
x = 3, y = 5
因為如果我們再次把整個執行過程的記憶體裡 Stack 的狀態畫出來的話,會發現他和 C 語言版的 swap() 一模一樣:
如果我就是要更動原本的變數的內容呢?
很簡單--Java 做不到!
因為 Java 當中的 Reference(實際上就是 Pointer),就只能指到 Heap 上的 Java 物件,而沒有辦法指到位於 Stack 上的變數。
在這樣的情況下,而 Java 的參數傳遞方式又只有 Call By Value 這一種,所以你永遠不可能更動到 main() 裡的 x 和 y 的值。
可是書上都說用物件包起來就好耶?
我知道,你一定會跟我說:
「嗨,我只要把這個整數用個物件包起來,這樣就可以交換 main() 裡的 x 和 y 啦!」
聽起來好像沒錯,所以我們來實作一個把整數包在物件裡的版本好了:
public class MyObject
{
int value;
public MyObject(int value)
{
this.value = value;
}
public static void swap(MyObject x, MyObject y)
{
int tmp = x.value;
x.value = y.value;
y.value = tmp;
}
public static void main(String [] args)
{
MyObject x = new MyObject(3);
MyObject y = new MyObject(5);
swap(x, y);
System.out.println("x = " + x.value + ", y = " + y.value);
}
}
你看,現在印出來的就是 x = 5, y = 3 啦!我們把兩個整數交換了啊!
沒錯,他的確印出了 x = 5, y = 3 的訊息,可是你有仔細想過你改到的到底是什麼東西嗎?現在就讓我們來認真瞧瞧看這段程式發生了什麼事情。
首先要回答的就是,當程式一開始跑,執行到 main() 裡的第二行時,記憶體裡的狀態到底是怎樣?
現在的你應該已經可以很容易回答出來了--在 Stack 上的 main() 裡面,有著 x 和 y 兩個變數,存的是 Heap 上的兩個 MyObject 物件所在的記憶體地址,所以可以畫出像下面的圖:
接著,我們一樣要思考的是 swap(MyObject x, MyObject y) 裡的 x 和 y 是什麼,又放在哪裡?
現在的我們,已經知道 x 和 y 既然不是那八個 primitive type,那他們就一定是存放記憶體地址的 Reference,再加上我們也知道 Java 只有 Call By Value,所以這兩個變數一定是放在 Stack 上!
有了這樣的認知,就可以畫出像下面這樣的圖:
那現在 swap() 裡的 x 和 y 到底會被放進什麼呢?還記得 Java 是 Call By Value,而我們傳進去的 x 和 y 存的又是「存記憶體地址」嗎?
沒錯!在呼叫 swap(x, y) 的時候,會把 main() 裡的 x 和 y 所存的記憶體地址,複製一份到 swap() 上的 x 和 y 當中,所以現在我們知道當進入 swap() 函式的時候,記憶體狀態如下所示:
接著 int tmp = x.value 這一行,會把 swap() 上 x 指到的物件裡的 value 變數的值複製一份到 tmp 裡面:
接著執行 x.value = y.value,此時的記憶體狀態如下:
之後的 y.value = tmp 再把 swap() 上的 tmp 指定給 swap() 上的 y 所指到的物件當中的 value 成員變數:
最後 swap() 函式返回,Stack 上配置給 swap() 函式用的空間被清空:
這個時候,你會發現如果我們在 main() 裡把 x.value 和 y.value 把 x 和 y 印出來,就會看起來好像 x 和 y 兩個變數交換了!
但是請注意一件事--
在上面的五張圖當中,main() 裡的 ``x`` 和 ``y`` 的內容從來都沒被更改過,一直都分別是 0x00010 和 0x00011!
你交換的是 x 和 y 所指到的物件的狀態,而不像 C 語言的指標那樣,直接改變了 Stack 上的 x 和 y 這兩個變數本身自己的內容!
Java 傳遞的不是「物件」
你可能常常會聽到有人告訴你「Java 在傳遞物件時的行為是……blah blah blah」之類的,但實際上這句話有非常大的問題--Java 的參數傳遞不會傳物件!
嚴格來說,在 Java 裡你只能傳以下九種資料給函式(方法):
- byte
- short
- int
- long
- float
- double
- char
- boolean
- 記憶體地址(也就是 Java 當中的 Reference 的「值 / Value」)
Note
這裡最主要的觀念,是 Java 裡的 Reference 實際上就只是一個存放記憶體地址的東西,他和一般的整數並沒有什麼不同,都是一種「數值」,而我們在做函式傳遞時,是把那個「數值」複製一份到新的地方。
重申一次!Java 不會傳遞物件!而且參數的傳遞方式就只有 Call By Value 這一種!也就是當你把上面的九種資料傳給某個函式時,他們的內容都會被複製一份到 Stack 上該函式所屬的空間當中。
接下來,我們來看看下面的程式會發生什麼事:
public class MyObject
{
int value;
public MyObject(int value)
{
this.value = value;
}
public static void test(MyObject x)
{
x = new MyObject(10);
}
public static void main(String [] args)
{
MyObject x = new MyObject(3);
test(x);
// 請問這一行會印出什麼
System.out.println("x = " + x.value);
}
}
同樣的,要回答這個問題的關鍵,是 x = new MyObject(10) 這行,到底在記憶體裡動了什麼手腳。
而現在的我們已經知道:
- test 的 x 是放在 Stack 屬於 test() 自己的區塊上,存的是記憶體地址
- new MyObject(10) 會在 Heap 上產生一個新的物件,並返回他的記憶體地址
所以如果我們把整個流程畫出來,就會如下所示:
現在,你可以知道為什麼印出來的會是 x = 3 而不是 x = 10 了吧?
同時你也應該會發現,這樣子做的話,實際上會在 Heap 上產生一個垃圾物件,因為只要你一離開了 test() 函式,就沒有人可以尋著任何 Reference 拿到你在 test() 裡 new 出來的 MyObject 囉。
傳 Java 陣列時發生什麼事
Java 的陣列到底是什麼
和 C 語言一樣,當我們在看 Java 中丟一個陣列給函式會發生什麼事的時候,要先了解 Java 的陣列是什麼才行。
public class Test
{
public static void main (String [] args) {
int [] x = {1, 2, 3};
}
}
注意!問題來了!上面的 Java 程式裡的 x 是什麼?!
「我知道,是一個長度為 3 的整數陣列!」
錯!錯!錯!老師在講你都沒有在聽嘛--我們之前就看過 Java 對於「物件」這兩個字的定義了:
An object is a class instance or an array.
沒錯,在 Java 中陣列是個物件,而物件是存在 Heap 上的,然後在 Java 裡要操作 Heap 上的物件就只能依靠名叫 Reference 的 Pointer,所以這個問題的標準答案應該是:
「x 是一個指向長度為 3 的整數陣列的 Reference」
換句話說,在這個程式裡,我們的記憶體長相如下:
傳陣列到函式裡
只要你弄懂了上面所說的 Java 的陣列其實是物件,和上述的程式碼裡的 x 實際上是 Reference 後,其參數的傳遞好像就沒啥好講的了,因為前面都提過了。
例如當你看到以下的程式碼:
public class Test
{
public static void addByOne(int [] y)
{
y[0] = y[0] + 1;
y[1] = y[1] + 1;
y[2] = y[2] + 1;
}
public static void main (String [] args) {
int [] x = {1, 2, 3};
addByOne(x);
System.out.println("x[0] = " + x[0]);
System.out.println("x[1] = " + x[1]);
System.out.println("x[2] = " + x[2]);
}
}
應該可以馬上一步一步畫出記憶體裡的狀態圖了:
然後你也應該很容易就回答得出來,這個程式印出來的會是以下的結果了:
x[0] = 2
x[1] = 3
x[2] = 4
小結
在這一篇裡面,我們提到了 Java 的參數傳遞方式,以對於 Java 的參數傳遞有多種模式這個常見的誤解。
而要了解這一切,最重要的一個概念就是 Java 只有 Call By Value,並且你能傳的就只有八種 Primitive Type 再加上記憶體位址這九種資料而已。
然而在這篇文章當中,我們討論的都僅止於 static method 的參數傳遞而已,如果是 instance method 的話 Java 的行為又會是如何呢?這會是我們下一篇討論的重點。
回響