ㄟ問你喔,傳值(Pass by Value)、傳址(Pass by Reference) 到底在傳什麼?
前情提要:要弄清楚傳值和傳址這兩者之間的差異,其中最重要的關鍵有三:1. 必須先能分清楚「值」有分物件型別、原始型別
2. 變數在賦予值的時候,電腦記憶體空間是如何運作的
3. 有沒有進行「重新賦予值」這個動作
基本上掌握這三點,那麽這個問題大概就先理清了一大半。
所以,在要回答這個問題之前,是不是應該先問,
什麼是值 (Value)?
先前在分辨 null 與 undefined 的差異這篇文章內,已討論過何謂「值」。
所謂的「值」,就是在變數裡存進去的一筆資料,而這資料又分為物件型別與原始型別,可參考以下示意圖。
要如何運用「值」?
舉例來說,今天有一個人名叫 Karen,他說他目前最喜歡的歌手叫做 Bruno Major,這一筆資料的值是一個人名,型別是「字串」,於是,Karen 最喜歡的歌手是 Bruno Major 這一筆資料,就儲存在 karenFavoriteSinger 這個變數裡了:
let karenFavoriteSinger = 'Bruno Major';
什麼是「傳值」?
此時,又來了一個 Karen 的同學 Yang,Yang 說好巧啊,他最喜歡的歌手也是 Bruno Major!所以他們兩個都很喜歡同一個歌手,於是可以這樣寫:
let yangFavoriteSinger = karenFavoriteSinger; //傳值 console.log(yangFavoriteSinger); // 回傳 Bruno Major
又過了幾天,Karen 突然在 Watermelon Music 聽到了一首超棒的歌,噢,結果他很興高采烈地跟 Yang 分享這件事,他對天空大聲高喊說,他發現突然已經沒那麼喜歡 Bruno Major 了,現在他最喜歡的歌手是 Robbie Williams,於是可以這樣寫:
karenFavoriteSinger = 'Robbie Williams';
現在,讓我們再度回過頭檢視 karenFavoriteSinger 和 yangFavoriteSinger 這兩個變數吧!
console.log(karenFavoriteSinger); //回傳 Robbie Williams console.log(yangFavoriteSinger); // 仍然回傳 Bruno Major
所以「傳值」這件事,其實可以理解為,Karen 和 Yang 曾經有過共同喜歡的歌手,但後來雖然 Karen 變了,他喜歡的跟 Yang 不同了,但是,Yang 是個有主見的人,他並不需要跟著一起改變啊!
然而,這個現象背後的原理其實是因為,當變數每一次被賦予一個新的值,電腦都會開一個記憶體來儲存這個值,並重新將變數指向這個記憶體。
傳值這個動作可以拆解為 2 個步驟:
- 電腦「複製」一個完完全全相同的值
- 電腦同時新開一個記憶體空間儲存這個值,並把變數 yangFavoriteSinger 指向到這個值過去,最後,值都是儲存在各自獨立的記憶體空間。
既然傳值後,值都是儲存在各自獨立的記憶體空間,那麼當變數重新賦予一個新的值,自然也會再依循剛剛的兩個步驟再走一次。
小結:傳值的兩個重點
- 傳值所指的「值」都是屬於原始資料型別 (Primitive Type)
- 傳值有一個很關鍵的重點是「複製」,複製後在記憶體空間中,兩個變數的值都是獨立存在不同的記憶體之中,因此,就算其中一方突然改變心意,各自都還是獨立個體,並不互相影響。
什麼是傳址(Pass by Reference)?
剛剛說到,在變數宣告並賦予值的過程中,變數是指向各個獨立的值,而這個值若是屬於「原始型別」,每一筆都將儲存在獨立的記憶體內,因此傳值的動作,可以理解為:變數指向的是另一份「複製」後的單獨記憶體。
但傳址就不同了,傳址是兩個變數共同指向同一個值,且這邊的值是屬於「物件型別」(它可能是物件、陣列或函式),因為傳址沒有複製值,當然不會有新的位址,可是兩個變數都指向同一筆資料,而這筆資料被儲存在某一個地點,並且有一個「位址 (address) 」。
let fruit1 = [ 'orange', 'papaya', 'guava', 'mango' ]
let fruit2 = fruit1; // 傳址 console.log(fruit2); // 回傳 ['orange', 'papaya', 'guava', 'mango']
傳址類似於 fruit 1 寫一張紙條給 fruit2,告訴它:
「當你之後要找這筆陣列資料的時候,去紙條上這個地址就會找到。」
既然 fruit1 與 fruit2 共用同一個陣列位址,當透過 fruit2 來刪減陣列內的資料,fruit1 會發生什麼事?
delete fruit2[0]; //刪除陣列中的第一筆資料 console.log(fruit1);
結果,從 fruit2 刪除陣列中的第一筆資料 orange,在 fruit1 會發現 orange 也一起不見了。
但假如對 fruit2 重新賦予值,fruit1 和 fruit2 各自會有什麼結果呢?
let fruit1 = [ 'orange', 'papaya', 'guava', 'mango' ]
let fruit2 = fruit1; // 傳址console.log(fruit2); // 回傳 ['orange', 'papaya', 'guava', 'mango'] fruit2 = [ 'orange', 'papaya', 'guava', 'mango', 'durian']
// 對 fruit2 重新賦予值 console.log(fruit2);
// 回傳 [ 'orange', 'papaya', 'guava', 'mango', 'durian'] console.log(fruit1);
// 回傳 ['orange', 'papaya', 'guava', 'mango']
根據回傳結果,可以得知,當對 fruit2 重新賦予值,fruit2 的值改變了,但 fruit1 仍然不受影響。
這可以理解為,當對變數重新賦予值的時候,電腦就會再開一個新的記憶體空間來儲存這個值,當有新的空間、就有新的位址,那麼兩個變數這時就各自指向獨立的值,不再互相影響了。
再看一個更進階的例子:
當我現在需要把物件的某一個屬性的值取出,因此我另外再宣告一個新的變數 stationary 儲存這個值,同時最後我用 console.log 驗證一下變數是否有成功代入物件。
let myPencilCase = { "color": "brown", "contents": ['ruler', 'fountain pen', 'scissor', 'pencil'],}let stationary = "contents";console.log(myPencilCase["contents"])
//回傳['ruler', 'fountain pen', 'scissor', 'pencil'](4)console.log(myPencilCase[stationary])
//回傳['ruler', 'fountain pen', 'scissor', 'pencil'](4)
//代表變數已經成功抓到我要的值
經過宣告 stationary 這個新的變數,自此之後,只要一修改 “contents” 屬性的陣列資料,這兩個地方的資料都會同步串聯了,例如:這邊用push()來新增一筆陣列資料。
myPencilCase["contents"].push('eraser'); //新增一筆陣列資料console.log(myPencilCase["contents"])
//回傳['ruler', 'fountain pen', 'scissor', 'pencil', 'eraser'](5)console.log(myPencilCase[stationary])
//回傳['ruler', 'fountain pen', 'scissor', 'pencil', 'eraser'](5)
但唯一做一個動作,就會斷掉兩者的牽連,那就是:重新對變數 stationary 賦予值。
let myPencilCase = { "color": "brown", "contents": ['ruler', 'fountain pen', 'scissor', 'pencil']}
let stationary = "contents";myPencilCase[stationary] = [666]stationary = ['marker']; //重新對變數 stationary 賦予值。console.log(myPencilCase["contents"]) //回傳 [666]console.log(myPencilCase[stationary]) // 回傳 undefined
這邊的 myPencilCase[“contents”] 之所以會回傳 [666],是因為 stationary 重新賦予值之前,透過這個變數 stationary 已經修改了“contents”陣列的資料,因此最終陣列資料停留在[666]。
但後面將 stationary 重新賦予值,因此最後一行的 console.log 已無法透過 stationary 這個變數在 myPencilCase 這個物件內讀取資料了,故回傳 undefined。
註:因為我自己對於修改陣列資料及重新賦予值這兩件事,很容易搞混,因此在這邊多增加第二個範例提醒自己。
小結:傳址的三個重點
- 傳址指的是當超過一個以上的變數要存取同一筆物件型別的值(物件、陣列或函式)。
- 傳址可視為有一個以上的變數都指向同一個位址、取用同一筆物件型別的值,因此透過任一個變數去修改值,所有的變數的值都會連動一起改變。
- 傳址後的兩個變數 fruit1、fruit2,雖然都會到同一個位址去存取一筆物件型別的資料,但假如對其中任一的變數 fruit1 再重新賦予一個值,此時電腦將另新開一個記憶體空間來存放這一筆資料,這時的 fruit1 與 fruit2 就各自指向不同的記憶體空間、資料也不再互相連動了。
總結
- 傳值:複製一筆值、產生獨立的記憶體空間來儲存資料,因此假如後續再對任一變數重新賦予值,兩者之間也不會產生影響。
- 傳址:變數 a 複製了一個「位址」到變數 b 那邊去,所以兩個變數共同指向的仍是同一筆值,因此若透過任一個變數來修改這個值,兩者都會產生連動的改變。
- 傳址又重新對其中一個變數賦予值:對變數重新賦予值會產生一個新的記憶體空間,又會回到很像傳值的時候,這時兩個變數裡面的資料內容並不相同。而這邊很關鍵的重點就是在於有了「重新賦予值」的動作,產生一個新的記憶體空間來儲存資料,導致 fruit1 與 fruit2 兩個變數日後不再互相影響。
參考資料
- 你不可不知的 JavaScript 二三事#Day26:程式界的哈姆雷特 — — Pass by value, or Pass by reference?
- by reference (傳參考)、by value(傳值)的差別
- Javascript pass by reference or value
4. How to get a grip on reference vs value in JavaScript
5. 深入探討 JavaScript 中的參數傳遞:call by value 還是 reference?
6. JS 變數傳遞探討:pass by value 、 pass by reference 還是 pass by sharing?