Day 26:程式界的哈姆雷特 —— Pass by value, or Pass by reference?
(圖片素材來源: 網路圖片)
「To be or not to be, that is the question.」
這是莎士比亞經典《哈姆雷特》中,哈姆雷特王子的名句,是一個人生真諦的千古大哉問。
你知道程式世界裡也有一個萬年經典題嗎?
(圖片素材來源: 網路圖片)
「Pass by value, or pass by reference, that is the question.」
對程式開發者來說,一個程式語言的變數是 Pass by value 還是 Pass by reference,可是至關重要的問題。
變數內容的傳遞方式是 Pass by value (傳值) 或 Pass by reference (傳址),對於程式開發是一個很基本、但極為重要的觀念,直接影響著程式對變數的操作,相信大部分開發者也都不陌生。
那 JavaScript 是 Pass by value 還是 Pass by reference?為何有人說其實是 Pass by sharing?甚至有人說 JavaScript 只有 Pass by value?
今天就來探討這個萬年引戰題。

Pass by value vs. Pass by reference

在探討 JavaScript 是哪一者之前,照例先快速介紹一下問題本身:什麼是 Pass by value 和 Pass by reference

Pass by value (傳值)

以下是一個典型的 Pass by value 的例子:
1
var x = 5;
2
var y = x;
3
console.log(x); // 5
4
console.log(y); // 5
5
6
x = 10;
7
console.log(x); // 10
8
console.log(y); // 5
Copied!
  • 變數 x 被賦予 5 這個數值,然後透過 y = x 的方式為變數 y 賦值,因此 xy 一開始的變數內容都是 5
  • 接著針對 x 賦予新的值 10,此時 x10,但 y 仍是 5
為什麼改變 x 的值,y 會不受影響?
下面是一個概念性的示意圖:
因為當變數內容傳遞方式是 Pass by value (傳值),也就是將舊變數的值內容複製一份,放進一塊新的記憶體,讓新變數指向過去
以上面的例子來說,就是將變數 y 指向一塊新的記憶體,然後將變數 x 裡的值複製一份放進去,等於變數 xy 裡存放的是兩份獨立的資料,其中一份改變,不會影響另一份。

Pass by reference (傳址)

換來看一個Pass by reference的例子:
1
var person1 = { money: 111 };
2
var person2 = person1;
3
console.log(person1); // {money: 111}
4
console.log(person2); // {money: 111}
5
6
person1.money = 222;
7
console.log(person1); // {money: 222}
8
console.log(person2); // {money: 222}
Copied!
  • 變數 person1 賦予一個物件,透過 person2 = person1 的方式為 person2 賦值,person1person2 兩個物件的 money 屬性一樣都是 111
  • 針對 person1.money 更換值的內容,連 person2 的屬性也一起變動到。
下面是概念性的示意圖:
因為當變數內容傳遞方式是 Pass by reference (傳址),新變數會直接指向舊變數的記憶體位址,等於新舊變數共用同一個位址的資料
所以 person1person2 其實指向同一個物件,就像同一個房間有兩個入口,從哪一個入口進去,看到的都是同一個房間內容。因此,對 person1 作任何變動,也等同對 person2 作變動。

JavaScript 什麼時候是 Pass by value?什麼時候是 Pass by reference?

基本上看變數的資料型別,決定傳遞行為是 Pass by value 或 Pass by reference

當變數的值是原生型別 (Primitive) 時,行為是 Pass by value

在 JavaScript 中,原生型別 (Primitive) 包含:
  • String
  • Number
  • Boolean
  • Undefined
  • Null (註)
註:null 雖然用 typeof 運算的結果是回傳 object,但 null 在行為上歸屬原生型別。
以下是這五種型別的行為測試:
1
/* string */
2
var str1 = "111";
3
var str2 = str1;
4
console.log(str1); // "111"
5
console.log(str2); // "111"
6
7
str1 = "222";
8
console.log(str1); // "222"
9
console.log(str2); // "111"
10
11
12
/* number */
13
var n1 = 111;
14
var n2 = n1;
15
console.log(n1); // 111
16
console.log(n2); // 111
17
18
n1 = 222;
19
console.log(n1); // 222
20
console.log(n2); // 111
21
22
23
/* boolean */
24
var bool1 = true;
25
var bool2 = bool1;
26
console.log(bool1); // true
27
console.log(bool2); // true
28
29
bool1 = false;
30
console.log(bool1); // false
31
console.log(bool2); // true
32
33
34
/* null */
35
var nu1 = null;
36
var nu2 = nu1;
37
console.log(nu1); // null
38
console.log(nu2); // null
39
40
nu1 = "something";
41
console.log(nu1); // "something"
42
console.log(nu2); // null
43
44
45
/* undefined */
46
var ud1 = undefined;
47
var ud2 = ud1;
48
console.log(ud1); // undefined
49
console.log(ud2); // undefined
50
51
ud1 = "something";
52
console.log(ud1); // "something"
53
console.log(ud2); // undefined
Copied!

當變數的值是物件型別 (Object) 時,行為是 Pass by reference

在 JavaScript 中的物件型別常見的例如:
  • Array
  • Object
以下是這兩種型別的行為測試:
1
/* array */
2
var ary1 = [1, 2, 3];
3
var ary2 = ary1;
4
console.log(ary1); // [1, 2, 3]
5
console.log(ary2); // [1, 2, 3]
6
7
ary1[0] = 99;
8
console.log(ary1); // [99, 2, 3]
9
console.log(ary2); // [99, 2, 3]
10
11
12
/* object */
13
var person1 = { money: 111 };
14
var person2 = person1;
15
console.log(person1); // {money: 111}
16
console.log(person2); // {money: 111}
17
18
person1.money = 222;
19
console.log(person1); // {money: 222}
20
console.log(person2); // {money: 222}
Copied!

Pass by sharing 又是怎麼一回事?

以上到目前為止,一切看起來很單純美好,沒什麼問題:JavaScript 裡,原生型別 (Primitive) 是 Pass by value,物件型別 (Object) 是 Pass by reference。
為何會冒出一個 Pass by sharing 的說法?
問題出在以下情境:

1. 函數參數傳遞

1
function rename(obj){
2
obj = { name: "XXX" };
3
}
4
5
var person = { name: "OneJar" };
6
console.log(person);
7
rename(person);
8
console.log(person);
Copied!
上面這個範例,將物件 person 傳遞給函數 rename() 作為參數,並在 rename() 裡面改變值。
根據前面的結論,物件型別 (Object) 是 Pass by reference,那麼執行結果應該是先印 {name: "OneJar"} 再印 {name: "XXX"} 囉?
執行結果:
1
{name: "OneJar"}
2
{name: "OneJar"}
Copied!
(Source: 網路圖片)
怎麼好像和前面說好的不一樣?
上述例子可以再作抽離,縮小範圍,只看對 obj 變更值的部分。例子中是使用物件實字 (Object Literals) 的方式來變更值。

2. 使用陣列實字 (Array Literals) 和物件實字 (Object Literals) 重新賦值

1
/* array literals */
2
var ary1 = [1, 2, 3];
3
var ary2 = ary1;
4
console.log(ary1); // [1, 2, 3]
5
console.log(ary2); // [1, 2, 3]
6
7
ary1 = [99, 100];
8
console.log(ary1); // [99, 100]
9
console.log(ary2); // [1, 2, 3]
10
11
12
/* object literals */
13
var person1 = { money: 111 };
14
var person2 = person1;
15
console.log(person1); // {money: 111}
16
console.log(person2); // {money: 111}
17
18
person1 = { money: 222 };
19
console.log(person1); // {money: 222}
20
console.log(person2); // {money: 111}
Copied!
可以發現到,雖然是物件型別 (Object) 的變數,如果用實字 (Literals) 對變數作重新賦值,並不會連另一個變數一起變更

3. 使用第三方變數作重新賦值

即使不是直接用實字 (Literals),而是透過第三方變數去作重新賦值,同樣不會影響到第二個變數:
1
/* array re-assigned */
2
var ary1 = [1, 2, 3];
3
var ary2 = ary1;
4
console.log(ary1); // [1, 2, 3]
5
console.log(ary2); // [1, 2, 3]
6
7
var ary3 = [99, 100];
8
ary1 = ary3;
9
console.log(ary1); // [99, 100]
10
console.log(ary2); // [1, 2, 3]
11
console.log(ary3); // [99, 100]
12
13
14
/* object re-assigned */
15
var person1 = { money: 111 };
16
var person2 = person1;
17
console.log(person1); // {money: 111}
18
console.log(person2); // {money: 111}
19
20
var person3 = { money: 333 };
21
person1 = person3;
22
console.log(person1); // {money: 333}
23
console.log(person2); // {money: 111}
24
console.log(person3); // {money: 333}
Copied!

Pass by reference 和 Pass by value 的融合版

從前面例子發現到,雖然是物件型別 (Object) 的變數,如果是對物件變數作重新賦值,只會變更自己的值,不會連另一個變數一起變更。
這和前面提到的 Pass by reference 行為似乎不太一樣,反而有點像 Pass by value。
如此一來,稱為 Pass by reference 也不對,稱為 Pass by value 也不對,於是就出現了 Pass by sharing 的說法。
(Source: 網路圖片)
不少人將 JavaScript 的變數內容傳遞方式,稱為 Pass by sharing
  • 碰到原生型別 (Primitive),表現行為是 Pass by value。
  • 碰到物件型別 (Object),如果只是對物件內容作操作(例如陣列元素或物件屬性),表現行為是 Pass by reference。
  • 碰到物件型別 (Object),如果對物件作重新賦值,表現行為是 Pass by value。
或者也有人視為:JavaScript 的 Primitive 是 Pass by Value,Object 是 Pass by sharing

JavaScript 只有 Pass by value?

那為何有人說 JavaScript 只有 Pass by value?
這要稍微牽涉到變數的記憶體儲存方式。

我不是骨阿默,但我今天還是要來說一個電腦資料儲存原理的故事

電腦在執行程式的過程,是將資料暫存在記憶體裡,以供運算過程使用。
(Source: 網路圖片)
對電腦來說,記憶體每一個空間都有對應的位址,就像住址編號一樣。
想像一下,如果大家寫程式都得這樣:
1
0x002 = 10;
2
0x085 = 5;
3
0x108 = 0x002 + 0x085;
4
console.log(0x108);
5
.........
6
.........
Copied!
應該會有 87% 的程式開發者想要逃坑。
(Source: 進擊的巨人)
還好,一個偉大的發明拯救了程式苦海中的眾生:變數
宣告變數,就是跟記憶體要某一個位址的空間來存放資料
在高階程式語言裡一定有支援變數,讓開發者能用更具可讀性的名稱來儲存資料。
對電腦來說,實際運作和辨認資料位置是以「位址」為準;變數名稱只是為了方便人類呼叫、類似別名的存在,由程式幫忙和實際的資料儲存位址作連結。
而我們透過變數儲存資料時,由於電腦對記憶體儲存資料有其機制,不同的資料型別會有不同儲存方式
以 JavaScript 來說會有兩種: 1. 資料是原生型別 (Primitive) 時,變數在記憶體內儲存的是「資料的內容」,也就是我們常稱的 Pass by value。 2. 資料是物件型別 (Object) 時,變數在記憶體內儲存的是「資料的位址」,通過這個位址,可以找到實際儲存資料的地方,也就是我們常稱的 Pass by reference
筆者按:這裡的介紹比較偏向概念性,大幅簡化細節,實際上作業系統底層對記憶體的操控機制沒那麼簡單。但對於上層高階語言來說,這樣概念性的理解應該足夠應付一般開發所需。

為什麼有人認為 JavaScript 只有 Pass by value?

我們先看原生型別 (Primitive) 的狀況:
1
var x = 5;
2
var y = x;
3
console.log(x); // 5
4
console.log(y); // 5
5
6
x = 10;
7
console.log(x); // 10
8
console.log(y); // 5
Copied!
再來看物件型別 (Object) 的狀況:
1
var x = { money: 111 };
2
var y = person1;
3
console.log(x); // {money: 111}
4
console.log(y); // {money: 111}
5
6
x.money = 222;
7
console.log(x); // {money: 222}
8
console.log(y); // {money: 222}
Copied!
從底層實際運作的角度來看,不管是原生型別 (Primitive) 或物件型別 (Object),在作 x = y 這個動作時,都是把 x 內儲存的值複製一份到 y 裡 (不管這個值的內容是一個數值或一個位址)。
這就是為什麼有人認為 JavaScript 只有 Pass by value。

萬年引戰的癥結點:人人定義不同

Pass by value 或 Pass by reference 直接關係著程式的變數運作,而且是每個程式語言都會碰到,不是 JavaScript 獨有。
這種理當在程式語言開天闢地時代就遇到的基本議題,怎麼會到今日仍爭議不斷?
我覺得這篇文章 —— 深入探討 JavaScript 中的參數傳遞:call by value 還是 reference? —— 點出一個癥結點:
大家對 call by reference 以及 call by value 的「定義」其實都不盡相同,而且也沒有一個權威性的出處能夠保證這個定義是正確的。
例如所謂 Value,指的究竟是「資料的內容」,還是「存放在變數記憶體位址裡的值」?
以下面變數 nobj 來說:
1
var n = 123;
2
var obj = { name: "OneJar" };
Copied!
變數
資料的內容
存放在變數記憶體位址裡的值
n
123
123
obj
{ name: "OneJar" }
類似 0x0001 這樣的記憶體位址
  • 如果 Value 指的是「資料的內容」
    • 物件 (Object) 間的 obj1 = obj2 傳值,就屬於 Reference 的複製,而非 Value 的複製。
    • 因此有 Pass by value 和 Pass by reference 分別。
  • 如果 Value 指的是「存放在變數記憶體位址裡的值」
    • 所有的 x = y 傳值都屬於 Value 的複製。
    • 因此只有 Pass by value。
這就是最初的定義不同、觀點不同,導致最後的結論不同
所以下面哪一句是對的: 1. JavaScript 只有 Pass by value。 2. JavaScript 的 Primitive 是 Pass by Value,Object 是 Pass by sharing。
答案都是對的,視乎立足的定義和觀點。
傳統 Java 在教變數資料型態時,Pass by Value 和 Pass by Reference 一直是一大考題。但如果從「Value 指的是存放在變數記憶體位址裡的值」的角度來看,這些考題全都該丟垃圾桶,因為都只有 Pass by Value。
其實不只是 Pass by Value 和 Pass by Reference 有這種名詞定義上的困擾,各種 MV-Whatever 名詞定義也是爭吵不休。
會有這麼多技術名詞上的歧義,就像這篇文章所言:
程式開發的世界中,名詞的創造經常是隨意的。
這些名詞通常沒有明確的定義,一開始只是為了溝通方便,而隨著聽聞者各自的領會與轉述,加上時間與歷史的推移,它們的定義會發生岐義而各自發展,最後失去了溝通的意義。

所以…… JavaScript 到底 Pass by WTF

技術名詞紛爭多這篇文章最後總結得很好:
在《松本行弘的程式設計世界》的〈語彙與共通語言的重要性〉這篇文章中,作者談到,為某個概念決定適當的名詞,目的是在設計時能有共同的語彙,也能讓開發者意識到它們的存在,這才是名詞存在的真正意義。
技術名詞是為了方便溝通,不是為了吵架
(Source: 網路圖片)
技術名詞的概念統一定義很重要,因為能讓大家在一個共通的語彙概念上溝通或學習。
這就是為什麼許多負責訂定標準或名詞定義的權威單位受人重視,他們所訂的名詞、定義等標準未必所有人都同意,但大部分的人願意接受和信服,因此大家一起遵循這樣的標準,使所有人能在同樣的理解共識下工作。
但如果為了在技術名詞上鑽牛角尖,反而把自己越弄越糊塗,變成為了捍衛技術名詞的存在而存在,我認為是本末倒置。
技術名詞是為了描述概念而存在,而不是概念為了技術名詞而存在。
最重要的是背後期望表達的概念,也就是體現出來的「行為」。

References