V8是怎麼實現1+‘2‘的,爲什麼1.toString會報錯?

2020-08-14 17:13:04

大家在學JavaScript 物件導向時,往往會有幾個疑惑:

1:爲什麼 JavaScript(直到 ES6)有物件的概念,但是卻沒有像其他的語言那樣,有類的概念呢;

2:爲什麼在 JavaScript 物件裡可以自由新增屬性,而其他的語言卻不能呢?

甚至,在一些爭論中,有人強調:JavaScript 並非 「物件導向的語言」,而是**「基於物件的語言」**。究竟是物件導向還是基於物件這兩派誰都說服不了誰。

實際上,基於物件和麪向物件兩個形容詞都出現在了 JavaScript 標準的各個版本當中。我們可以先看看 JavaScript 標準對基於物件的定義,這個定義的具體內容是:「語言和宿主的基礎設施由物件來提供,並且 JavaScript 程式即是一系列互相通訊的物件集合」。

這裏的意思根本不是表達弱化的物件導向的意思,反而是表達物件對於語言的重要性。

我們首要任務就是去理解物件導向和 JavaScript 中的物件導向究竟是什麼。

什麼是物件導向?

在《物件導向分析與設計》這本書中,作者 替我們做了總結,他認爲,從人類的認知角度來說,物件應該是下列事物之一:

一個可以觸控或者可以看見的東西;

  • 人的智力可以理解的東西;
  • 可以指導思考或行動(進行想象或施加動作)的東西。
  • 有了物件的自然定義後,我們就可以描述程式語言中的物件了。在不同的程式語言中,設計者也利用各種不+ 同的語言特性來抽象描述物件,最爲成功的流派是使用"類" 的方式來描述物件,這誕生了諸如 C++、

Java 等流行的程式語言。而 JavaScript 早年卻選擇了一個更爲冷門的方式:原型。這是我在前面說它不合羣的原因之一。

JavaScript 推出之時受管理層之命被要求模仿 Java,所以,JavaScript 創始人 Brendan Eich 在 「原型執行時」 的基礎上引入了 new、this 等語言特性,使之"看起來更像 Java"。 這也就造就了JavaScript這個古怪的語言。

首先我們來了解一下 JavaScript 是如何設計物件模型的。

JavaScript 物件的特徵

不論我們使用什麼樣的程式語言,我們都先應該去理解物件的本質特徵(參考 Grandy Booch《物件導向分析與設計》)。總結來看,物件有如下幾個特點。

  • 物件具有唯一標識性:即使完全相同的兩個物件,也並非同一個物件。
  • 物件有狀態:物件具有狀態,同一物件可能處於不同狀態之下。
  • 物件具有行爲:即物件的狀態,可能因爲它的行爲產生變遷。
  • 我們先來看第一個特徵,物件具有唯一標識性。一般而言,各種語言的物件唯一標識性都是用記憶體地址來體現的, 物件具有唯一標識的記憶體地址,所以具有唯一的標識。

所以我們都應該知道,任何不同的 JavaScript 物件其實是互不相等的,我們可以看下面 下麪的程式碼,o1 和 o2 初看是兩個一模一樣的物件,但是列印出來的結果卻是 false。

var o1 = { a: 1 }; 
var o2 = { a: 1 };  
console.log(o1 == o2); // false

關於物件的第二個和第三個特徵**「狀態和行爲」,不同語言會使用不同的術語來抽象描述它們,比如 C++ 中稱它們爲"成員變數""成員函數",Java 中則稱它們爲"屬性""方法"**。

在 JavaScript 中,將狀態和行爲統一抽象爲** 「屬性」**,這是因爲考慮到 JavaScript 中將函數設計成一種特殊物件所以 JavaScript 中的行爲和狀態都能用屬性來抽象。

下面 下麪這段程式碼其實就展示了普通屬性和函數作爲屬性的一個例子,其中 o 是物件,count 是一個屬性,而函數 render 也是一個屬性,儘管寫法不太相同,但是對 JavaScript 來說,count 和 render 就是兩個普通屬性。

var o = {          
conut: 1,          
render() {              
 console.log(this.d);              
  }            
};

所以,總結一句話來看,在 JavaScript 中,物件的狀態和行爲其實都被抽象爲了屬性。

在實現了物件基本特徵的基礎上, 我認爲,JavaScript 中物件獨有的特色是:物件具有高度的動態性,這是因爲 JavaScript 賦予了使用者在執行時爲物件添改狀態和行爲的能力。

比如,JavaScript 允許執行時向物件新增屬性,這就跟絕大多數基於類的、靜態的物件設計完全不同。如果你用過 Java 或者其它別的語言,肯定會產生跟我一樣的感受。

下面 下麪這段程式碼就展示了執行時如何向一個物件新增屬性,一開始我定義了一個物件 o,定義完成之後,再新增它的屬性 b,這樣操作是完全沒問題的。

var o = { a: 1 };  
o.b = 2; 
console.log(o.a, o.b); //1 2

爲了提高抽象能力,JavaScript 的屬性被設計成比別的語言更加複雜的形式,它提供了數據屬性和存取器屬性(getter/setter)兩類。

  • JavaScript 物件的兩類屬性
  • 對 JavaScript 來說,屬性並非只是簡單的名稱和值,JavaScript 用一組特徵(attribute)來描述屬性(property)。

先來說第一類屬性,數據屬性。它比較接近於其它語言的屬性概念。數據屬性具有四個特徵。

  • value:就是屬性的值。
  • writable:決定屬效能否被賦值。
  • enumerable:決定 for in 能否列舉該屬性。
  • configurable:決定該屬效能否被刪除或者改變特徵值。

在大多數情況下,我們只關心數據屬性的值即可。第二類屬性是存取器(getter/setter)屬性,它也有四個特徵。

  • getter:函數或 undefined,在取屬性值時被呼叫。
  • setter:函數或 undefined,在設定屬性值時被呼叫。

存取器屬性使得屬性在讀和寫時執行程式碼,它允許使用者在寫和讀屬性時,得到完全不同的值,它可以視爲一種函數的語法糖。

講到了這裏,如果你理解了物件的特徵,也就可以理解爲什麼會有 **「JavaScript 不是物件導向」 ** 這樣的說法了。

這是由於 JavaScript 的物件設計跟目前主流基於類的物件導向差異非常大。可事實上,這樣的物件系統設計雖然特別,JavaScript 語言標準也已經明確說明,JavaScript 是一門物件導向的語言。

型別系統

接下來繼續來聊另一個非常重要的概念,同時也是很容易被大家忽略的內容,那就是 JavaScript 中的’型別系統’。

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

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


比如在 C/C++ 中,你需要爲要處理的每條數據指定型別,這樣定義變數:

int count = 100;
char* name = "zwj";

C/C++ 編譯器負責將這些數據片段轉換爲供 CPU 處理的正確命令,通常是二進制的機器程式碼。

在JavaScript中引擎可以根據數據自動推導出型別,因此就不需要直接指定變數的型別。

var counter = 100; 
const name = "ZWJ";

通用的型別有數位型別、字串、Boolean 型別等等,引入了這些型別之後,編譯器或者直譯器就可以根據型別來限制一些有害的或者沒有意義的操作。

比如在 Python 語言中,如果使用字串和數位相加就會報錯,因爲 Python 覺得這是沒有意義的。而在 JavaScript 中,字串和數位相加是有意義的,可以使用字串和數位進行相加的。

再比如,你讓一個字串和一個字串相乘,這個操作是沒有意義的,所有語言幾乎都會禁止該操作。

每種語言都定義了自己的型別,還定義瞭如何操作這些型別,另外還定義了這些型別應該如何相互作用,我們就把這稱爲型別系統

關於型別系統

直觀地理解,一門語言的型別系統定義了各種型別之間應該如何相互操作,比如,兩種不同類型相加應該如何處理,兩種相同的型別相加又應該如何處理等。還規定了各種不同類型應該如何相互轉換,比如字串型別如何轉換爲數位型別。

V8是怎麼認爲字串和數位相加是有意義?

接下來我們就可以來看看 V8 是怎麼處理 1+「2"的了。 之前我們提到過它並不會報錯而是輸出字串"12」.

當有兩個值相加的時候,比如:

a+b

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

通俗地理解:

如果 Type(lprim) 和 Type(rprim) 中有一個是 String則:

  • 把 ToString(lprim) 的結果賦給左字串 (lstr);
  • 把 ToString(rprim) 的結果賦給右字串 (rstr);
  • 返回左字串 (lstr) 和右字串 (rstr) 拼接的字串。

如果是其他的(物件) V8 會提供了一個 ToPrimitve 方法,其作用是將 a 和 b 轉換爲原生數據型別,其轉換流程如下:

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

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

Number(1).toString() + "2"

這個過程還有另外一個名詞叫裝箱轉換。

關於裝箱轉換

每一種基本型別 Number、String、Boolean在物件中都有對應的建構函式,所謂裝箱轉換,正是把基本型別轉換爲對應的物件,它是型別轉換中一種相當重要的種類。

在看一個例子:

1.toString();

這裏會直接報錯,原因如下。

數位直接量

原因是JavaScript 規範中規定的數位直接量可以支援四種寫法:十進制數、二進制整數、八進制整數和十六進制整數。

十進制的 Number 可以帶小數,小數點前後部分都可以省略,但是不能同時省略,我們看幾個例子:

.01
12. 
12.01

這都是合法的數位直接量。這裏就有一個問題,也是我們剛剛提出的報錯問題:

1.toString();

這時候1. toString()會被當作成一個帶有小數的數位整體。所以我們要把點單獨成爲一個 token(語意單元),就要加入空格,這樣寫:

1 .toString();
//或者 
(1).toString();

此時就不會報錯了。

但是爲什麼1能呼叫tostring方法, 1不是原始值嗎?

這個過程就是經歷了裝箱轉換,在遇到(1).toString() 根據基本型別 Number 這個建構函式轉換成一個物件。
圍繞拆箱 裝箱 轉換可以寫出很多有意思的程式碼。

{}+[]
  • 以{}開頭的會被解析爲語句塊
  • 此時+爲一元操作符,非字串拼接符
  • []會隱式呼叫toString()方法,將[]轉化爲原始值 ‘’
  • +’’ 被轉化爲數位0
  • 擴充套件:如果將其用()括起來,即({}+[]),此時會顯示"[object Object]",因爲此時{}不再被解析爲語句塊
[]+{}
  • []會隱式呼叫toString()方法,將[]轉化爲原始值 ‘’
  • {}會隱式呼叫toString()方法,將{}轉化爲原始值"[object Object]"
  • +爲字串拼接符
[]+[]
  • []會隱式呼叫toString()方法,將[]轉化爲原始值 ‘’
{}+{}
  • 以{}開頭的會被解析爲語句塊,即第一個{}爲語句塊
  • 此時+爲一元操作符,非字串拼接符
  • 第二個{}會隱式呼叫toString()方法,將{}轉化爲原始值"[object Object]"
  • +"[object Object]" 輸出 NaN
  • 擴充套件 在chrome 瀏覽器中輸出"[object Object][object Object]"

前幾年比較噁心的面試題。

([][[]]+[])[+!![]]+([]+{})[!+[]+!![]]

問題分解:

左 ([][[]]+[])[+!![]]

拆分

[+!![]]

!![] => true

[+!![]] => 1

拆分

([][[]]+[])

[][0] => undefined

undefined+[] =>「undefined」

輸出:「undefined」[1]

([]+{})[!+[]+!![]]

([]+{}) => 「[object Object]」

[!+[]+!![]]

!![] => true => 1

+[] => 0

!0 => 1

[1+1] => 2

輸出: 「[object Object]」[2]

最後: 「undefined」[1]+"[object Object]"[2] ==> nb