為什麼為 const 變數重新賦值不是個靜態錯誤

紫雲飛發表於2016-11-10

const 和 let 的唯一區別就是用 const 宣告的變數不能被重新賦值(只讀變數),比如像下面這樣就會報錯:

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 是什麼,所以本文采用靜態錯誤這個術語。

錯誤當然是越早知道越好,所以靜態錯誤一定比執行時錯誤好,比如下面這種程式碼:

const foo = 1
/*
這裡有很多行程式碼
*/
if (this.isInProduction) { // 只在生產環境中執行的程式碼
    /*
    這裡有很多行程式碼
    */
    foo = 2 // 寫這行程式碼的人忘了 foo 是用 const 宣告的了
}

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

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

那為什麼 ES6 沒有把這個錯誤設計為靜態錯誤呢?其實在 11 年 const 剛剛進入 ES6 草案時,在嚴格模式下為 const 變數重新賦值就是個靜態錯誤(錯誤型別為 SyntaxError),同時在嚴格模式下也是個執行時錯誤(錯誤型別為 TypeError),而且 Brendan Eich 同年也在 SpiderMonkey 裡實現了這一規定(Firefox 7)。到了 12 年,草案改成了不在嚴格模式下也要報那個靜態錯誤,SpiderMonkey 的另外一個工程師 Tom Schuster 在 2014 年 11 月 19 號實現了這一改動(Firefox 36)。

有些同學就問了,為什麼同一個錯誤要在兩個階段報?有靜態錯誤的情況下執行時錯誤應該永遠不會觸發才對啊?這是因為某些情況下的錯誤是無法或者說很難靜態檢測出來的,比如:

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

在 eval 裡為 const 變數重新賦值,這個錯誤無論從規範上還是從實現上還是從邏輯上說,都是不可能靜態分析出的,還比如:

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

引擎在解析到 foo = 2 的時候,還不知道 foo 在後面會成為個只讀變數,引擎很難靜態檢測出這樣的錯誤。也許引擎可以實現,比如把前面解析到的函式內部的隱式全域性變數的資訊存下來,如果後面解析到了一個同名的 const 變數,再報錯,可否?誰知道呢,反正 Firefox 36 當時是檢測不到這樣的錯誤的,把宣告和賦值倒過來就可以了:

還有一種情況是,雖然是先宣告再重新賦值,但宣告和賦值分別處於兩個不同的 <script> 標籤裡,如下:

<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 裡相反

相關文章