爲何爲 const 變量從新賦值不是個靜態錯誤

const 和 let 的惟一區別就是用 const 聲明的變量不能被從新賦值(只讀變量),好比像下面這樣就會報錯:html

const foo = 1
foo = 2 // TypeError: Assignment to constant variable.
注:本文不會使用「常量」這個術語,由於我覺的這個術語容易有歧義:有些人把數字、字符串等這些不可改變的字面量稱爲常量,也有人把一些只讀屬性稱爲常量,好比 Math.PI,還有人把 ES6 裏用 const 聲明的變量稱爲常量。不過通常來講,這點歧義不是個事。

但遺憾的是,這個錯誤不是個靜態錯誤(static error),而是個運行時錯誤(runtime error)。靜態錯誤,也被稱爲解析錯誤(parsing error),由於是在解析的時候報的錯,其實規範裏的正統叫法叫提早錯誤(early error),有時候也能看到對應的的叫法 late error,但其實規範裏的正統叫法只有 runtime error。考慮到通常人徹底不知道 early error 是什麼,因此本文采用靜態錯誤這個術語。git

錯誤固然是越早知道越好,因此靜態錯誤必定比運行時錯誤好,好比下面這種代碼:es6

const foo = 1
/*
這裏有不少行代碼
*/
if (this.isInProduction) { // 只在生產環境中執行的代碼
    /*
    這裏有不少行代碼
    */
    foo = 2 // 寫這行代碼的人忘了 foo 是用 const 聲明的了
}

alert(foo) // 開發環境彈出 1,線上環境報錯,悲劇

假如 foo = 2 是個靜態錯誤,這個代碼在開發環境就直接報錯了,即使 foo = 2 沒有被執行到。 github

那爲何 ES6 沒有把這個錯誤設計爲靜態錯誤呢?其實在 11 年 const 剛剛進入 ES6 草案時,在嚴格模式下爲 const 變量從新賦值就是個靜態錯誤(錯誤類型爲 SyntaxError),同時在嚴格模式下也是個運行時錯誤(錯誤類型爲 TypeError),並且 Brendan Eich 同年也在 SpiderMonkey 裏實現了這一規定(Firefox 7)。到了 12 年,草案改爲了不在嚴格模式下也要報那個靜態錯誤,SpiderMonkey 的另一個工程師 Tom Schuster 在 2014 年 11 月 19 號實現了這一改動(Firefox 36)。api

有些同窗就問了,爲何同一個錯誤要在兩個階段報?有靜態錯誤的狀況下運行時錯誤應該永遠不會觸發纔對啊?這是由於某些狀況下的錯誤是沒法或者說很難靜態檢測出來的,好比:瀏覽器

const foo = 1
let script = "foo = 2"
eval(script) // 註定是個運行時錯誤

在 eval 裏爲 const 變量從新賦值,這個錯誤不管從規範上仍是從實現上仍是從邏輯上說,都是不可能靜態分析出的,還好比:ecmascript

function f() { 
  foo = 2 // 多是個靜態錯誤嗎?
}
const foo = 1
f()

引擎在解析到 foo = 2 的時候,還不知道 foo 在後面會成爲個只讀變量,引擎很難靜態檢測出這樣的錯誤。也許引擎能夠實現,好比把前面解析到的函數內部的隱式全局變量的信息存下來,若是後面解析到了一個同名的 const 變量,再報錯,能否?誰知道呢,反正 Firefox 36 當時是檢測不到這樣的錯誤的,把聲明和賦值倒過來就能夠了:編輯器

還有一種狀況是,雖然是先聲明再從新賦值,但聲明和賦值分別處於兩個不一樣的 <script> 標籤裏,以下:ide

<script>
const foo = 1
</script>
<script>
foo = 2
</script> 

引擎在解析第二個 <script> 裏的 foo = 2 時可能不會去管 foo 是否是已經被聲明過了(好比你的編輯器在靜態解析這個 tab 裏的 js 文件的時候,會去考慮另一個 tab 裏的 js 文件嗎?),Firefox 36 實現的就是這樣的(在 JS 命令行裏執行的每一行代碼,都至關因而放在網頁裏一個單獨的 <scirpt> 標籤裏執行的同樣):函數

寫在一行就報靜態錯誤(非嚴格模式下也報靜態錯誤),分紅兩行就靜默失敗(沒有被靜態分析出錯誤,且運行時錯誤只在嚴格模式下才報)。

關於第三種狀況,當 Tom Schuster 在 14 年 11 月 7 號提了 bug 準備實現那個改動的時候,我就預料到會產生這樣的表現,我當天晚上在 IRC 羣裏找到了 Tom Schuster(evilpie),詢問他有沒有覺的這種表現有點怪,他的回答是說這種怪異只會發生在 JS 的命令行裏,不會發生在網頁裏。的確,在正常的網頁裏,其實很難遇到聲明 const 變量和爲它從新賦值出如今兩個 <script> 裏的狀況。我當時雖然以爲他說的有點道理,但仍是隱約覺的哪裏有問題,但連我本身也說不出來問題是什麼。

而後大概次日(記不清了),我就發現,原來早在一個月前(2014 年的 10 月份),SpiderMonkey 的另一個工程師  Shu-yu Guo 就已經在 esdiscuss 提過一個相關的問題(這個帖子的內容是本文的核心)了,並且問題說的很是簡單明瞭:

1. 關於引擎應該多麼努力去檢測這個靜態錯誤,規範說的太籠統,可能致使引擎實現有差別。

的確,下面就是當時規範裏關於這個 early error 檢測的描述,規範只說了一句 can be statically determined,並無具體說 how,我上面舉的一些 Firefox 36 沒檢測到的狀況也證明了這一點。

  • It is a Syntax Error if LeftHandSideExpression is an IdentifierReference that can be statically determined to always resolve to a declarative environment record binding and the resolved binding is an immutable binding. 

2. 靜態錯誤是在任何模式下都報,而運行時錯誤倒是隻在嚴格模式下才報。

我看到這裏才恍然大悟,這不就是我前一天覺的有問題的點嗎。。。一句代碼都能靜態的分析出有錯了,結果在執行的時候卻沒錯?這說不通啊,沒天理了。

ES6 的編輯 Allen Wirfs-Brock 在帖子二樓針對這兩點一一作了回覆:

1. 關於第一點,這個是已知的問題了,並且已經建了相關的 bug(網站的 https 證書過時了;裏面還舉了另外兩個難以靜態檢測的例子),規範會嘗試去詳細闡述 can be statically determined 具體指什麼。

2. 關於第二點,這個是規範的 bug,不是故意這樣設計的,bug 緣由是由於在 ES6 裏,爲一個 const 變量從新賦值的運行時錯誤和 ES5 裏嚴格模式下爲一個函數表達式的函數名從新賦值的運行時錯誤是在同一個內部方法(SetMutableBinding)裏拋出的:

(function foo() {
  "use strict"
  foo = 1
})()

由於後者是隻在嚴格模式下報錯的,因此前者也繼承了這一表現,這是個 bug,這兩種錯誤應該分開。

其實 2 樓的回覆已經解決了樓主的疑問,這個帖子本來要討論的東西已經有結論了,結論就是:無論什麼模式都報靜態錯誤(規範會完善具體的靜態檢測規則);無論什麼模式都報運行時錯誤(全部逃過靜態檢測的錯誤都會在這裏被捕獲)。很完美,不是嗎。

然而這時,V8 的工程師 Erik Arvidsson 在三樓跳出來講:帶有預解析器的的引擎要實現這個靜態檢測難度很大,規範要不更強制一點,要不乾脆刪掉這個要求,模棱兩口可能致使各引擎的實現不統一。

而後 V8 的另一個工程師 Andreas Rossberg 也發帖說了一些見解,我總結一下他說的:

1. 報這個靜態錯誤須要有完整的 AST,而 V8 的預解析器目前作不到這一點

2. 非要讓引擎實現這個可能引發很大的性能問題,並且可能很難優化

3. 這種錯誤不是特別常見,非讓引擎處理性價比不高,仍是交給 lint 工具去作吧

4. 一個一樣很難靜態檢測出的錯誤 - 嚴格模式下爲不存在的變量賦值("use strict"; foo = 1),就是個運行時錯誤,這個錯誤不該該搞特殊

通過這個帖子的討論後,在一個月後也就是 2014 年 11 月 18 號的 TC39 會議上,TC39 決定把爲 const 變量從新賦值的靜態錯誤刪去,只留下運行時錯誤(任何模式)。會議記錄裏寫着刪掉的緣由是「引擎實現有難度」和「哪些狀況下爲 const 變量從新賦值應該被靜態檢測出來沒有達成共識」。

而後我又跑到 SpiderMonkey 的 IRC 羣裏告訴他們:規範改了,大家前兩天實現的靜態錯誤應該去掉了,而後招來一羣 SpiderMonkey 的人吐槽規範太不穩定了。

關於 let/const,目前網上較爲推崇的一種代碼風格是全用 const,除非這個變量要被從新賦值,才改爲 let。ESLint 有個 prefer-const 規則能夠強制你作到這一點,我在此告誡各位,若是你使用這種編碼風格,你的編輯器最好開啓 ESLint 的 no-const-assign 的規則,不然我不肯定這麼用 const 能給你帶來多大的好處,但我知道有可能讓你遭遇文章開頭的那種線上 bug。

額外小竅門:如何判斷某個錯誤是靜態錯誤仍是運行時錯誤

絕大多數狀況下,SyntaxError 類型的錯誤就是靜態錯誤,而其餘類型的錯誤就是運行時錯誤,但也有特例,好比:

1 = 2 // 靜態錯誤,可是個 ReferenceError

還有:

/(/ // 在 V8 裏是個運行時錯誤,可是個 SyntaxError,SpiderMonkey 裏是個靜態錯誤

怎麼知道的?我一般是在瀏覽器開發者工具的控制檯裏寫 alert();而後後面跟上測試代碼,好比:

alert();1 = 2 // 不會彈出 alert,證實 1 = 2 是個靜態錯誤 

和:

alert();/(/ // Chrome 裏會彈出 alert,證實 /(/ 是個運行時錯誤,Firefox 裏相反
相關文章
相關標籤/搜索