JavaScript 形參與實參的愛恨情仇

作為前端開發,JavaScript 可謂是我們吃飯的家伙。

但我記得當我初學 JavaScript 的時候,總是搞不懂對形參的操作什么時候會影響到實參。

網絡上關于這個問題眾說紛紜,有說基本類型按值傳遞、復雜類型(對象、數組、函數等)按引用傳遞,更有人直接生造了一個詞——按共享傳遞。

到底 JavaScript 的參數是按什么方式傳遞的呢?

考慮下面的例子:

function addOne(num) {
  num += 1
}

let n = 1
addOne(n)  // n = ?

乍一看可能會覺得 n === 2 ,因為 num += 1 執行后 num 的值會變成 2

但實際結果卻是 n === 1,因為對形參的修改不會影響到實參的值。

按值傳遞?按引用傳遞?

再看另一個例子:

function addOne(obj) {
  obj.num += 1
}

let o = { num: 1 }
addOne(o)  // o.num = ?

如你所想,o.num 的值已經變成了 2。由此,我們可以得出結論,JavaScript 的傳參方式就是基本類型按值傳遞,復雜類型按引用傳遞

真的是這樣嗎?再來看一個例子:

function addOne(obj) {
  obj = { num: obj.num + 1 }
}

let o = { num: 1 }
addOne(o)  // o.num = ?

出人意料的是,這次的 addOne 沒能改掉 obj.num 的值,結果還是 1

這似乎和我們剛才得出的結論不太一樣,如果對象是按引用傳遞的,那我們對形參 obj 的操作應該會反應到實參 o 上才對。

按共享傳遞

為了解釋這個問題,有人提出了按共享傳遞。

按共享傳遞,是指在調用函數時,傳遞給函數的是實參的地址的拷貝(如果實參在棧中,則直接拷貝該值)。在函數內部對參數進行操作時,需要先拷貝的地址尋找到具體的值,再進行操作。如果該值在棧中,那么因為是直接拷貝的值,所以函數內部對參數進行操作不會對外部變量產生影響。如果原來拷貝的是原值在堆中的地址,那么需要先根據該地址找到堆中對應的位置,再進行操作。因為傳遞的是地址的拷貝所以函數內對值的操作對外部變量是可見的。

按指針傳遞

隨著工作年限日久,對 JavaScript 的理解也愈發深刻。回過頭來看這個問題,發現 JavaScript 的傳參方式其實更像按指針傳遞。

想一下在 C 語言中我們如何修改實參?

void addOne(int* num) {
  *num += 1;
}

int main() {
  int n = 1;
  addOne(&n);  // n == 2

  return 0;
}

由于 C 語言出生的年代尚未提出引用的概念,要修改實參,只能通過取地址運算符(&)和解引用運算符(*)直接操作實參的內存地址。但如果我們直接對形參賦值:

void addOne(int* num) {
  num = num + 1;
}

int main() {
  int n = 1;
  addOne(&n);  // n == 1

  return 0;
}

會發現對實參 n 的影響消失了。因為我們讓 num 這個指針指向了另一個地址,當然不會對實參所在的地址產生任何影響,也就不會修改到實參的值。

但在 JavaScript 中并沒有解引用這個概念,為什么我們還是可以改變實參的值呢?

再來看一個 C 語言的例子。

typedef struct {
  int num;
} Obj;

void addOne(Obj* obj) {
  obj->num += 1;
}

int main() {
  Obj o;
  o.num = 1;

  addOne(&o);  // o.num == 2

  return 0;
}

這個例子中我們用了 struct 結構體來模擬 JavaScript 中的對象。如果你看不懂也沒關系,把它類比成 ES6 中的 class 定義即可。

我們發現通過箭頭操作符 -> 可以修改實參,不過這個箭頭可不是 ES6 里的那個箭頭,它的作解引用,再取值。obj->num 就相當于 (*obj).num

通過 -> 操作符修改結構體的值,就跟我們在 JavaScript 里修改對象的屬性一樣自然。

類比一下,可以得出在傳遞參數時,JavaScript 是按指針傳遞的。而 JavaScript 中的. 操作符,就像的 C 語言中的 -> ,解引用再取值,就可以修改實參所在內存地址的值,從而影響到實參。由于 JavaScript 缺失了解引用操作符 * ,所以直接對形參賦值就相當修改指針指向的地址,不會影響實參的值。

令人意外的是,這個結論是普適的。也就是說它同時適用于基本類型和復雜類型。

function reassignPrimitive(num) {
  num += 1  // 相當于 num = num + 1,對形參直接賦值不會影響到實參
}

function reassignComplex(obj) {
  obj = { num: obj.num + 1 }  // 對形參直接賦值不會影響到實參
}

function assignField(obj) {
  // 相當于 obj.num = obj.num + 1,解引用后賦值,會改變實參
  obj.num += 1
}

還有問題?

再考慮一種特殊情況:

function setField(num) {
  num.a = 1
}

let n = 1
setField(n)  // n.a = ?

答案是 n.a === undefined。你也許會問,不是說修改形參的屬性會對實參直接生效嗎,為什么 n.a 的值還是 undefined

這倒不是因為 num 并非按指針傳遞,而是因為基本類型沒有 prototype,所有基本類型的方法都是通過其對應的復雜類型的實例來執行的。

例如:我們調用 num.toFixed(2),其中 numNumber 類型,相當于調用

let numObj = new Number(num)
numObj.toFixed(2)

numObj 在調用結束后被丟棄,所以即使我們修改了基本類型的屬性,也不過是修改了其對應類型的實例的屬性,這個實例在語句執行完之后就丟掉了。

let num = 1
num.a = 10
console.log(num.a)  // undefined

就算我們給基本類型添加屬性會立馬打印,得到的結果也還是 undefined

這個是 JavaScript 的語言特性,與我們按指針傳遞的結論并不矛盾。

簡而言之

如果你沒有 C/C++/Golang 基礎的話,可能對指針和地址的概念不太熟悉,很難理解到按指針傳遞的含義。你只需要記住,直接對形參賦值,不會影響到實參;對形參的屬性賦值,會修改實參對應屬性的值。

如今函數式編程大熱,在日常編程中已經不推薦通過形參去修改實參的值(這種操作被稱為副作用),而應該返回一個新的值(這種函數叫作純函數)。但是了解 JavaScript 的傳參原理,還是有助于分析遺留代碼,以及用于在極端情況下提高性能。畢竟修改現有的對象比生成新對象快多了。

?著作權歸作者所有,轉載或內容合作請聯系作者
平臺聲明:文章內容(如有圖片或視頻亦包括在內)由作者上傳并發布,文章內容僅代表作者本人觀點,簡書系信息發布平臺,僅提供信息存儲服務。

推薦閱讀更多精彩內容