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

語言: CN / TW / HK

theme: smartblue highlight: dark


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

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

動態型別是 JavaScript 的動態語言特性中最有代表性的一種。動態執行與動態型別是天生根植於 JavaScript 語言核心設計中的基礎元件,它們相輔相成,導致了 JavaScript 在學習上是易學難精,在使用中是易用易錯。是好是壞難以定論。

型別系統的簡化

從根底上來說,JavaScript 有著兩套型別系統,如果僅以此論,那麼還算不上覆雜。 但是 ECMAScript 對語言型別的約定,又與 JavaScript 原生的、最初的語言設計不同,這導致了各種解釋紛至沓來,很難統一成一個說法。而且,ECMAScript 又為規範書寫而訂立了一套型別系統,並不停地演進它。這就如同雪上加霜,導致 JavaScript 的型別系統越發地說不清楚了。 在討論動態型別的時候,可以將 JavaScript 型別系統做一些簡化,從根底裡來說, JavaScript 也就是 typeof() 所支援的 7 種類型,其中的“物件(object)”與“函式 (function)”算一大類,合稱為引用型別,而其他型別作為值型別。 無論如何,我們且先以這種簡單的型別劃分為基礎,來討論 JavaScript 中的動態型別。因為這樣一來,JavaScript 中的型別轉換變得很簡單、很乾淨,也很易懂,可以用兩條規則概括如下:

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

簡單吧?當然不會這麼簡單。

先搞定一半

在型別轉換這件事中,有“半件”是比較容易搞定的。 這個一半,就是“從值 x 到引用”。因為主要的值型別都有對應的引用型別,因此 JavaScript 可以用簡單方法一一對應地將它們轉換過去。 使用Object(x)來轉換是很安全的方法,在使用者程式碼中不需要特別關心其中的x是什麼樣的資料——它們可以是特殊值(例如 null、undefined 等),或是一般的值型別資料,又或者也可以是一個物件。所有使用 Object(x) 的轉換結果,都將是一個儘可能接近你的預期的物件。例如,將數字值轉換成數字物件:

js x = 1234; Object(x); [Number: 1234]

類似的還包括字串、布林值、符號等。而 null、undefined 將被轉換為一個一般的、空白的物件,與new Object或一個空白字面量物件(也就是{ })的效果一樣。這個運算非常好用的地方在於,如果 x 已經是一個物件,那麼它只會返回原物件,而不會做任何操作。也就是說,它沒有任何的副作用,對任何資料的預期效果也都是“返回一個物件”。而且在語法上,Object(x)也類似於一個型別轉換運算,表達的是將任意x轉換成物件x。 簡單的這“半件事”說完後,我們反過來,接著討論將物件轉換成值的情況。

值 VS 原始值(Primitive values)

任何物件都會有繼承自原型的兩個方法,稱為 toString() 和 valueOf(),這是 JavaScript 中“物件轉換為值”的關鍵。 一般而言,你可以認為“任何東西都是可以轉換為字串的”,這個很容易理解,比如 JSON.stringify() 就利用了這一個簡單的假設,它“幾乎”可以將 JavaScript 中的任何物件或資料,轉換成 JSON 格式的文字。

所以在 JavaScript 中將任何東西都轉換成字串這一點,在具體的處理技術上並不存在什麼障礙。 但是如何理解“將函式轉換成字串”呢? 從最基礎的來說,函式有兩個層面的含義,一個是它的可執行程式碼,也就是文字形式的原始碼;另一個則是函式作為物件,也有自己的屬性。 所以,函式也可以被作為一個物件來轉換成字串,或者說,序列化成文字形式。 又或者再舉一個例子,我們需要如何來理解將一個“符號物件”轉換成“符號”呢?是的, 我想你一定會說,沒有“符號物件”這個東西,因為符號是值,不是物件。其實這樣講只是對了一半,因為現實中確實可以將一個“符號值”轉換為一個“符號物件”,例如:

js (new Object).toString() '[object Object]'

為了將這個問題“一致化”——也就是將問題收納成更小的問題,JavaScript 約定,所有“物件 -> 值”的轉換結果要儘量地趨近於 string、number 和 boolean 三者之一。不過這從來都不是書面的約定,而是因為 JavaScript 在早期的作用,就是用於瀏覽器上的開發,而:

  • 瀏覽器可以顯示的東西,是 string;
  • 可以計算的東西,是 number;
  • 可以表達邏輯的東西,是 boolean。

因此,在一個“最小的、可以被普通人理解的、可計算的程式系統中”,支援的“值型別數 據”的最小集合,就應該是這三種。

這個問題不僅僅是瀏覽器,就算是一臺放在雲端的主機,你想要去操作它,那麼通過控制檯登入之後的 shell 指令碼,也必須支援它。更遠一點地說,你遠端操作一臺計算機,與瀏覽器使用者要使用 gmail,這二者在計算的抽象上是一樣的,只是程式實現的複雜性不一樣而已。

所以,對於 ECMAScript 5 JavaScript 來說,當它支援值轉換向對應的物件時,或者反過來從這些物件轉換回值的時候,所需要處理的也無非是這三種類型而已。而處理的具體方法也很簡單,就是在使用Object(x)來轉換得到的物件例項中新增一個內部槽,存放這個x的值。更確切地說,下面兩行程式碼在語義上的效果是一致的:

js obj = Object(x); // 等效於(如果能操作內部槽的話) obj.[[PrimitiveValue]] = x;

於是,當需要從物件中轉換回來到值型別時,也就是把這個PrimitiveValue值取出來就 可以了。而“取出這個值,並返回給使用者程式碼”的方法,就稱為valueOf()。 到了 ECMAScript 6 中,這個過程就稍稍有些不同,這個內部槽是區別值型別的,因此為每種值型別設計了一個獨立的私有槽名字。加上 ES8 中出現的大整數型別(BigInt),一共就有了 5 個對應的私有槽:[[BooleanData] [[NumberData]]、[[StringData] [[SymbolData]]和[[BigIntData]]。

那麼在 ECMAScript 6 之後,除[[PrimitiveValue]]這個私有槽變成了 5 種值型別對應的、獨立的私有槽之外,還有什麼不同呢?

是的,這個你可能也已經注意到了。ECMAScript 6 之後還出現了Symbol.toPrimitive 這個符號。而它,正是將原本的[[PrimitiveValue]]這個私有槽以及其訪問過程標準化,然後暴露給 JavaScript 使用者程式設計的一個介面。

說到這裡,就必須明確一般的值(Values)與原始值(Primitive values)之間的關係了。不過,在下一步的討論之前,先總結一下前面的內容:也就是說,從typeof(x)的 7 種結果型別來看,其中 string、boolean、number、 bigint 和 symbol 的值型別與物件型別轉換,就是將該值存入私有槽,或者從私有槽中把相應的值取出來就好了。 在語言中,這些對應的物件型別被稱為“包裝類”,與此相關的還有“裝箱”與“拆箱”等行為,這也是後續會涉及到的內容。

所以,一種關於“原始值”的簡單解釋是:所有 5 種能放入私有槽(亦即是說它們有相應的包裝類)的值(Values),都是原始值;並且,再加上兩個特殊值 undefined 和 null, 那麼就是所謂原始值(Primitive values)的完整集合了。 接下來,如果轉換過程發生在“值與值”之間呢?

隱式轉換

由於函式的引數沒有型別宣告,所以使用者程式碼可以傳入任何型別的值。對於 JavaScript 核心庫中的一些方法或操作來說,這表明它們需要一種統一、一致的方法來處理這種型別差異。例如說,要麼拒絕“型別不太正確的引數”,丟擲異常;要麼用一種方式來使這些參 數“變得正確”。 後一種方法就是“隱式轉換”。但是就這兩種方法的選擇來說,JavaScript 並沒有編碼風格層面上的約定。基本上,早期 JavaScript 以既有實現為核心的時候,傾向於讓引擎吞掉型別異常(TypeError),儘量採用隱式轉換來讓程式在無異常的情況下執行;而後期,以 ECMAScript 規範為主導的時候,則傾向於丟擲這些異常,讓使用者程式碼有機會處理型別問題。

隱式轉換最主要的問題就是會帶來大量的“潛規則”。 例如經典的String.prototype.search(r)方法,其中的引數從最初設計時就支援在r引數中傳入一個字串,並且將隱式地呼叫r = new RegExp(r)來產生最終被用來搜尋的正則表示式。而new RegExp(r)這個運算中,由於RegExp()構造器又會隱式地將r從任何型別轉換為字串型別,因而在這整個過程中,向原始的r引數傳入任何值都不會產生任何的異常。

隱式轉換導致的“潛規則”很大程度上增加了理解使用者程式碼的難度,也不利於引擎實現。因 此,ECMAScript 在後期就傾向於拋棄這種做法,多數的“新方法”在發現型別不匹配的時 候,都設計為顯式地丟擲型別錯誤。一個典型的結果就是,在 ECMAScript 3 的時代, TypeError 這個詞在規範中出現的次數是 24 次;到了 ECMAScript 5,是 114 次;而 ECMAScript 6 開始就暴增到 419 次。

因此,越是早期的特性,越是更多地採用了帶有“潛規則”的隱式轉換規則。然而很不幸的 是,幾乎所有的“運算子”,以及大多數常用的原型方法,都是“早期的特性”。 所以在型別轉換方面,JavaScript 成了“潛規則”最多的語言之一。

消化一下

到現在為止,這一節課其實才開了個頭,也就是對“a + b”這個標題做了一個題解而已。 這主要是因為在 JavaScript 中有關型別處理的背景資訊太多、太複雜,而且還處在不停的變化之中。許多稍早的資訊,與現在的應用環境中的現狀,或者你手邊可備查的資料之間都存在著不可調和的矛盾衝突,因此對這些東西加以梳理還原,實在是大有必要的。這也就是為什麼會寫到現在,仍然沒有切入正題的原因。

你至少應該知道這些:

  • 語言中的引用型別和值型別,以及 ECMAScript 中的原始值型別(Primitive values)之間存在區別;
  • 語言中的所謂“引用型別”,與 ECMAScript 中的“引用(規範型別)”是完全不同的概念;
  • 所有值通過包裝類轉換成物件時,這個物件會具有一個內部槽,早期它統一稱為 [[PrimitiveValue]],而後來 JavaScript 為每種包裝類建立了一個專屬的;使用 typeof(x) 來檢查 x 的資料型別,在 JavaScript 程式碼中是常用而有效方法;
  • 原則上來說,系統只處理 boolean/string/number 三種值型別(bigint 可以理解為 number 的特殊實現),其中 boolean 與其他值型別的轉換是按對照表來處理的。

總的來說,型別在 JavaScript 中的顯式轉換是比較容易處理的,而標題“a + b”其實包含了太多隱式轉換的可能性,因此尤其複雜。關於這些細節,下一篇再說。

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