翻譯 | 像 JavaScript 一樣思考

,發表於2017-09-29

原文連結: https://davidwalsh.name/thinking-javascript

幾天前我在一個專題討論會講 JavaScript,午飯時候一個學員跑來向我請教一個 JS 難題,而它確實把我給難住了。他保證說這個問題是偶然間遇到的,而我對此表示懷疑,因為這極有可能是一道有意而為之的燒腦題。

我得承認,最初的幾次分析,我都搞錯了,不得不藉助解析器來執行程式碼,然後查閱規範(以及向一位大牛請教)來搞清楚到底發生了什麼。既然我在這個過程中學到一些東西,我決定分享給大家。

我並不是教你有意寫(但願也不會讀到)這樣的程式碼,但是能夠按照 JavaScript 自身的原理來進行思考可以讓你寫出更好的程式碼。

背景介紹

把我難住的是這麼一個問題:為什麼第一行程式碼可以正常執行(編譯且執行)而第二行卻報錯了呢?

[[]][0]++;

[]++;

照理說 [[]][0][] 應該是等價的,所以二者要麼都可以正常執行,要麼都會失敗。

思忖片刻之後,我的第一反應是二者都會報錯,但是報錯的原因不盡相同。在這裡我犯了幾點錯誤。實際上,第一行程式碼是合法的程式碼(即便看起來很傻)。

儘管我試圖按照 JavaScript 的執行方式來思考,還是犯錯了。很不幸,我把一些事情給搞混了。無論一個人的知識多麼淵博,還是會輕易發現自己還有很多不知道的地方的。

這正是我試圖讓人們承認“你並不懂 JS ”的原因;沒有人會對一門像 JS 這樣複雜的程式語言完全精通。我們會先學會一部分,再學會一部分,然後一直學下去。這是一個會一直持續下去、沒有終點的過程。

我犯的錯誤

首先我觀察到這裡用到了兩個 ++ ,我的第一反應是二者都會執行失敗,因為一元后置操作符 ++,比如在 x++ 中,幾乎等同於 x = x + 1 。這意味著 x (無論是什麼值)必須是一個可以出現在賦值運算子 = 左側的合法的東東。

實際上呢,上面這段論述是對的,這一點我說的沒問題,但是原因錯了。

我出錯的地方在於,我認為 x++ 相當於 x = x + 1;這樣的話,[]++ 就相當於 [] = [] + 1,而這是不正確的。然而儘管這看起來很詭異,但實際上卻是沒有問題的。在 ES6 中,[] = .. 是陣列解構賦值表示式,而這是合法的。

x++ 看作是 x = x + 1 具有誤導性,在思維上偷懶了,也難怪讓我誤入歧途。

另外,我還認為第一行程式碼是完全錯誤的。我的思路是,[[]] 構成了一個陣列(外層的 [ ]),內層的 [] 相當於屬性訪問操作,這樣的話,它會被轉化為字串(""),結果就是 [""]。而這是沒有任何意義的。我不知道我的思維為什麼會如此混亂。

當然了,要讓外層的 [ ] 作為被訪問的陣列,需要採用 x[[]] 的形式,其中 x 是被訪問的物件,而不僅僅是 [[]] 自身。不管怎樣,思路完全錯了。犯傻了!

思路修正

讓我們從最簡單的開始修正。為什麼 []++ 是非法的呢?

要獲得正確的答案,我們應該求助於這方面的權威官方資料——規範

按照規範裡的說法,x++ 中的 ++ 屬於“Update Expression”的一種,稱為“Postfix Increment Operator”。它要求 x 部分是合法的 “Left-Hand Side Expression” —— 簡單來說,就是能夠出現在 = 的左邊的表示式。實際上,更準確的說法是賦值操作的合法物件。

檢視一下賦值操作物件的合法表示式列表,我們可以在諸多表示式中看到“Primary Expression”和“Member Expression”這兩種。

仔細看一下“基本型別表示式(Primary Expression)”,你會發現“陣列字面量(Array Literal)” (比如我們前面提到的 [ ]!)是合法的,至少從語法的角度來說是這樣的。

所以呢,等等![] 可以是一個合法的左側表示式,那麼就可以與 ++ 一起出現了。嗯!既然如此為什麼 []++ 還會報錯呢?

其實你忽略的一點——也是我之前所忽略的——是,報的錯誤根本不是語法錯誤(SyntaxError)!這是一個執行時錯誤——ReferenceError

有時候人們會問我另外一個令人困惑的——但與此關聯緊密的—— JS 中的現象,即程式碼語法完全合法(但是會在執行時報錯):

2 = 3;

很顯然,一個數字字面量不應該被賦值。這毫無意義。

但是這並不是非法的語法,只是不合理的執行時邏輯。

那麼規範的哪一部分規定使得 2 = 3 執行失敗呢?而導致 2 = 3 執行失敗的原因就是導致 []++ 失敗的原因。

上面的兩個操作都用到了一個抽象的演算法,在規範中稱為 "PutValue"。該演算法的第3步是這麼說的:

If Type(V) is not Reference, throw a ReferenceError exception.

Reference規範中規定的一種特殊的型別,指代任何型別的、可以標記記憶體中的一塊兒區域的、可以為此賦值的表示式。換句話說,只有 Reference 型別的變數可以作為合法的賦值物件。

很顯然,2[] 都不屬於 Reference 型別,這就是為什麼會在執行時出現 ReferenceError;它們都不是合法的賦值物件呀!

但是...

放心吧,我還沒把第一行程式碼給忘了,這行程式碼是可以執行的。要記得我先前的思路有問題,所以下面需要作出一些修正。

[[]] 本身根本不屬於什麼陣列訪問。它只是一個陣列恰巧包含了另外一個陣列作為它的唯一元素而已。諾,就像下面這樣:

var a = [];
var b = [a];

b;  // [[]]

怎麼樣?

那麼,[[]][0] 是個什麼東西呢?讓我們再次用臨時變數進行分解:

var a = [];
var b = [a];

var c = b[0];
c;  // [] -- 又名: `a`!

所以之前的結論是正確的,[[]][0] 相當於 [] 本身。

現在回到最初的問題:為什麼第一行程式碼可以執行而第二行卻不能呢?

正如我們早先看到的那樣,“Update Expression” 要求提供 “LeftHandSideExpression”。其中一個合法的表示式就是“Member Expression”,比如 x[0] 中的 [0] 就屬於成員表示式。

看起來是不是有點眼熟?對了,[[]][0] 就是一個成員表示式。

所以呢,就語法來說,[[]][0]++ 本身是合法的。

且慢!

如果 [] 不是 Reference 型別的話,[[]][0] —— 它的求值結果是 [] —— 怎麼能被看做是 Reference 型別而 PutValue(..) 沒有報錯呢?

這就是讓人覺得蹊蹺的地方。這裡我要感謝我的好朋友 Allen-Wirfs Brock —— JS 規範的前編輯團隊成員,是他讓我理清了頭緒。

成員表示式的求值結果並非結果本身([]),而是對該值的一個引用(Reference)——可以參考這裡的第8步。因此實際上,諸如 [0] 的訪問返回的是對外部陣列的第 0 個位置的引用,而非位於該位置的值本身。

這就是為什麼 [[]][0] 可以作為合法的左側表示式原因:歸根結底這貨是個引用型別值(Reference)啊!

因此,真是情況是,++ 確實將值進行了更新,這一點我們可以分步演示:

var a = [[]];
a[0]++;

a;  // [1]

成員表示式 a[0] 返回的是陣列 [],而數學表示式 ++ 會將其強制轉化為一個基本型別的數字(先轉成 "" 再轉成 0)。++ 操作會將該值增加到 1 並賦值給 a[0]a[0]++ 相當於 a[0] = a[0] + 1

一個小小的提醒:如果在瀏覽器的控制檯執行 [[]][0]++ 的話返回的是 0 而不是 1[0] 。這是怎麼回事呢?

原因在於 ++ 返回的是“初始值”(好吧,是經過轉化後的值,參考這裡的第2步與第5步),而不是經過更新後的值。所以 0 被返回給控制檯,而 1 通過引用(Reference)放到了陣列裡。

當然了,如果沒有像我們上面做的那樣將外層陣列賦值給一個變數進行引用的話,這種更新是沒有任何意義的,因為這個值是無法獲取的。但是它的的確確被更新了。嗯,Reference,棒棒噠!

覆盤

我不知道你會因為這些細微的差異而更加喜歡 JS 呢還是倍感受挫。但是對我來說,上面的這番努力讓我更加敬重這門語言,抖擻精神向著更深處進發。我認為任何一門程式語言都會存在一些邊邊角角的特性,有些大受歡迎,有些卻讓人抓狂。

不管你站在哪一邊,有一點是沒有爭議的,那就是無論選擇何種工具,按照工具自身的原理來進行思考,會讓你用起來更加得心應手。像 JavaScript 一樣思考,祝開心!

相關文章