JS 原力覺醒 Day12- 傳值呼叫、傳址呼叫

今天要談的是另一個 JS 裡面很重要的特性,我們在做變數宣告與赴值時, JS 引擎是如何為我們保留記憶體位置的?還記得前面有提到 JS 裡面概括可以分為兩大類別:「物件型別」、「原始型別」嗎?這兩種型別,在變數操作時,記憶體位置的運作方式各有不同。

Outline

  • 原始型別的傳值呼叫 ( Call By Value )
  • 物件型別的傳參考呼叫 ( Call By Reference )
  • 補充:Call By Sharing

原始型別的傳值呼叫 ( Call By Value )

原始型別的記憶體位置是透過「傳值呼叫( Call by Value )」的方式來傳遞。那具體來說是怎麼運作呢?我們都知道變數在被宣告的時候,引擎會為我們預留記憶體空間(還記得什麼是「創造階段」嗎?忘記可以往前看),接著這個變數就會被赴值成為我們預期的變數內容。我們姑且稱一個被指派純值的變數為純值變數。

https://ithelp.ithome.com.tw/upload/images/20190927/201065803oQCVhZRB7.jpg
上面我們透過宣告,產生一個變數, var a = 12 ,接著再把 a 指派給另外一個變數 b ,所以現在 b 的值應該與 a 相同。但是 JavaScript 引擎知道這是一個純值之後,就會幫我們另外創造記憶體空間,而就算我們修改 b 的內容,a 也不會受到影響,兩者之間是完全沒有關聯的。

 b = 21 

 console.log(a) //12
 console.log(b) //21

物件型別的傳參考呼叫 ( Call By Reference )

當一個變數被賦予物件型別時候,這個物件實際上並非存在該變數裡面,而是被存在某個位置,既然是「位置」當然有地址,就稱為該物件存放的記憶體位置,而存在這個變數內的就是這個「記憶體位置」。因此這個「以記憶體位置為參考」而在變數間傳遞的存取行為,就稱為「傳參考呼叫」。

https://ithelp.ithome.com.tw/upload/images/20190927/20106580OsgJIIjubY.jpg

現在我們「傳值」所傳遞的是「數值的複製」,而「傳參考」所傳的則是「記憶體的參考位置( 我要去哪裡找這個物件? )」。那傳參考呼叫跟剛開始提到的傳值,在行為上會有什麼不一樣呢?

https://ithelp.ithome.com.tw/upload/images/20190927/201065800HcRlJdm9b.jpg

https://ithelp.ithome.com.tw/upload/images/20190927/20106580AHYSLGu03v.jpg

當我們像剛才那樣新增了一個 a 物件變數,然後再把 a 的值傳給另外一個變數 b ,這時候有一個很重要的問題:「 a 裡面存的值是什麼? 」還記得剛剛提到,是記憶體位置嗎?所以我傳給 b的時候,傳的正是記憶體位置。 因此如果後面我修改了 b 內容的值, a 理所當然的也會被改變,因為他們指的,是同一個物件。

補充:Call By Sharing

如果你多讀幾篇文章,可能會發現有的文章會說「JavaScript 是 Call By Sharing 」。「 Call By Sharing 」這個詞因為定義曖昧,模糊不清的關係,並不被廣泛地使用。「Call By Sharing」也有「 Call By Object-Sharing 」之稱,看到這個詞有沒有覺得跟「 Call By Reference 」意義很像?事實上,還真的有點像,但這個詞的定義更模糊。什麼意思呢?我們先來看看一個與 function 有關的經典例子:

let jediList = ['Anakin' , 'Luke' , 'Ahsoka'] 

function addFellow(list){
	list.push('Yoda') 
}

addFellow(jediList)

console.log('jediList',jediList)

我在這個裡面做了幾件事情:

  1. 在全域宣告陣列以及一個函式
  2. 把這個陣列傳入函式裡面
  3. 修改這個函式被傳入的陣列
  4. 回到全域執行環境,發現剛剛宣告的陣列在函式執行後也一併被修改

為什麼會這樣呢?這就要先提到函式的參數,其實在參數被傳遞進函式的時候,會重新創造一個變數,然後把參數的值丟進這個變數裡面。不過

因為 Call By Reference 傳參考的特性,如果傳入的值是物件,那麼雖然函式試圖創造新的變數與外部環境做區隔,但是指派給這個新變數的值仍然會是「記憶體位置」!因此在這個情況下,函式內對 argument 做的修改,是對傳入物件參考的修改,連帶也會影響到全域環境下的 list 陣列值。

https://ithelp.ithome.com.tw/upload/images/20190927/20106580ianSvW0gQt.jpg

上面是當傳入函式參數是物件型別的情況,但是如果這個參數是原始型別,那麼情況又不同了,還記得原始型別在不同變數之間傳遞時的行為是「傳值」嗎?也就是「數值的拷貝」,所以就不會有上述修改到物件參考的奇怪情況:

https://ithelp.ithome.com.tw/upload/images/20190927/20106580rrkNDOLwsG.jpg

好,上面兩種情況正好運用到今天的兩個重點「傳值呼叫」與「傳參考呼叫」,我們回到剛剛的程式碼,現在,為了討論 Call By Sharing 與 Call By Reference 的差異,我稍微修改一下程式碼,你可以思考一下結果回有怎樣的不同:

let jediList = ['Anakin' , 'Luke' , 'Ahsoka'] 

function addFellow(list){
	 //somebody bad wants to change the result.
   list = ['nobody']
}

addFellow(jediList)

console.log('jediList',jediList) // ['Anakin' , 'Luke' , 'Ahsoka'] 

如何? 根據剛剛的原則,傳入參數是物件,那麼我對這個物件作修改,就會影響到全域環境傳進參數的陣列內容,所以最後 console 出來的結果就是 ['nobody'] 囉?並不是!答案是維持原來的 ['Anakin' , 'Luke' , 'Ahsoka'] ,也就是說在函式內的修改並沒有影響到這個全域變數。

這裡有一個關鍵差別是在做 list = ['nobody'] 的時候,是指派一個全新的陣列物件給 list 變數,JS 知道這點之後就會為這個變數創造一個新的記憶體空間,然後把新指派的陣列存進去,而不會直接修改到外部傳進來的變數,造成連帶影響,這個創造新空間的行為,其實有點像是 Call By Value。

https://ithelp.ithome.com.tw/upload/images/20190927/20106580n5AOAxQnql.jpg

也就是說,雖然透過記憶體位置參考,函式內被傳入的參數,有能力影響 / 修改到外部環境傳進來的變數,但是已經被宣告的物件無論如何都不會因為對這個變數的修改而被消滅。

在看完 wiki 以及數篇文章的說明後,我認為上面的描述就是 Call By Sharing 與 Call By Reference 最大的不同,我相信看到這裡的你應該已經能夠了解它與「記憶體位置」脫不了關係。而 Call By Sharing 則在 Call By Value 與 Call By Reference 兩者之間有著曖昧模糊的地位 - 已經不單純取決於型別,而端看你對變數操作的行為。

結論

今天我們了解了基本的 Call By Value 與 Call By Reference 兩種行為,兩者在 JS 環境內所發生的時間點,Call By Value 發生在當指派給變數的值是純值時,而 Call By Reference 則發生在物件型別。最後,我用一個函式的範例,針對一個比較特殊的名詞 Call By Sharing 做了解說。

你在別的語言可能也會看到以上這些名詞,甚至,在某些語言裡面相同的名詞的意義也完全不同( 如 Call By Reference ) 。但那不重要,在這個篇幅內,我希望看到最後的你,能夠了解 JS 變數與記憶體的關係與運作方式就好。

Your browser is out-of-date!

Update your browser to view this website correctly. Update my browser now

×