JavaScript 是如何工作的:JavaScript 的共享傳遞和按值傳遞

子瑜說發表於2019-05-08

關於JavaScript如何將值傳遞給函式,在網際網路上有很多誤解和爭論。大致認為,引數為原始資料類時使用按值傳遞,引數為陣列、物件和函式等資料型別使用引用傳遞

按值傳遞 和 引用傳遞引數 主要區別簡單可以說:

  • 按值傳遞:在函式裡面改變傳遞的值不會影響到外面
  • 引用傳遞:在函式裡面改變傳遞的值會影響到外面

但答案是 JavaScript 對所有資料型別都使用按值傳遞。它對陣列和物件使用按值傳遞,但這是在的共享傳參拷貝的引用中使用的按值傳參。這些說有些抽象,先來幾個例子,接著,我們將研究JavaScript在 函式執行期間的記憶體模型,以瞭解實際發生了什麼。

image

按值傳參

在 JavaScript 中,原始型別的資料是按值傳參;物件型別是跟Java一樣,拷貝了原來物件的一份引用,對這個引用進行操作。但在 JS 中,string 就是一種原始型別資料而不是物件類

let setNewInt = function (i) {
 i = i + 33;
};
let setNewString = function (str) {
 str += "cool!";
};
let setNewArray = function (arr1) {
 var b = [1, 2];
 arr1 = b;
};
let setNewArrayElement = function (arr2) {
 arr2[0] = 105;
};
let i = -33;
let str = "I am ";
let arr1 = [-4, -3];
let arr2 = [-19, 84];
console.log('i is: ' + i + ', str is: ' + str + ', arr1 is: ' + arr1 + ', arr2 is: ' + arr2);
setNewInt(i);
setNewString(str);
setNewArray(arr1);
setNewArrayElement(arr2);
console.log('現在, i is: ' + i + ', str is: ' + str + ', arr1 is: ' + arr1 + ', arr2 is: ' + arr2);

複製程式碼

執行結果

i is: -33, str is: I am , arr1 is: -4,-3, arr2 is: -19,84
現在, i is: -33, str is: I am , arr1 is: -4,-3, arr2 is: 105,84

複製程式碼

這邊需要注意的兩個地方:

**1)**第一個是通過 setNewString 方法把字串 str 傳遞進去,如果學過物件導向的語言如C#,Java 等,會認為呼叫這個方法後 str 的值為改變,引用這在面嚮物件語言中是 string 型別的是個物件,按引用傳參,所以在這個方法裡面更改 str外面也會跟著改變。

但是 JavaScript 中就像前面所說,在JS 中,string 就是一種原始型別資料而不是物件類,所以是按值傳遞,所以在 setNewString 中更改 str 的值不會影響到外面。

**2)**第二個是通過 setNewArray 方法把陣列 arr1 傳遞進去,因為陣列是物件型別,所以是引用傳遞,在這個方法裡面我們更改 arr1 的指向,所以如果是這面嚮物件語言中,我們認為最後的結果arr1 的值是重新指向的那個,即 [1, 2],但最後列印結果可以看出 arr1 的值還是原先的值,這是為什麼呢?

image

共享傳遞

Stack Overflow上Community Wiki 對上述的回答是:對於傳遞到函式引數的物件型別,如果直接改變了拷貝的引用的指向地址,那是不會影響到原來的那個物件;如果是通過拷貝的引用,去進行內部的值的操作,那麼就會改變到原來的物件的。

可以參考博文 JavaScript Fundamentals (2) – Is JS call-by-value or call-by-reference?

function changeStuff(state1, state2)
{
 state1.item = 'changed';
 state2 = {item: "changed"};
}
var obj1 = {item: "unchanged"};
var obj2 = {item: "unchanged"};
changeStuff(obj1, obj2);
console.log(obj1.item); // obj1.item 會被改變 
console.log(obj2.item); // obj2.item 不會被改變

複製程式碼

緣由: 上述的 state1 相當於 obj1, 然後 obj1.item = 'changed',物件 obj1 內部的 item 屬性進行了改變,自然就影響到原物件 obj1 。類似的,state2 也是就 obj2,在方法裡 state2 指向了一個新的物件,也就是改變原有引用地址,這是不會影響到外面的物件(obj2),這種現象更專業的叫法:call-by-sharing,這邊為了方便,暫且叫做 共享傳遞

記憶體模型

JavaScript 在執行期間為程式分配了三部分記憶體:程式碼區呼叫堆疊。 這些組合在一起稱為程式的地址空間。

image

程式碼區:這是儲存要執行的JS程式碼的區域。

呼叫堆::這個區域跟蹤當前正在執行的函式,執行計算並儲存區域性變數。變數以後進先出法儲存在堆疊中。最後一個進來的是第一個出去的,數值資料型別儲存在這裡。

例如:

var corn = 95
let lion = 100

複製程式碼

image

在這裡,變數 corn 和 lion 值在執行期間儲存在堆疊中。

堆:是分配 JavaScript 引用資料型別(如物件)的地方。 與堆疊不同,記憶體分配是隨機放置的,沒有 LIFO策略。 為了防止堆中的記憶體漏洞,JS引擎有防止它們發生的記憶體管理器。

class Animal {}
// 在記憶體地址 0x001232 上儲存 new Animal() 例項
// tiger 的堆疊值為 0x001232
const tiger = new Animal()
// 在記憶體地址 0x000001 上儲存 new Objec例項
// `lion` 的堆疊值為 0x000001
let lion = {
 strength: "Very Strong"
}

複製程式碼

image

Here,lion 和 tiger 是引用型別,它們的值儲存在堆中,並被推入堆疊。它們在堆疊中的值是堆中位置的記憶體地址

啟用記錄(Activation Record),引數傳遞

我們已經看到了 JS 程式的記憶體模型,現在,讓我們看看在 JavaScript 中呼叫函式時會發生什麼。

// 例子一
function sum(num1,num2) {
 var result = num1 + num2
 return result
}
var a = 90
var b = 100
sum(a, b)

複製程式碼

每當在 JS 中呼叫一個函式時,執行該函式所需的所有資訊都放在堆疊上。這個資訊就是所謂的啟用記錄(Activation Record)

這個 Activation Record,我直譯為啟用記錄,找了好多資料,沒有看到中文一個比較好的翻譯,如果朋友們知道,歡迎留言。

啟用記錄上的資訊包括以下內容:

  • SP 堆疊指標:呼叫方法之前堆疊指標的當前位置。
  • RA 返回地址:這是函式執行完成後繼續執行的地址。
  • RV 返回值:這是可選的,函式可以返回值,也可以不返回值。
  • 引數:將函式所需的引數推入堆疊。
  • 區域性變數:函式使用的變數被推送到堆疊。

我們必須知道這一點,我們在js檔案中編寫的程式碼在執行之前由 JS 引擎(例如 V8,Rhino,SpiderMonke y等)編譯為機器語言。

所以以下的程式碼:

let shark = "Sea Animal"

複製程式碼

會被編譯成如下機器碼:

01000100101010
01010101010101

複製程式碼

上面的程式碼是我們的js程式碼等價。 機器碼和 JS 之間有一種語言,它是組合語言。 JS 引擎中的程式碼生成器在最終生成機器碼之前,首先是將 js 程式碼編譯為彙編程式碼。

為了瞭解實際發生了什麼,以及在函式呼叫期間如何將啟用記錄推入堆疊,我們必須瞭解程式是如何用匯編表示的。

image

為了跟蹤函式呼叫期間引數是如何在 JS 中傳遞的,我們將例子一的程式碼使用匯編語言表示並跟蹤其執行流程。

先介紹幾個概念:

ESP:(Extended Stack Pointer)為擴充套件棧指標暫存器,是指標暫存器的一種,用於存放函式棧頂指標。與之對應的是 EBP(Extended Base Pointer),擴充套件基址指標暫存器,也被稱為幀指標暫存器,用於存放函式棧底指標。

EBP:擴充套件基址指標暫存器(extended base pointer) 其記憶體放一個指標,該指標指向系統棧最上面一個棧幀的底部。

EBP 只是存取某時刻的 ESP,這個時刻就是進入一個函式內後,cpu 會將ESP的值賦給 EBP,此時就可以通過 EBP 對棧進行操作,比如獲取函式引數,區域性變數等,實際上使用 ESP 也可以。

// 例子一
function sum(num1,num2) {
 var result = num1 + num2
 return result
}
var a = 90
var b = 100
var s = sum(a, b)

複製程式碼

我們看到 sum 函式有兩個引數 num1 和 num2。函式被呼叫,傳入值分別為 90 和 100 的 a 和 b。

記住:值資料型別包含值,而引用資料型別包含記憶體地址。

在呼叫 sum 函式之前,將其引數推入堆疊

ESP->[......] 
ESP->[ 100 ]
 [ 90 ]
 [.......]

複製程式碼

然後,它將返回地址推送到堆疊。返回地址儲存在EIP 暫存器中:

ESP->[Old EIP]
 [ 100 ]
 [ 90 ]
 [.......]

複製程式碼

接下來,它儲存基指標

ESP->[Old EBP]
 [Old EIP]
 [ 100 ]
 [ 90 ]
 [.......]

複製程式碼

然後更改 EBP 並將呼叫儲存暫存器推入堆疊。

ESP->[Old ESI]
 [Old EBX]
 [Old EDI]
EBP->[Old EBP]
 [Old EIP]
 [ 100 ]
 [ 90 ]
 [.......]

複製程式碼

為區域性變數分配空間:

ESP->[ ]
 [Old ESI]
 [Old EBX]
 [Old EDI]
EBP->[Old EBP]
 [Old EIP]
 [ 100 ]
 [ 90 ]
 [.......]

複製程式碼

這裡執行加法:

mov ebp+4, eax ; 100
add ebp+8, eax ; eax = eax + (ebp+8)
mov eax, ebp+16
ESP->[ 190 ]
 [Old ESI]
 [Old EBX]
 [Old EDI]
EBP->[Old EBP]
 [Old EIP]
 [ 100 ]
 [ 90 ]
 [.......]

複製程式碼

我們的返回值是190,把它賦給了 EAX。

mov ebp+16, eax

複製程式碼

EAX 是"累加器"(accumulator), 它是很多加法乘法指令的預設暫存器。

然後,恢復所有暫存器值。

[ 190 ] DELETED
 [Old ESI] DELETED
 [Old EBX] DELETED
 [Old EDI] DELETED
 [Old EBP] DELETED
 [Old EIP] DELETED
ESP->[ 100 ]
 [ 90 ]
EBP->[.......]

複製程式碼

並將控制權返回給呼叫函式,推送到堆疊的引數被清除。

[ 190 ] DELETED
 [Old ESI] DELETED
 [Old EBX] DELETED
 [Old EDI] DELETED
 [Old EBP] DELETED
 [Old EIP] DELETED
 [ 100 ] DELETED
 [ 90 ] DELETED
[ESP, EBP]->[.......]

複製程式碼

呼叫函式現在從 EAX 暫存器檢索返回值到 s 的記憶體位置。

mov eax, 0x000002 ; // s 變數在記憶體中的位置

複製程式碼

我們已經看到了記憶體中發生了什麼以及如何將引數傳遞彙編程式碼的函式。

呼叫函式之前,呼叫者將引數推入堆疊。因此,可以正確地說在 js 中傳遞引數是傳入值的一份拷貝。如果被呼叫函式更改了引數的值,它不會影響原始值,因為它儲存在其他地方,它只處理一個副本。

function sum(num1) {
 num1 = 30
}
let n = 90
sum(n)
// `n` 仍然為 90

複製程式碼

讓我們看看傳遞引用資料型別時會發生什麼。

function sum(num1) {
 num1 = { number:30 }
}
let n = { number:90 }
sum(n)
// `n` 仍然是 { number:90 }

複製程式碼

用匯編程式碼表示:

n -> 0x002233 
Heap: Stack:
002254 012222
... 012223 0x002233
002240 012224
002239 012225
002238
002237
002236
002235
002234
002233 { number: 90 }
002232
002231 { number: 30 }
Code:
 ...
000233 main: // entry point
000234 push n // n 值為 002233 ,它指向堆中存放 {number: 90} 地址。 n 被推到堆疊的 0x12223 處.
000235 ; // 儲存所有暫存器
...
000239 call sum ; // 跳轉到記憶體中的`sum`函式
000240
 ...
000270 sum:
000271 ; // 建立物件 {number: 30} 內在地址主 0x002231
000271 mov 0x002231, (ebp+4) ; // 將記憶體地址為 0x002231 中 {number: 30} 移動到堆疊 (ebp+4)。(ebp+4)是地址 0x12223 ,即 n 所在地址也是物件 {number: 90} 在堆中的位置。這裡,堆疊位置被值 0x002231 覆蓋。現在,num1 指向另一個記憶體地址。
000272 ; // 清理堆疊
...
000275 ret ; // 回到呼叫者所在的位置(000240)

複製程式碼

我們在這裡看到變數n儲存了指向堆中其值的記憶體地址。 在sum 函式執行時,引數被推送到堆疊,由 sum 函式接收。

sum 函式建立另一個物件 {number:30},它儲存在另一個記憶體地址 002231 中,並將其放在堆疊的引數位置。 將前面堆疊上的引數位置的物件 {number:90} 的記憶體地址替換為新建立的物件 {number:30} 的記憶體地址。

這使得 n 保持不變。因此,複製引用策略是正確的。變數 n 被推入堆疊,從而在 sum 執行時成為 n 的副本。

image

此語句 num1 = {number:30} 在堆中建立了一個新物件,並將新物件的記憶體地址分配給引數 num1。 注意,在 num1 指向 n 之前,讓我們進行測試以驗證:

// example1.js
let n = { number: 90 }
function sum(num1) {
 log(num1 === n)
 num1 = { number: 30 }
 log(num1 === n)
}
sum(n)
$ node example1
true
false

複製程式碼

是的,我們是對的。就像我們在彙編程式碼中看到的那樣。最初,num1 引用與 n 相同的記憶體地址,因為n被推入堆疊。

然後在建立物件之後,將 num1 重新分配到物件例項的記憶體地址。

讓我們進一步修改我們的例子1:

function sum(num1) {
 num1.number = 30
}
let n = { number: 90 }
sum(n)
// n 成為了 { number: 30 }

複製程式碼

這將具有與前一個幾乎相同的記憶體模型和組合語言。這裡只有幾件事不太一樣。在 sum 函式實現中,沒有新的物件建立,該引數受到直接影響。

...
000270 sum:
000271 mov (ebp+4), eax ; // 將引數值複製到 eax 暫存器。eax 現在為 0x002233
000271 mov 30, [eax]; // 將 30 移動到 eax 指向的地址

複製程式碼

num1 是(ebp+4),包含 n 的地址。值被複制到 eax 中,30 被複制到 eax 指向的記憶體中。任何暫存器上的花括號 [] 都告訴 CPU 不要使用暫存器中找到的值,而是獲取與其值對應的記憶體地址號的值。因此,檢索 0x002233 的 {number: 90}值。

看看這樣的答案:

原始資料型別按值傳遞,物件通過引用的副本傳遞。

具體來說,當你傳遞一個物件(或陣列)時,你無形地傳遞對該物件的引用,並且可以修改該物件的內容,但是如果你嘗試覆蓋該引用,它將不會影響該物件的副本- 即引用本身按值傳遞:

function replace(ref) {
 ref = {}; // 這段程式碼不影響傳遞的物件
}
function update(ref) {
 ref.key = 'newvalue'; // 這段程式碼確實會影響物件的內容
}
var a = { key: 'value' };
replace(a); // a 仍然有其原始值,它沒有被修改的
update(a); // a 的內容被更改

複製程式碼

從我們在彙編程式碼和記憶體模型中看到的。這個答案百分之百正確。在 replace 函式內部,它在堆中建立一個新物件,並將其分配給 ref 引數,a 物件記憶體地址被重寫。

update 函式引用 ref 引數中的記憶體地址,並更改儲存在儲存器地址中的物件的key屬性。

image

總結

根據我們上面看到的,我們可以說原始資料型別和引用資料型別的副本作為引數傳遞給函式。不同之處在於,在原始資料型別,它們只被它們的實際值引用。JS 不允許我們獲取他們的記憶體地址,不像在C與C++程式設計學習與實驗系統,引用資料型別指的是它們的記憶體地址。

image
最後,給大家推薦一個前端學習進階內推交流群685910553前端資料分享),不管你在地球哪個方位, 不管你參加工作幾年都歡迎你的入駐!(群內會定期免費提供一些群主收藏的免費學習書籍資料以及整理好的面試題和答案文件!)

如果您對這個文章有任何異議,那麼請在文章評論處寫上你的評論。

如果您覺得這個文章有意思,那麼請分享並轉發,或者也可以關注一下表示您對我們文章的認可與鼓勵。

願大家都能在程式設計這條路,越走越遠。

相關文章