一路上感謝各位讀者們的支持和回饋。
本 30 天系列文目前已經將篇幅重新整理、編纂成冊。
《JavaScript 概念三明治》在天瓏書局上架囉!
喜歡這個系列,想閱讀更詳細原理說明的讀者可以參考:
https://www.tenlong.com.tw/products/9789864347575
今天要談的是另一個 JS 裡面很重要的特性,我們在做變數宣告與赴值時, JS 引擎是如何為我們保留記憶體位置的?還記得前面有提到 JS 裡面概括可以分為兩大類別:「物件型別」、「原始型別」嗎?這兩種型別,在變數操作時,記憶體位置的運作方式各有不同。
原始型別的記憶體位置是透過「傳值呼叫( Call by Value )」的方式來傳遞。那具體來說是怎麼運作呢?我們都知道變數在被宣告的時候,引擎會為我們預留記憶體空間(還記得什麼是「創造階段」嗎?忘記可以往前看),接著這個變數就會被赴值成為我們預期的變數內容。我們姑且稱一個被指派純值的變數為純值變數。
上面我們透過宣告,產生一個變數, var a = 12
,接著再把 a
指派給另外一個變數 b
,所以現在 b
的值應該與 a
相同。但是 JavaScript 引擎知道這是一個純值之後,就會幫我們另外創造記憶體空間,而就算我們修改 b
的內容,a 也不會受到影響,兩者之間是完全沒有關聯的。
b = 21
console.log(a) //12
console.log(b) //21
當一個變數被賦予物件型別時候,這個物件實際上並非存在該變數裡面,而是被存在某個位置,既然是「位置」當然有地址,就稱為該物件存放的記憶體位置,而存在這個變數內的就是這個「記憶體位置」。因此這個「以記憶體位置為參考」而在變數間傳遞的存取行為,就稱為「傳參考呼叫」。
現在我們「傳值」所傳遞的是「數值的複製」,而「傳參考」所傳的則是「記憶體的參考位置( 我要去哪裡找這個物件? )」。那傳參考呼叫跟剛開始提到的傳值,在行為上會有什麼不一樣呢?
當我們像剛才那樣新增了一個 a
物件變數,然後再把 a
的值傳給另外一個變數 b
,這時候有一個很重要的問題:「 a
裡面存的值是什麼? 」還記得剛剛提到,是記憶體位置嗎?所以我傳給 b
的時候,傳的正是記憶體位置。 因此如果後面我修改了 b
內容的值, a
理所當然的也會被改變,因為他們指的,是同一個物件。
如果你多讀幾篇文章,可能會發現有的文章會說「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)
我在這個裡面做了幾件事情:
為什麼會這樣呢?這就要先提到函式的參數,其實在參數被傳遞進函式的時候,會重新創造一個變數,然後把參數的值丟進這個變數裡面。不過
因為 Call By Reference 傳參考的特性,如果傳入的值是物件,那麼雖然函式試圖創造新的變數與外部環境做區隔,但是指派給這個新變數的值仍然會是「記憶體位置」!因此在這個情況下,函式內對 argument 做的修改,是對傳入物件參考的修改,連帶也會影響到全域環境下的 list 陣列值。
上面是當傳入函式參數是物件型別的情況,但是如果這個參數是原始型別,那麼情況又不同了,還記得原始型別在不同變數之間傳遞時的行為是「傳值」嗎?也就是「數值的拷貝」,所以就不會有上述修改到物件參考的奇怪情況:
好,上面兩種情況正好運用到今天的兩個重點「傳值呼叫」與「傳參考呼叫」,我們回到剛剛的程式碼,現在,為了討論 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 知道這點之後就會為這個變數創造一個新的記憶體空間,然後把新指派的陣列存進去,而不會直接修改到外部傳進來的變數,造成連帶影響。
也就是說,雖然透過記憶體位置參考,函式內被傳入的參數,有能力影響 / 修改到外部環境傳進來的變數,但是已經被宣告的物件無論如何都不會因為對這個變數的修改而被消滅。
在看完 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 變數與記憶體的關係與運作方式就好。
hello~~好心提醒一下在 原始型別的傳值呼叫 ( Call By Value ) 此段落第一句話,不小心寫成Call by reference囉~
嗨 harry_xie : 非常感謝提醒!!已光速修正!
Call By Value 或 Call By Reference,在過去 Java 的領域已經吵過一陣子了,如果要用 C++ 裡的定義來說的話(因為 C++ 中確實有定義 Reference 的語法,指定的行為也就叫 Call By Reference),Java 裡只有 Call By Value。
類似地,如果要用 C++ 裡的定義來說的話,這篇文章裡談的 Call By Reference 範例,其實還是 Call By Value,為什麼呢?如果是 C++ 裡的 Call By Reference 定義的話,應該會有這種行為:
let o1 = {x: 10};
let o2 = o1;
let o2 = {x: 20};
console.log(o1.x); // 20
實際上,在 JavaScript 裡不會有這種結果,在 JavaScript 中有最接近 C++ 中 Call By Reference 行為的情境,其實是非嚴格模式下 arguments 與參數之間的行為:
function foo(p) {
console.log(p.x); // 10
arguments[0] = {x: 20}
console.log(p.x); // 20
p = {x: 30}
console.log(arguments[0].x); // 30
}
foo({x: 10});
為什麼過去 Java 會為了 Call By Value 或 Call By Reference 吵上一陣呢?因為在 Java 中也使用 reference 這個名詞,當變數或參數參考至(refer to)物件時,Java 中會說該變數或參數為 reference,實際上,這個 reference 與 C++ 中 Call By Reference 的 reference,兩個定義上是不同的。
最後在 Java 圈中是這麼解決的,如果一定要使用 Call By Value 或 Call By Reference 來說明 Java 中變數與參數的指定發生了什麼,那就用 C++ 中的定義來解釋,那麼 Java 中純綷就只有 Call By Value(因為底層傳遞的只有位址值,有些 C++ 文件甚至還會說,這時叫 Call By Address,更是徒增混淆)。
為了避免誤會,Call By Value 或 Call By Reference 還是留在 C++ 中討論就好,通常在 Java、JavaScript、Python 等這類語言中,我不會用 Call By Value 或 Call By Reference 這類名詞,以避免誤會。
對於底下的情況,我會說變數 x 參考至 10:
let x = 10;
對於底下的情況,我會說變數參考至物件:
let o = {x: 10};
至於那個 Call By Sharing?看這篇文章的示範,其實還是 Call By Value。
結論就是,程式設計領域有許多情況,名詞在定義上並不嚴謹,導致開發者對於相同名詞的理解不同,因而常發生溝通上的誤會與爭吵。
嚴格來說,JavaScript 也不適合用 Call By Value 來描述,因為它在基本型態的行為與 C++ 差更多,若硬是要用 C++ 裡的定義來說的話,JavaScript 幾乎只有 Call By Value,最接近 Call By Reference 定義的是 arguments 與參數之間的關係。
良葛格 你好!
非常感謝你的說明,看完你的範例之後我大致了解你所要描述的問題,我認為,如果硬要下定論的話,JS 與 C++所指的參考之所以有差異,有可能是因為「參考的對象」不同。
當 JS 說,傳物件是傳「參考」時,參考的是「物件記憶體位置」,因為 JS 有個特別的東西叫做 Object Literal ,也就是利用大括號直接產生一個新物件,根據我的印象,在別的語言是沒有這種寫法的:
let o1 = {x: 10}; //新的物件產生
let o2 = o1; //傳遞上述物件的記憶體位置給o2
o2 = {x: 20}; //新的物件產生,傳更新的記憶體位置給o2
console.log(o1.x); // 10, o1不會有任何變化
而根據你的範例,C++ 說的傳參考,參考的可能是「變數的記憶體位置」,所以像是在第二行把 o2 賦值給 o1 時,因為傳的是 o1 這個變數對應空間的位置,後面在對o2修改時,也修改到 o1 的內容。不過以上只是為了區分而做出的有點硬要的描述。
我想若真的要了解差異就必須C++ / JS / Java 在記憶體位置上的分配跟控制是怎麼實作、以及細部有什麼不同,為了避免更多因為用詞不精確而造成的混淆,就不再往下探討。
基本上我同意你說的 「 JS 內只有 Call By Value 」的說法,如果知道JS物件什麼時候會被產生、記憶體位置怎麼傳遞,其實不難理解!只是我找到的許多跟 JS 有關的資源,可能為了讓讀者更容易區別,都是以「傳值」跟「傳參考」來區分「純值」跟「物件型別」這兩種變數在運作上的不同,為了不造成混淆,我也用同樣的方式來理解跟描述。
我一直在思考怎麼用簡單的方式去跟初學者解釋 JS 內物件的參考這件事情,不過照你說的如果以這種方式來解釋,可能會讓讀者對這些名詞的定義產生誤解而造成在其他語言上的學習困難。這部分我可能要重新思考一下,也許之後連「Call By Reference / Value」都不要對初學者提到比較好,就像你在文末提到的。
再次感謝你的補充說明,認真覺得獲益良多!
等等你揪是那個 openhome.cc 跟寫了 Java 學習手冊的良葛格嗎我這是被良葛格造訪了嗎!((敬禮((敬禮((敬禮((敬禮!