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 的例子:

var x = 5;
var y = x;
console.log(x);     // 5
console.log(y);     // 5

x = 10;
console.log(x);     // 10
console.log(y);     // 5
  • 變數 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的例子:

var person1 = { money: 111 };
var person2 = person1;
console.log(person1);  // {money: 111}
console.log(person2);  // {money: 111}

person1.money = 222;
console.log(person1);  // {money: 222}
console.log(person2);  // {money: 222}
  • 變數 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 在行為上歸屬原生型別。

以下是這五種型別的行為測試:

/* string */
var str1 = "111";
var str2 = str1;
console.log(str1); // "111"
console.log(str2); // "111"

str1 = "222";
console.log(str1); // "222"
console.log(str2); // "111"


/* number */
var n1 = 111;
var n2 = n1;
console.log(n1); // 111
console.log(n2); // 111

n1 = 222;
console.log(n1); // 222
console.log(n2); // 111


/* boolean */
var bool1 = true;
var bool2 = bool1;
console.log(bool1); // true
console.log(bool2); // true

bool1 = false;
console.log(bool1); // false
console.log(bool2); // true


/* null */
var nu1 = null;
var nu2 = nu1;
console.log(nu1); // null
console.log(nu2); // null

nu1 = "something";
console.log(nu1); // "something"
console.log(nu2); // null


/* undefined */
var ud1 = undefined;
var ud2 = ud1;
console.log(ud1); // undefined
console.log(ud2); // undefined

ud1 = "something";
console.log(ud1); // "something"
console.log(ud2); // undefined

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

在 JavaScript 中的物件型別常見的例如:

  • Array

  • Object

以下是這兩種型別的行為測試:

/* array */
var ary1 = [1, 2, 3];
var ary2 = ary1;
console.log(ary1); // [1, 2, 3]
console.log(ary2); // [1, 2, 3]

ary1[0] = 99;
console.log(ary1); // [99, 2, 3]
console.log(ary2); // [99, 2, 3]


/* object */
var person1 = { money: 111 };
var person2 = person1;
console.log(person1);  // {money: 111}
console.log(person2);  // {money: 111}

person1.money = 222;
console.log(person1);  // {money: 222}
console.log(person2);  // {money: 222}

Pass by sharing 又是怎麼一回事?

以上到目前為止,一切看起來很單純美好,沒什麼問題:JavaScript 裡,原生型別 (Primitive) 是 Pass by value,物件型別 (Object) 是 Pass by reference。

為何會冒出一個 Pass by sharing 的說法?

問題出在以下情境:

1. 函數參數傳遞

function rename(obj){
    obj = { name: "XXX" };
}

var person = { name: "OneJar" };
console.log(person);
rename(person);
console.log(person);

上面這個範例,將物件 person 傳遞給函數 rename() 作為參數,並在 rename() 裡面改變值。

根據前面的結論,物件型別 (Object) 是 Pass by reference,那麼執行結果應該是先印 {name: "OneJar"} 再印 {name: "XXX"} 囉?

執行結果:

{name: "OneJar"}
{name: "OneJar"}

怎麼好像和前面說好的不一樣?

上述例子可以再作抽離,縮小範圍,只看對 obj 變更值的部分。例子中是使用物件實字 (Object Literals) 的方式來變更值。

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

/* array literals */
var ary1 = [1, 2, 3];
var ary2 = ary1;
console.log(ary1); // [1, 2, 3]
console.log(ary2); // [1, 2, 3]

ary1 = [99, 100];
console.log(ary1); // [99, 100]
console.log(ary2); // [1, 2, 3]


/* object literals */
var person1 = { money: 111 };
var person2 = person1;
console.log(person1);  // {money: 111}
console.log(person2);  // {money: 111}

person1 = { money: 222 };
console.log(person1);  // {money: 222}
console.log(person2);  // {money: 111}

可以發現到,雖然是物件型別 (Object) 的變數,如果用實字 (Literals) 對變數作重新賦值,並不會連另一個變數一起變更

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

即使不是直接用實字 (Literals),而是透過第三方變數去作重新賦值,同樣不會影響到第二個變數:

/* array re-assigned */
var ary1 = [1, 2, 3];
var ary2 = ary1;
console.log(ary1); // [1, 2, 3]
console.log(ary2); // [1, 2, 3]

var ary3 = [99, 100];
ary1 = ary3;
console.log(ary1); // [99, 100]
console.log(ary2); // [1, 2, 3]
console.log(ary3); // [99, 100]


/* object re-assigned */
var person1 = { money: 111 };
var person2 = person1;
console.log(person1);  // {money: 111}
console.log(person2);  // {money: 111}

var person3 = { money: 333 };
person1 = person3;
console.log(person1);  // {money: 333}
console.log(person2);  // {money: 111}
console.log(person3);  // {money: 333}

Pass by reference 和 Pass by value 的融合版

從前面例子發現到,雖然是物件型別 (Object) 的變數,如果是對物件變數作重新賦值,只會變更自己的值,不會連另一個變數一起變更。

這和前面提到的 Pass by reference 行為似乎不太一樣,反而有點像 Pass by value。

如此一來,稱為 Pass by reference 也不對,稱為 Pass by value 也不對,於是就出現了 Pass by sharing 的說法。

不少人將 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?

這要稍微牽涉到變數的記憶體儲存方式。

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

電腦在執行程式的過程,是將資料暫存在記憶體裡,以供運算過程使用。

對電腦來說,記憶體每一個空間都有對應的位址,就像住址編號一樣。

想像一下,如果大家寫程式都得這樣:

0x002 = 10;
0x085 = 5;
0x108 = 0x002 + 0x085;
console.log(0x108);
.........
.........

應該會有 87% 的程式開發者想要逃坑。

還好,一個偉大的發明拯救了程式苦海中的眾生:變數

宣告變數,就是跟記憶體要某一個位址的空間來存放資料

在高階程式語言裡一定有支援變數,讓開發者能用更具可讀性的名稱來儲存資料。

對電腦來說,實際運作和辨認資料位置是以「位址」為準;變數名稱只是為了方便人類呼叫、類似別名的存在,由程式幫忙和實際的資料儲存位址作連結。

而我們透過變數儲存資料時,由於電腦對記憶體儲存資料有其機制,不同的資料型別會有不同儲存方式

以 JavaScript 來說會有兩種: 1. 資料是原生型別 (Primitive) 時,變數在記憶體內儲存的是「資料的內容」,也就是我們常稱的 Pass by value。 2. 資料是物件型別 (Object) 時,變數在記憶體內儲存的是「資料的位址」,通過這個位址,可以找到實際儲存資料的地方,也就是我們常稱的 Pass by reference

筆者按:這裡的介紹比較偏向概念性,大幅簡化細節,實際上作業系統底層對記憶體的操控機制沒那麼簡單。但對於上層高階語言來說,這樣概念性的理解應該足夠應付一般開發所需。

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

我們先看原生型別 (Primitive) 的狀況:

var x = 5;
var y = x;
console.log(x);     // 5
console.log(y);     // 5

x = 10;
console.log(x);     // 10
console.log(y);     // 5

再來看物件型別 (Object) 的狀況:

var x = { money: 111 };
var y = person1;
console.log(x);  // {money: 111}
console.log(y);  // {money: 111}

x.money = 222;
console.log(x);  // {money: 222}
console.log(y);  // {money: 222}

從底層實際運作的角度來看,不管是原生型別 (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 來說:

var n = 123;
var obj = { name: "OneJar" };

變數

資料的內容

存放在變數記憶體位址裡的值

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

技術名詞紛爭多這篇文章最後總結得很好:

在《松本行弘的程式設計世界》的〈語彙與共通語言的重要性〉這篇文章中,作者談到,為某個概念決定適當的名詞,目的是在設計時能有共同的語彙,也能讓開發者意識到它們的存在,這才是名詞存在的真正意義。

技術名詞是為了方便溝通,不是為了吵架

技術名詞的概念統一定義很重要,因為能讓大家在一個共通的語彙概念上溝通或學習。

這就是為什麼許多負責訂定標準或名詞定義的權威單位受人重視,他們所訂的名詞、定義等標準未必所有人都同意,但大部分的人願意接受和信服,因此大家一起遵循這樣的標準,使所有人能在同樣的理解共識下工作。

但如果為了在技術名詞上鑽牛角尖,反而把自己越弄越糊塗,變成為了捍衛技術名詞的存在而存在,我認為是本末倒置。

技術名詞是為了描述概念而存在,而不是概念為了技術名詞而存在。

最重要的是背後期望表達的概念,也就是體現出來的「行為」。

References

Last updated