JavaScript | a+b:動態型別是災難之源還是最好的特性?(下)

語言: CN / TW / HK

theme: smartblue highlight: dark


持續創作,加速成長!這是我參與「掘金日新計劃 · 10 月更文挑戰」的第2天,點選檢視活動詳情

a+b:動態型別是災難之源還是最好的特性?(下)

上一篇,我們說到如何將複雜的型別轉換縮減到兩條簡單的規則,以及兩種主要型別。這兩條簡單規則是:

  1. 從值 x 到引用:呼叫 Object(x) 函式。
  2. 從引用 x 到值:呼叫 x.valueOf() 方法;或,呼叫四種值型別的包裝類函式,例如Number(x),或者 String(x) 等等。

兩種主要型別則是字串和數字值。

當型別轉換系統被縮減成這樣之後,有些問題就變得好解釋了,但也確實有些問題變得更加難解。例如 graybernhardt 在講演中提出的靈魂發問,就是:如果將陣列跟物件相加,會發生什麼?

如果你忘了,那麼我們就一起來回顧一下這四個直擊你靈魂深處的示例:

```js

[] + {} '[object Object]' {} + [] 0 {} + {} NaN [] + [] '' ```

而這個問題,也就是這兩講的標題中“a + b”這個表示式的由來。也就是說,如何準確地解釋“兩個運算元相加”,與如何全面理解 JavaScript 的型別系統的轉換規則,關係匪淺!

轉換過程

一般來說,運算子很容易知道運算元的型別,例如“a - b”中的減號,我們一看就知道意圖,是兩個數值求差,所以 a 和 b 都應該是數值;又例如“obj.x”中的點號,我們一看也知道,是取物件 obj 的屬性名字串 x。

當需要引擎“推斷目的”時,JavaScript 設定推斷結果必然是三種基礎值(boolean、number 和 string)。由於其中的 boolean 是通過查表來進行的,所以就只剩下了number 和 string 型別需要“自動地、隱式地轉換”。

但是在 JavaScript 中,“加號(+)”是一個非常特別的運算子。像上面那樣簡單的判斷,在加號(+)上面就不行,因為它在 JavaScript 中既可能是字串連結,也可能是數值求和。另外還有一個與此相關的情況,就是object[x]中的x,其實也很難明確地說它是字串還是數值。因為計算屬性(computed property)的名字並不能確定是字串還是數值;尤其是現在,它還可能是符號型別(symbol)。

由於“加號(+)”不能通過程式碼字面來判斷意圖,因此只能在運算過程中實時地檢查運算元的型別。並且,這些型別檢查都必須是基於“加號(+)運算必然操作兩個值資料”這個假設來進行。於是,JavaScript 會先呼叫ToPrimitive()內部操作來分別得到“a 和 b 兩個運算元”可能的原始值型別。

所以,問題就又回到了在上面講的Value vs. Primitive values這個東西上面。物件到底會轉換成什麼?這個轉換過程是如何決定的呢?

這個過程包括如下的四個步驟。

步驟一

首先,JavaScript 約定:如果x原本就是原始值,那麼ToPrimitive(x)這個操作直接就返回x本身。這個很好理解,因為它不需要轉換。也就是說(如下程式碼是不能直接執行的):

```js

1. 如果 x 是非物件,則返回 x

_ToPrimitive(5) 5 ```

步驟二

接下來的約定是:如果x是一個物件,且它有對應的五種PrimitiveValue內部槽之一,那麼就直接返回這個內部槽中的原始值。由於這些物件的valueOf()就可以達成這個目的,因此這種情況下也就是直接呼叫該方法(步驟三)。相當於如下程式碼:

```js

2. 如果 x 是物件,則嘗試得到由 x.valueOf() 返回的原始值

Object(5).valueOf() 5 ``` 但是在處理這個約定的時候,JavaScript 有一項特別的設定,就是對“引擎推斷目的”這一行為做一個預設。如果某個運算沒有預設目的,而 JavaScript 也不能推斷目的,那麼 JavaScript 就會強制將這個預設為“number”,並進入“傳統的”型別轉換邏輯(步驟四)。

所以,簡單地說(這是一個非常重要的結論):如果一個運算無法確定型別,那麼在型別轉換前,它的運算數將被預設為 number

於是,這裡會發生兩種情況(步驟三、步驟四)。

步驟三

其一,作為原始值處理。

如果是上述的五種包裝類的物件例項(它們有五種PrimitiveValue內部槽之一),那麼它們的valueOf()方法總是會忽略掉“number”這樣的預設,並返回它們內部確定(即內部槽中所保留的)的原始值。

所以,如果我們為符號建立一個它的包裝類物件例項,那麼也可以在這種情況下解出它的值。例如:

```js

x = Symbol()

obj = Object(x)

obj.valueOf() === x true ```

正是因為物件(如果它是原始值的包裝類)中的原始值總是被解出來,所以:

```js

Object(5) + Object(5) 10 ```

這個程式碼看起來是兩個物件“相加”,但是卻等效於它們的原始值直接相加。由於“物件屬性存取”是一個“有預期”的運算——它的預期是“字串”,因此會有第二種情況。

步驟四

其二,進入“傳統的型別轉換邏輯”。

這需要利用到物件的valueOf()和toString()方法:當預期是“number”時,valueOf()方法優先呼叫;否則就以toString()為優先。並且,重要的是,上面的預期只決定了上述的優先順序,而當呼叫優先方法仍然得不到非物件值時,還會順序呼叫另一方法。

這帶來了一個結果,即:如果使用者程式碼試圖得到“number”型別,但x.valueOf()返回的是一個物件,那麼就還會呼叫x.toString(),並最終得到一個字串。

到這裡,就可以解釋前面四種物件與陣列相加所帶來的特殊效果了。

解題 1:從物件到原始值

在a + b的表示式中,a和b是物件型別時,由於“加號(+)”運算子並不能判別兩個運算元的預期型別,因此它們被“優先地”假設為數字值(number)進行型別轉換。這樣一來:

```js

在預期是'number'時,先呼叫valueOf()方法,但得到的結果仍然是物件型別;

[typeof ([].valueOf()), typeof ({}.valueOf())] [ 'object', 'object' ]

由於上述的結果是物件型別(而非值),於是再嘗試toString()方法來得到字串

[[].toString(), {}.toString()] [ '', '[object Object]' ] ```

在這裡,我們就看到會有一點點差異了。空陣列轉換出來,是一個空字串,而物件的轉換成字串時是’[object Object]’。

所以接下來的四種運算變成了下面這個樣子:

```js

[] + {}

'' + '[object Object]' '[object Object]'

{} + []

??? 0

{} + {}

??? NaN

[] + []

'' + '' '' ```

好的,你應該已經注意到了,在第二和第三種轉換的時候我打了三個問號“???”。因為如果按照上面的轉換過程,它們無非是字串拼接,但結果它們卻是兩個數字值,分別是 0,還有 NaN。

怎麼會這樣?

解題 2:“加號(+)”運算的戲分很多

現在看看這兩個表示式。

{} + []

{} + {}

你有沒有一點熟悉感?嗯,很不幸,它們的左側是一對大括號,而當它們作為語句執行的時候,會被優先解析成——塊語句!並且大括號作為結尾的時候,是可以省略掉語句結束符“分號(;)”的。

所以,你碰到了 JavaScript 語言設計歷史中最大的一塊鐵板!就是所謂“自動分號插入(ASI)”。這個東西的細節我這裡就不講了,但它的結果是什麼呢?上面的程式碼變成下面這個樣子:

{}; +[]

{}; +{}

於是後續的結論就比較顯而易見了。

由於“+”號同時也是“正值運算子”,並且它很明顯可以準確地預期後續運算元是一個數值,所以它並不需要呼叫ToPrimitive()內部操作來得到原始值,而是直接使用“ToNumber(x)”來嘗試將x轉換為數字值。而上面也講到,“將物件轉換為數字值,等效於使用它的包裝類來轉換,也就是 Number(x)”。所以,上述兩種運算的結果就變成了下面的樣子:

```js

+[] 將等義於

  • Number([]) 0

+{} 將等義於

  • Number({}) NaN ```

結語

今天我們更深入地講述了型別轉換的諸多細節,除了這一講的簡單題解之外,對於“+”號運算也做了一些補充。總地來講,我們是在討論 JavaScript 語言所謂“動態型別”的部分,但是動態型別並不僅限於此。也就是說 JavaScript 中並不僅僅是“型別轉換”表現出來動態型別的特性。

參考:紅寶書,犀牛書,你不知道的 JS、 JS 核心原理解析