Try   HackMD

JavaScript - 傳值 ( by Value )、傳址 ( by Reference )、傳共享 ( by sharing )

tags: JavaScript 六角學院 JS直播班

開始之前

在學習 JavaScript 路上,免不了要熟悉所謂 傳值 ( call by value ) 以及 傳址 ( call by reference ) 的觀念 (當初因為不熟悉這鬼東西放棄JS好幾次)

先感謝 六角學院 讓我透過直播班有機會探討這個主題,寫下第一篇公開的技術筆記。

這篇筆記我會以比較實作的方式,探討 變數 ( Variable )、記憶體位址 ( Memory Address )、值 ( Value ) 之間的三角關係,讓我們開始吧 !

基本型別 ( Primity type )

先讓我們暖身一下 :

let a = 10; a += 1; console.log(a); // ?

這個答案應該顯而易見

>> 點我看答案 <<

a 的值會是 11



那麼在記憶體中,這是怎麼呈現的呢 ? 讓我們來畫個圖表 !

步驟 1. let a = 10;

變數 記憶體位址
0x0 10
a 0x1 0x0

其實這圖表就是在模擬 變數記憶體 的存取流程,記憶體位址 0x0、0x1、0x2,都是代表記憶體的某個空間位址,就像是你家的門牌啦 !

步驟 1 會先在記憶體的某個空間位址,存放一個 10 的值,然後再將 變數 a 的值,指向這個 10 所在的記憶體位址,所以此時 console.log(a),會印出 number 型別的 10。

りしれしゃさ小
如果覺得太難懂,沒關係這不是你的問題,我來說個故事…

今天有一間教室 (記憶體),教室裡某個桌上 ( 0x0 ),放了 10 塊錢 ( number 10 ),看了一下這桌子是誰的,原來是 a 同學的 ( 變數 a 的值指向 0x0 ),也就是 a 同學擁有這 10 塊錢 ( let a = 10 )。


步驟 2. a += 1

變數 記憶體位址
0x0 10
a 0x1 0x3
0x2 1
0x3 11

此時 a 同學走在走廊上 ( 0x2 ),發現走廊地板有 1 塊錢 ( 0x2 的值為 1 ),於是 a 同學撿起來收進口袋 ( 變數 a 的值與 0x2 的值相加運算 ),所以 a 同學此時有 11 塊 ( 在新的記憶體空間放進運算後的結果 11 這個值,並將變數 a 的值重新賦值指向至 0x3,求得值為 11 )

什麼 !? 你說我這樣說你聽不懂 ?

-

對不起 〒▽〒



暖身完畢,緊接著我們來延伸上一個例子 :

let a = 10; let b = a; console.log(b); // 10

這個例子我們可以很直觀講出 console 印出來的結果是 10,因為 b 等於 a 嘛 !
那我們來多加一行程式碼,將 a 重新賦值看看 :

let a = 10; let b = a; a = 20; console.log(b); // ?

過去初嚐 Javascript 的我,很直覺地說出「 那麼簡單,這我知道 ! b 會印出 20 啊,因為 b 等於 a,現在 a 重新給他一個 20 的值,所以 b 也是 20 對啊 ! 」
然後我默默開啟 devtools 的 console 試試,試完後,我表情面有難色,心裡OS : WTF

>> 點我看答案 <<

Image Not Showing Possible Reasons
  • The image file may be corrupted
  • The server hosting the image is unavailable
  • The image path is incorrect
  • The image format is not supported
Learn More →

b 還是 10 呢 (然後就放棄 JS 了)


修但幾勒 先不要這麼快放棄,我們一樣畫個圖表來一個一個步驟看 :

步驟 1. let a = 10;

變數 記憶體位址
0x0 10
a 0x1 0x0

步驟 1 跟上一個例子的步驟 1 一樣,但我們換個故事來說

今天有一棟宿舍 (記憶體),在這棟宿舍發現有 10 個比特幣在某房間,看了一下房號,原來是 0x0 號 (記憶體位址) 啊 ! 這時來了一個人,他是 a 先生 ( let a ),a 先生表示這房間 ( 0x0 ) 是他租的 ( 變數 a 的值指向 0x0 ),也就是 a 先生擁有房間裡面這 10 個比特幣 ( a = 10 ) (羨慕


接下來步驟 2. let b = a;

變數 記憶體位址
0x0 10
a 0x1 0x0
b 0x2 0x0

此時跳出一個 b 先生 ( let b ),b 先生說他也租這房間 ( 變數 b 複製變數 a 的值 0x0 ),也就是 a 先生和 b 先生是室友,他們其實是共同擁有這 10 個比特幣 ( a === b // true ) (還是羨慕


最後步驟 3. a = 20;

變數 記憶體位址
0x0 10
a 0x1 0x3
b 0x2 0x0
0x3 20

然後又在這棟宿舍的某個房間發現到 20 個比特幣,看一下房號,是 0x3 號 (記憶體位址),a 看到有人發現這 20 個比特幣,手忙腳亂地衝過來解釋說:「哈哈是我的啦」,原來 a 先生日前跟 b 先生吵架,已經不租那間房間了,還跟 b 先生說:「沒關係那 10 個比特幣送你,我才不稀罕勒」 (拜託送我),反正 a 先生還有藏在 0x3 號房的 20 個比特幣 ( 變數 a 重新賦值指向到 0x3 )

從上面的故事最後可以得知,a 先生擁有 20 個比特幣 ( 變數 a 的值是 20 ),b 先生擁有 10 個比特幣 ( 變數 b 的值是 10 )

Image Not Showing Possible Reasons
  • The image file may be corrupted
  • The server hosting the image is unavailable
  • The image path is incorrect
  • The image format is not supported
Learn More →

當然,值換成 字串型別 ( strig ) 也是一樣的結論 :

Image Not Showing Possible Reasons
  • The image file may be corrupted
  • The server hosting the image is unavailable
  • The image path is incorrect
  • The image format is not supported
Learn More →


理解之後,我們來做個稍微複雜的例子 :

let a = 'A'; let b = 'A'; let c = 'A'; b = 'B'; c = 'C'; a += b; a += c; console.log(a, b, c); // ?

( 截至 Alex JS30 Day14 影片片段 )

步驟1. 一樣先畫圖表,將程式碼 1~3 行在記憶體的位址畫上去 :

變數 記憶體位址
0x0 'A'
a 0x1 0x0
b 0x2 0x0
c 0x3 0x0

當然,為了控制記憶體不要大爆炸,宣告變數並賦予一個 基本型別 ( Primitive type ) 的值時,會去找記憶體有沒有一模模一樣樣的值,若有一樣的,就 複製 那個值的記憶體位址當作自己 ( 變數 ) 的值,就不會再重覆多做一個一樣的值,但若值是 物件型別 ( Object type ) 的話,代誌丟毋係哩修欸價里甘丹,稍後會來探討。


步驟2. 程式碼第 4 行 b = 'B';

變數 記憶體位址
0x0 'A'
a 0x1 0x0
b 0x2 0x4
c 0x3 0x0
0x4 'B'

變數 b 重新賦值指向到新的值 'B' 所在的位址 0x4


步驟3. 程式碼第 5 行 c = 'C';

變數 記憶體位址
0x0 'A'
a 0x1 0x0
b 0x2 0x4
c 0x3 0x5
0x4 'B'
0x5 'C'

變數 c 重新賦值指向到新的值 'C' 所在的位址 0x5


步驟4. 程式碼第 6 行 a += b;

變數 記憶體位址
0x0 'A'
a 0x1 0x6
b 0x2 0x4
c 0x3 0x5
0x4 'B'
0x5 'C'
0x6 'AB'

變數 a 原本的值是 'A' ( 0x0 ),而 b 的值是 'B' ( 0x4 ),字串相加連接後得到 'AB' 這個值 ( 在記憶體空間 0x6 產生出 'AB' ),再賦予給 a,此時 a 的值是 'AB' ( 0x6 )


步驟5. 程式碼第 7 行 a += c;

變數 記憶體位址
0x0 'A'
a 0x1 0x7
b 0x2 0x4
c 0x3 0x5
0x4 'B'
0x5 'C'
0x6 'AB'
0x7 'ABC'

變數 a 在上一個步驟完成後的值是 'AB' ( 0x6 ),而 c 的值是 'C' ( 0x5 ),字串相加連接後得到 'ABC' 這個值,再賦予給 a,此時 a 的值是 'ABC' ( 0x7 )

如此我們就能清楚得知 console.log(a, b, c) 的結果 :

>> 點我看答案 <<

印出 ABC B C



畫圖表步驟中,這種複製某個值的行為就是大家所通稱的 「傳值 ( call by value )」。

其實前面有提到一個關鍵字 : 複製 ( copy )

以下 基本型別 ( Primitive type ) 的值 :

  • 字串 string
  • 數字 number
  • 布林值 boolean
  • 空值 null
  • 未定義 undefined
  • 符號 symbol

在記憶體複製來複製去,就是 傳值 ( call by value ) 的概念,不過我認為這只是一個概念而已,實際上還是用畫圖表的記憶方式會最印象深刻。

講了那麼多基本型別的傳值,接下來就讓我們來探討 物件型別 ( Object type ) 怎麼在記憶體中運作吧 !

物件型別 ( Object type )

除了 基本型別 ( Primitive type ) 以外的值,都是屬於 物件型別 ( Object type )

常見的 物件 Object { }、陣列 Array [ ]、函式 function( ) {},皆是屬於物件型別

為什麼 陣列 Array函式 function 也是屬於物件型別 ? 關於這個問題,以後我們專門做一篇筆記講解

物件型別 ( Object type ) 皆是以 傳址 ( call by reference ) 的方式在記憶體中求值

其實上面這段話,單就技術名詞的定義解釋,是蠻有爭議的,怎麼說呢 ? 我們接下來做幾個實例 :

let person1 = { name: 'Tim', }; let person2 = person1; person1.name = 'Ray'; console.log(person2.name); // ?

啊啊我知道,別想再騙我了,person2 只是複製 person1 的值,所以這裡改了 person1 的 name 屬性,不會動到 person2,因為他們兩個是獨立的嘛 ! (得意)

>> 點我看答案 <<

devtools

( OS : !%@#$^@&@#%@&ˇˋ& )

答案是 'Ray'person2.name 的值也被動到了

每個初學 JS 都要被騙過一次的題目

什麼 ? 你問我有沒有被騙過 ?

當然被騙過,不然也不會放棄 JS 那麼多次



好,我知道你和我都一樣,心中有股怒火難以發洩,讓我們再用上面學到的畫圖表方式來拆解 :

步驟 1. let person1 = { name: 'Tim' };

變數 記憶體位址
0x0 { name: 'Tim' }
person1 0x1 0x0

這裡不難看出來,變數 person1 的值是指向 0x0 的物件 { name: 'Tim' }


步驟 2. let person2 = person1;

變數 記憶體位址
0x0 { name: 'Tim' }
person1 0x1 0x0
person2 0x2 0x0

變數 person2 複製變數 person1 的值指向 0x0 這個物件 { name: 'Tim' }


步驟 3. person1.name = 'Ray';

變數 記憶體位址
0x0 { name: 'Ray' }
person1 0x1 0x0
person2 0x2 0x0

現在我們要 修改 變數 person1 這個物件的屬性 name 的值為 'Ray'

這邊講 修改 是因為我們使用 特性存取( property access ),也就是 person1.name. 去取得值並且修改為 'Ray'( 或是使用 鍵值存取 key access : person1['name'],也是一樣 ),至於這個 'Ray' 到底有沒有多占用一個記憶體位址,我比較傾向理解是 'Ray' 霸佔了原本的 'Tim' 值所在的位置,然後因為 'Tim' 值沒有被使用到而被 JavaScript 的記憶體回收機制回收了,當然講這個太深入,而且我也不太懂,我們就先理解成修改或是覆蓋就好。

因為 person1 和 person2 都同樣指向 0x0 這個記憶體位址,因此當 0x0 位址的 name 屬性的值修改成 'Ray',person1 和 person2 的值也都同樣是 { name: 'Ray' } 這個物件。

這就是 JavaScript 中,大家所通稱的 「傳址 ( call by reference )」。


好像哪裡怪怪der

有沒有覺得步驟 2 很熟悉,跟我們講基本型別傳值的第一個例子,是一樣使用 複製 的方式,person2 複製 person1 的值,那那那 這不就是 「傳值 ( call by value )」 ?

meme1

其實這麼理解也是沒什麼問題的,之所以會稱作這是 call by reference ,是因為複製的值是 記憶體指向,所以如果修改到這個記憶體位址 ( 0x0 ) 的值,person1 和 person2 傳出來的值都會跟著被改變,就有那種 參考 ( reference ) 的意味在了。

你可能會問說,那 基本型別 ( Primitive type ) 不也是 複製記憶體指向 嗎 ?
我認為沒有錯,但其實基本型別的值若有變動,說修改比較不貼切,應該說是賦予新的值,這個新的值是會多占用一個記憶體空間的,也就可以稱作 傳址 ( by reference ) 其實是 傳值 ( by value ) 的其中一種方式。

但是為什麼不是在一個記憶體空間創建新的值,例如在 0x3 這個位址創建新的值是{ name: 'Ray'},然後 person1 的值為指向 0x3,與 person2 的物件彼此獨立呢 ?

別急,這不就來了嘛,讓我們修改一下剛剛那個例子 :

let person1 = { name: 'Tim', }; let person2 = person1; person1 = { name: 'Ray' }; console.log(person2.name); // ?

糾竟會印出什麼呢 ?

>> 點我看答案 <<

devtools

答案是 'Tim'

meme2



好,我知道你已經懶得吐槽了 廢話不多說直接畫圖表 !

步驟 1. let person1 = { name: 'Tim' };

變數 記憶體位址
0x0 { name: 'Tim' }
person1 0x1 0x0

變數 person1 的值一樣是指向 0x0 這個位址的值 ( 物件 { name: 'Tim' } )


步驟 2. let person2 = person1;

變數 記憶體位址
0x0 { name: 'Tim' }
person1 0x1 0x0
person2 0x2 0x0

變數 person2 一樣複製變數 person1 的值,指向 0x0 這個位址的值 ( 物件 { name: 'Tim' } )


步驟 3. person1 = { 'Ray' };

變數 記憶體位址
0x0 { name: 'Tim' }
person1 0x1 0x3
person2 0x2 0x0
0x3 { name: 'Ray' }

這邊就要注意了,當我們使用物件實體語法 ( Object literal ),也就是使用 { } 大括號包住 key and value ( { name: 'Ray' } ),是會額外多占用一個記憶體空間 ( 0x3 ),然後將變數 person1 重新賦值指向記憶體位址 0x3。

這種創建新的值,並修改 person1 記憶體指向的行為,不就是「傳值 ( by value )」的概念嗎 ?

也就是說,物件型別 ( Object type ) 同時存在 傳值傳址 兩個概念呢,同樣類似的情況也發生在 函式 ( Function ) 中,讓我們來試試 :

function add(a, b) { a += b; console.log(a); // 30 } let x = 10; let y = 20; add(x, y);

Function 內的 a 值為 30,怎麼求得的呢 ?

步驟 1. let x = 10;

變數 記憶體位址
0x0 10
x 0x1 0x0

步驟 2. let y = 20;

變數 記憶體位址
0x0 10
x 0x1 0x0
0x2 20
y 0x3 0x2

步驟 3. add(x, y);

變數 記憶體位址
0x0 10
x 0x1 0x0
0x2 20
y 0x3 0x2
0x4 0x0
a 0x5 0x4
0x6 0x2
b 0x7 0x6

我們知道 function 的引數是區域變數,執行 add(x, y); 時,會將 x 和 y 的值複製 ( 在 0x4 的空間複製 x 的值,在 0x6 的空間複製 y 的值 ),並傳給 function 的引數 a 與 b 承接 ( a 的值指向 0x4,0x4 又指向 0x0,同理套用到 b )


步驟 4. a += b;

變數 記憶體位址
0x0 10
x 0x1 0x0
0x2 20
y 0x3 0x2
0x4 0x0
a 0x5 0x8
0x6 0x2
b 0x7 0x6
0x8 30

將運算結果的值 30 存放到新的記憶體空間 0x8,並且變數 a 重新賦值指向到這個 0x8


這種 function 傳入基本型別的值,就是使用「傳值 ( call by value )」的概念。


如果 function 是傳入 物件 ( Object ) 呢 ?

function updateObj(insideObj) { insideObj.name = 'Ray'; } let outsideObj = { name: 'Tim' }; updateObj(outsideObj); console.log(outsideObj); // ?
>> 點我看答案 <<

devtools

看來 outsideObj 這個物件裡面屬性 name 對應的值 'Tim' 成功被修改'Ray' 了 !



畫圖拆解步驟 :

步驟 1. let outsideObj = { name: 'Tim' };

變數 記憶體位址
0x0 { name: 'Tim' }
outsideObj 0x1 0x0

步驟 2. updateObj(outsideObj);

變數 記憶體位址
0x0 { name: 'Tim' }
outsideObj 0x1 0x0
0x2 0x0
insideObj 0x3 0x2

執行 updateObj(outsideObj); 時,會將 outsideObj 的值複製 ( 在 0x2 的空間複製 outsideObj 的值 ),並傳給 function 的引數 insideObj 承接 ( insideObj 的值指向 0x2,0x2 又指向 0x0 )。


步驟 3. insideObj.name = 'Ray';

變數 記憶體位址
0x0 { name: 'Ray' }
outsideObj 0x1 0x0
0x2 0x0
insideObj 0x3 0x2

先前我們說過,insideObj.name 這種 特性存取( property access ) 方式,會取得值並且修改他,而 insideObj 的值依然是指向 0x2,並且 0x2 的值指向 0x0,藉由對 insideObj 的修改,outsideObj 的值同樣也是指向 0x0,求得的值也就會是 { name: 'Ray' }

所以這邊 function 就是使用「傳址 ( call by reference )」的概念。


那如果我偏偏要在 function 內把物件重新賦值呢 ?

function updateObj(insideObj) { insideObj = { name: 'Ray' }; console.log('insideObj: ', insideObj); // ? } let outsideObj = { name: 'Tim' }; updateObj(outsideObj); console.log('outsideObj: ', outsideObj); // ? console.log('insideObj: ', insideObj); // ?
>> 點我看答案 <<

devtools

outsideObj 這個物件沒有變動,而 insideObj 僅存在於 function 的塊狀作用域裡面,外面是取值不到的 ( insideObj is not defined )。

這該不會又是傳值 ( by value ) ?



拆解步驟 :

步驟 1. let outsideObj = { name: 'Tim' };

變數 記憶體位址
0x0 { name: 'Tim' }
outsideObj 0x1 0x0

步驟 2. updateObj(outsideObj);

變數 記憶體位址
0x0 { name: 'Tim' }
outsideObj 0x1 0x0
0x2 0x0
insideObj 0x3 0x2

到目前為止都與上一個例子的步驟一樣。


步驟 3. insideObj = { name: 'Ray' };

變數 記憶體位址
0x0 { name: 'Tim' }
outsideObj 0x1 0x0
0x2 0x0
insideObj 0x3 0x4
0x4 { name: 'Ray' }

有沒有很熟悉,這就是先前提到,使用物件實體語法 ( Object literal ),也就是使用 { } 大括號包住 key and value ( { name: 'Ray' } ),額外多占用一個記憶體空間 ( 0x4 ),然後將變數 insideObj 重新賦值指向 0x4,此時 insideObjoutsideObj 就是兩個獨立的物件了,而 insideObj 只獨立在這個 function 塊狀作用域裡,outsideObj 則是在這個 function 外都能取值。

總結

我們現在知道,物件型別 ( Object type ) 可以 call by value 也可以 call by reference,所以也有人稱此為 :

傳共享 ( call by sharing )

也就是根據物件型別 ( Object、Array、Function ) 操作的「行為」不同,會求得不一樣的結果。

meme3

相信你跟我一樣有同樣的感受 ╰(‵□′)╯

不過我認為,不管是傳值、傳址、傳共享,充其量都只是一種概念,是方便我們在溝通時所使用的代名詞,但也因為對名詞的定義每個人都不盡相同,因此產生許多爭議。

所以我皆是以拆解步驟,並畫圖表的方式嘗試去理解 變數、值、記憶體 之間的三角關係
上面所畫的圖表,在 JavaScript 並不是百分之百如此運作,但就是一個方便我們學習、記憶的方式,真的要深入的話,還需要探討到 Stack、Heap、淺拷貝、深拷貝,甚至是原型鍊這我還不知道是什麼碗糕小的東東

以上這篇筆記是小弟我對 傳值 ( call by value )、傳址 ( call by reference )、傳共享 ( call by sharing ) 粗淺的見解,若筆記中其中有矛盾或錯誤的地方,還請留言指教 `(>﹏<)′

參考資料