1. 程式人生 > >JavaScript進階(三) 值傳遞和引用傳遞

JavaScript進階(三) 值傳遞和引用傳遞

從C語言開始

有時候講一些細節或是底層的東西,我喜歡用C語言來講,因為用C更方便來描述記憶體裡面的東西。先舉一個例子,swap函式,相信有一些程式設計經驗的人都見識過,宣告如下,函式體我就不寫了,各位腦補一下。
void swap1(int a, int b);
void swap2(int* a, int* b)

這裡swap1是不能交換兩個數的值的,swap2可以。那為什麼呢?有教材會說,第一個是值傳遞,第二個是引用傳遞,傳遞的是指標,所以第二個可以。好吧,這個解釋和沒說一樣,那下面我就來解釋一下,呼叫這兩個函式的時候,到底發生了什麼,為什麼一個可以交換,另一個不可以。為了方便描述,我把這兩個函式的呼叫程式碼也寫出來
int main() {
    int a = 3;
    int b = 4;
    swap1(a, b);    //此時a = 3, b = 4;
    int* pa = &a;
    int* pb = &b;     //為了方便解釋,增加這兩個臨時變數,否則直接寫swap2(&a, &b)的話,這行程式碼做的事情太多,不好解釋。
    swap2(pa, pb);    //此時a = 4, b = 3;
    return 0;
}

函式的執行是在棧中,下圖描述了swap1執行開始和結束的時候,棧中的情況。
左圖為執行前,右圖為執行後。當main函式呼叫swap1函式的時候,將兩個入參a,b壓棧,壓棧採用的是複製的方式,當swap1執行的時候,修改了swap1棧空間的兩個值,但是main函式中的兩個值沒有受影響。這就是值傳遞。把入參的值複製壓棧來傳入引數。下面來看swap2的情況
左圖為執行前,右圖為執行後。其中最左一列為記憶體地址。這裡地址,壓棧方向,地址順序均為示例。各位看到沒有,所謂引用傳遞,其實還是值傳遞,傳遞的時候還是採用複製壓棧,只是傳遞的“值”是個地址。swap2執行的時候,執行*a = temp.給*a 賦值,這行語句的意思是修改a這個地址指向的記憶體的值,由於這個地址指向的位置在main方法的棧空間中,所以實現了修改原來值。 以上就是C語言在實現swap函式時候記憶體的細節,下面我們來探討一下JS中呼叫函式的情況。由於JS中沒有&這個取地址符號,那麼JS中傳遞的到底是什麼呢?

回到JS

JS中的資料型別有數字,布林,陣列,字串,物件,null, undefined. 那麼當用這些資料型別來作為函式的引數的時候,到底是引用傳遞還是值傳遞呢?先說結論:布林,數字是值傳遞,字串,陣列,物件是引用傳遞。事實上字串,陣列也可以作為物件。null和undefined在傳遞的時候到底是什麼,我不清楚。如果有熟悉的大神請幫忙解釋一下。在這裡小弟先謝了。 布林,數字在作為引數傳遞的時候,其實現和C語言一樣,這裡不做贅述。也是將呼叫者的區域性變數複製壓棧,傳遞給被呼叫者。下面我來詳細的描述一下物件是如何傳遞的。以一個函式來舉例,假設你需要實現一個函式,將一個傳入的陣列反序,reverse,下面有兩個實現,請各位來看一下有什麼問題:
function reverse1(array) {
	var temp = [];
	for (var i = array.length - 1; i > -1; i--) {
		temp.push(array[i]);
	}
	array = temp;
}

function reverse2(array) {
	var temp = [];
	for (var i = array.length - 1; i > -1; i--) {
		temp.push(array[i]);
	}
	for (var i = 0; i < array.length; i++) {
		array[i] = temp[i]
	}
}

這兩個函式都是先將一個反序完成的陣列儲存在temp裡面,然後賦值給入參array,就是賦值的方式有所不同。這個不同的賦值方式也導致了結果的不同,結果就是reverse1無法完成工作,reverse2可以。為了解答這個問題,我先講一下JS裡面,記憶體中物件是如何儲存的。當一行程式碼 var temp = [] 被執行的時候,記憶體中是這樣的:


其中藍色的是棧,黑框的是堆,用來動態分配記憶體,最右綠色的表示這段堆的起始地址。也就是說當宣告一個物件的時候,棧中儲存的內容只是一個指標,真正的內容在堆中。以此為基礎,我們再來看一下當函式reverse1執行的時候,記憶體中如何實現的。為方便舉例,假設傳入的陣列為[1,2,3];


上圖為執行前,下圖為執行後。當函式reverse1執行時,in作為引數傳入。傳入引數時,類似C語言的引用傳遞,將地址複製了一份,壓棧傳到子函式中。所以兩個函式中的變數是指向同一個位置的。當reverse1執行時,temp中儲存了array的反序,最後一行賦值的時候,你就看到了如下面的圖表示的那樣,reverse1中的array確實指向了新的反序陣列,但是呼叫者中的區域性變數in卻絲毫未動。所以導致了reverse1無法完成反序功能。

那麼我們再看reverse2. reverse2中的第二個迴圈逐個給陣列的內容複製,其實它操縱的記憶體空間就是array指向的區域,我們又知道array和in指向了同一個區域,所以in指向的區域也被改變了。

總結一下以上所說的,

  1. JS中布林,數字為基本資料型別,是值傳遞。無法作為引用傳遞。所以JS中無法實現基本資料型別的swap函式。
  2. 物件是引用傳遞。當傳遞物件給子函式時,傳遞的是地址。子函式使用這個地址來操作修改傳入的物件。但是如果在子函式修改該地址指向的位置時,這個改變將無法作用於呼叫者。
  3. 引用傳遞其實還是值傳遞,只是傳入的值是個地址,並且該地址指向了一段儲存了物件資料的記憶體。這點和C中的引用傳遞類似。

特別說一下String

String是JS的內建物件,所以根據上文所說,它是引用傳遞。那麼下面我請你寫一個函式,將傳入的String修改,給它兩頭加上引號。所以很明顯,下面這樣的函式就是錯誤的了
function foo(s) {
    s = "\"" + s + "\""
}
那麼正確的函式應該怎寫呢?你可能會想,應該使用String物件的函式來修改String的內容。這麼想是對的,但是很不幸,JS提供的String沒有任何一個可以修改String內容的函式。有人說不對,比如字串連線函式,concat,轉大小寫函式toUpperCase,toLowerCase。事實上這兩個函式只是返回了一個新的String物件,其原本的值兵沒有改動。這個你可以去做實驗看看。所以String物件被建立好之後,就再也無法改動了,所以無法用一個子函式來修改它的值。又由於String可以用 == 來判斷其內容是否相等,所以它的各方面特性都很像基本資料型別。但是還有一點不一樣,請看下面的例子:
var a = 1
var b = 1
a == b            //true
a === b           //true

var s1 = "sdf"
var s2 = "sdf"
s1 == s2          //true
s1 === s2         //true
s3 = new String("sdf")
s1 === s3         //false

對於數字,估計各位沒有疑問吧。那麼對於字串來說,== 比較的是兩個字串的內容,這個應該也沒有疑問。那麼===呢?並且為什麼s1===s2為true,s1===s3為false呢? 當用===來比較字串的時候,事實上比較的是兩個物件的地址。s1的值“sdf”這個字串的地址,s3則是一個新的物件的地址。他們不相等,這個很好理解。那麼s1 和 s2如何解釋呢?這因為JS引擎有一個靜態字串儲存區,當宣告一個字串常量的時候,會先去該儲存區查詢有沒有相同的字串,如果有就返回該字串,沒有再在靜態字串區重新初始化一個字串物件。這就解釋了為什麼s1 === s2. 順便說一句,就是字串的不可變性,以及常量字串區這兩個特性,Java和JS是一樣的。然而C++的STL中的std::string是可變的。

注:本文中的JS執行時的記憶體示例圖並不是真正的JS引擎執行時候實體記憶體的樣子。實體記憶體的實現取決於JS引擎。