瞭解V8(二)型別轉換:V8是怎麼實現1+「2」的?

2021-04-22 12:01:58

各位小夥伴們好,今天我們來聊一聊JavaScript 中的「型別系統」。

但是在開始之前呢我們可以先思考一個簡單的表示式,那就是在 JavaScript 中,「1+‘2’等於多少?」

其實這相當於是在問,在 JavaScript 中,讓數位字串相加是會報錯,還是可以正確執行。

如果能正確執行,那麼結果是等於數位 3,還是字串「3」,還是字串「12」呢?

如果你嘗試用一些其他語言執行數位了字串相加,會是什麼楊的結果呢。

比如說用 Python 使用數位和字串進行相加操作,則會直接返回一個執行錯誤,錯誤提示是這樣的:

  >>>1+'2'
  Traceback (most recent call last):
    File "<stdin>", line 1, in <module>
  TypeError: unsupported operand type(s) for +: 'int' and 
  'str'

但是在 JavaScript 中執行這段表示式,卻是可以返回一個結果的,最終返回的結果是字串「12」。

那麼為什麼同樣的表示式,在 Python 和 JavaScript 中執行為什麼會有不同的結果?為什麼在 JavaScript 中執行,輸出的是字串「12」,不是數位 3 或者字串「3」呢?

什麼是型別系統 (Type System)?

在上邊的表示式中,涉及到了兩種不同型別的資料的相加。要想理清以上兩個問題,我們就需要知道型別的概念,以及 JavaScript 操作型別的策略。

對機器語言來說,所有的資料都是一堆二進位制程式碼,CPU 處理這些資料的時候,並沒有型別的概念,CPU 所做的僅僅是行動資料,比如對其進行移位,相加或相乘。

而在高階語言中,我們都會為操作的資料賦予指定的型別,型別可以確認一個值或者一組值具有特定的意義和目的。所以,型別是高階語言中的概念

image.png

在 JavaScript 中,你可以這樣定義變數:


  var num = 100 # 賦值整型變數
  let miles = 1000.0 # 浮點型
  const name = "John" # 字串

V8 是怎麼執行加法操作的?

瞭解了型別系統,接下來我們就可以來看看 V8 是怎麼處理 1+「2」的了。當有兩個值相加的時候,比如:


  a+b

V8 會嚴格根據 ECMAScript 規範來執行操作。ECMAScript 是一個語言標準,JavaScript 就是 ECMAScript 的一個實現,比如在 ECMAScript 就定義了怎麼執行加法操作,如下所示:

image.png
具體細節你也可以參考規範,我將標準定義的內容翻譯如下:

  1. 把第一個表示式 (AdditiveExpression) 的值賦值給左參照 (lref)。
  2. 使用 GetValue(lref) 獲取左參照 (lref) 的計算結果,並賦值給左值。
  3. 使用ReturnIfAbrupt(lval) 如果報錯就返回錯誤。
  4. 把第二個表示式 (MultiplicativeExpression) 的值賦值給右參照 (rref)。
  5. 使用 GetValue(rref) 獲取右參照 (rref) 的計算結果,並賦值給 rval。
  6. 使用ReturnIfAbrupt(rval) 如果報錯就返回錯誤。
  7. 使用 ToPrimitive(lval) 獲取左值 (lval) 的計算結果,並將其賦值給左原生值 (lprim)。
  8. 使用 ToPrimitive(rval) 獲取右值 (rval) 的計算結果,並將其賦值給右原生值 (rprim)。
  9. 如果 Type(lprim) 和 Type(rprim) 中有一個是 String,則:

    a. 把 ToString(lprim) 的結果賦給左字串 (lstr);

    b. 把 ToString(rprim) 的結果賦給右字串 (rstr);

    c. 返回左字串 (lstr) 和右字串 (rstr) 拼接的字串。

  10. 把 ToNumber(lprim) 的結果賦給左數位 (lnum)。
  11. 把 ToNumber(rprim) 的結果賦給右數位 (rnum)。
  12. 返回左數位 (lnum) 和右數位 (rnum) 相加的數值。

通俗地理解,V8 會提供了一個 ToPrimitive 方法,其作用是將 a 和 b 轉換為原生資料型別,其轉換流程如下:

  • 先檢測該物件中是否存在 valueOf 方法,如果有並返回了原始型別,那麼就使用該值進行強制型別轉換;
  • 如果 valueOf 沒有返回原始型別,那麼就使用 toString 方法的返回值;
  • 如果 vauleOf 和 toString 兩個方法都不返回基本型別值,便會觸發一個 TypeError 的錯誤。

image.png

當 V8 執行 1+「2」時,因為這是兩個原始值相加,原始值相加的時候,如果其中一項是字串,那麼 V8 會預設將另外一個值也轉換為字串,相當於執行了下面的操作:Number(1).toString() + "2"

這裡,把數位 1 偷偷轉換為字串「1」的過程也稱為強制型別轉換,因為這種轉換是隱式的,所以如果我們不熟悉語意,那麼就很容易判斷錯誤。

我們還可以再看一個例子來驗證上面流程,你可以看下面的程式碼:


  var Obj = {
      toString() {
        return '200'
      }, 
      valueOf() {
        return 100
      }   
    }
    Obj+3

執行這段程式碼,你覺得應該返回什麼內容呢?

上面我們介紹過了,由於需要先使用 ToPrimitive 方法將 Obj 轉換為原生型別,而 ToPrimitive 會優先呼叫物件中的 valueOf 方法,由於 valueOf 返回了 100,那麼 Obj 就會被轉換為數位 100,那麼數位 100 加數位 3,那麼結果當然是 103 了。

如果我改造下程式碼,讓 valueOf 方法和 toString 方法都返回物件,其改造後的程式碼如下:


  var Obj = {
      toString() {
        return new Object()
      }, 
      valueOf() {
        return new Object()
      }   
  }
  Obj+3

再執行這段程式碼,你覺得應該返回什麼內容呢?

因為 ToPrimitive 會先呼叫 valueOf 方法,發現返回的是一個物件,並不是原生型別,當 ToPrimitive 繼續呼叫 toString 方法時,發現 toString 返回的也是一個物件,都是物件,就無法執行相加運算了,這時候虛擬機器器就會丟擲一個異常,異常如下所示:


  VM263:9 Uncaught TypeError: Cannot convert object to primitive value
    at <anonymous>:9:6

提示的是型別錯誤,錯誤原因是無法將物件型別轉換為原生型別。

所以說,在執行加法操作的時候,V8 會通過 ToPrimitive 方法將物件型別轉換為原生型別,最後就是兩個原生型別相加,如果其中一個值的型別是字串時,則另一個值也需要強制轉換為字串,然後做字串的連線運算。在其他情況時,所有的值都會轉換為數位型別值,然後做數位的相加。

總結

今天我們主要了解了 JavaScript 中的型別系統是怎麼工作的。型別系統定義了語言應當如何操作型別,以及這些型別如何互相作用。

在 JavaScript 中,數位和字串相加會返回一個新的字串,這是因為 JavaScript 認為字串和數位相加是有意義的,V8 會將其中的數位轉換為字元,然後執行兩個字串的相加操作,最終得到的是一個新的字串。

在 JavaScript 中,型別系統是依據 ECMAScript 標準來實現的,所以 V8 會嚴格根據 ECMAScript 標準來執行。

在執行加法過程中,V8 會先通過 ToPrimitive 函數,將物件轉換為原生的字串或者是數位型別,在轉換過程中,ToPrimitive 會先呼叫物件的 valueOf 方法,如果沒有 valueOf 方法,則呼叫 toString 方法,如果 vauleOf 和 toString 兩個方法都不返回基本型別值,便會觸發一個 TypeError 的錯誤。

思考題

我們一起來分析一段程式碼:


  var Obj = {
    toString() {
      return "200"
    }, 
    valueOf() {
      return 100
    }   
  }
  Obj+"3"

你覺得執行這段程式碼會列印出什麼內容呢?歡迎你在留言區與我分享討論。