delete 0:到底是在刪除什麼?

本文出自周愛民《JavaScript 核心原理解析》專欄。

你好,我是周愛民,今天想和大家從 JavaScript 中最不起眼的、使用率最低的一個運算——delete 聊起。

你知道,JavaScript 是一門面向對象的語言。它很早就支持了 delete 運算,這是一個元老級的語言特性。但細追究起來,delete 其實是從 JavaScript 1.2 中才開始有的,與它一同出現的,是對象和數組的字面量語法。有趣的是,JavaScript 中最具惡名的 typeof 運算其實是在 1.1 版本中提供的,比 delete 運算其實還要早。這裏提及 typeof 這個聲名狼籍的運算符,主要是因爲 delete 的操作與類型的識別其實是相關的。

習慣中的“引用”

早期的 JavaScript 在推廣時,仍然採用傳統的數據類型的分類方法,也就是說,它宣稱自己同時支持值類型和引用類型的數據,並且呢,所謂值類型中的字符串是按照引用來賦值和傳遞引用(而不是傳遞值)的。這些都是當時“開發人員的概念集”中已經有的、容易理解的知識,不需要特別地解釋。

但是什麼是引用類型呢?

在這件事上,JavaScript 偷了個懶,它強行定義了“Object 和 Function 就是引用類型”。這樣一來,引用類型和值類型就給開發人員講清楚了,對象和函數呢,也就可以理解了:它們按引用來傳遞和使用。

絕大多數情況下,這樣解釋起來是行得通的。但是到了 delete 運算這裏,就不行。

因爲這樣一來,delete 0就是刪除一個值,而delete x就既可能是刪除一個值,也可能是刪除一個引用。然而,當時 JavaScript 又同時約定:那些在 global 對象上聲明的屬性,就“等同於”全局變量。於是,這就帶來了第三個問題:delete x還可能是刪除一個 global 對象上的屬性。而它在執行這個操作的時候,看起來卻像是一個全局變量(的名字)。

這中間有哪些細節的區別呢?delete 這個運算的表面意思,是該運算試圖銷燬某種東西。然而,delete 0中的 0 是一個具體的、字面量表示的“值”。一個字面量值“0”如何在現實世界中銷燬呢?假定它銷燬了,那是不是說,在這個語言當前的運行環境中,就不能使用 0 這個值了呢?顯然,這不合理。

所以,JavaScript 認爲“所有刪除值的 delete 就直接返回 true”,表明該行爲過程中沒有異常。很不幸,JavaScript 1.2的時代並沒有結構化異常處理(即 try…catch 語句)。所以,通過函數調用中返回 true 來表明“沒有異常”,其實是很常規的做法。

然而,返回值只表明執行過程中沒有異常,但實際的執行行爲是“什麼也沒發生”。你顯然不可能真的將“0”從執行系統中清理出去。

那麼接下來,就還剩下刪除變量和刪除屬性。由於全局變量實際上是通過全局對象的屬性來實現的,因此刪除變量也就存在識別這兩種行爲的必要性。例如:

delete x

這行代碼究竟是在刪除什麼呢?出於 JavaScript 是動態語言這項特性,所以從根本上來說,我們是沒有辦法在語法分析期來判斷x的性質的。所以現在,需要有一種方法在運行期來標識x的性質,以便進一步地處理它。

這就導致了一種新的“引用”類型呼之欲出。(“引用”到底有什麼用?它對我們的編程有什麼實際的影響呢?我會在《JavaScript 核心原理解析》專欄的 02 講中詳細爲你講解。)

到底在刪除什麼?

探索工作往往如此,是所謂“進五退一”,甚至是“進五退四”。在今後的專欄文章中,你往往會看到,我在碰觸到一種新東西的時候會竭力向前,但隨後又後退好幾步,再來討論一些更基礎層面的東西。這是因爲如果不把這些基礎概念說得清楚明白,那麼往前衝的那幾步常常就被帶偏了方向。

一如現在這個問題:delete 0到底是在刪除什麼?

對於一門編譯型語言來說,所謂“0”,就是上面所述的一個值,它可以是基礎值(Primitive values),也可以是數值類型。但如果將這個問題上升到編譯之前的、所謂語法分析的階段,那麼“0”就會被稱爲一個記號(Tokens)。一個記號是沒有語義的,記號既可以是語言能識別的,也可以是語言不能識別的。唯有把這二者同時納入語言範疇,那麼這個語言才能識別所謂的“語法錯誤”。

delete 不僅僅是要操作 0 或 x 這樣的單個記號或標識符(例如變量)。因爲這個語法實際起作用的是一個對象的屬性,也就是“刪除對象的成員”。那麼它真正需要的語法其實是:

delete obj.x

只不過因爲全局對象的成員可以用全局變量的形式來存取,所以它纔有了

delete x

這樣的語法語義而已。所以,這正好將你之前所認識的倒轉過來,是刪除 x 這個成員,而不是刪除 x 這個值。不過終歸有一點是沒錯的:既然沒辦法表達異常,而 delete 0 又不產生異常,那麼它自然就該返回 true。

然而,如果你理解了delete obj.x,那麼就一定會想到:obj.x既不是之前說過的引用類型,也不是之前說過的值類型,它與typeof(x)識別的所有類型都無關。因爲,它是一個表達式。

所以,delelet 這個操作的正式語法設計並不是“刪除某個東西”,而是“刪除一個表達式的結果”:

delete UnaryExpression

表達式的結果是什麼?

在 JavaScript 中表達式是一個很獨特的東西,所有一切表達式運算的終極目的都是爲了得到一個值,例如字符串。然後再用另外一些操作將這個值輸出出來,例如變成網頁中的一個元素(element)。這是 JavaScript 語言創生的原力,也是它的基礎設計。也只因爲有了這種設計,它才變得既像面向對象的,又像函數式語言的樣子。

表達式的執行特性,以及表達式與語句的關係等等細節,回頭我放在第二階段的內容中講給你聽。現在我們只需要關注一個要點,表達式計算的結果到底是什麼?因爲就像上面所說的,這個結果,纔是delete這個操作要刪除的東西。

在 JavaScript 中,有兩個東西可以被執行並存在執行結果值(Result),包括語句和表達式。比如你用eval()來執行一個字符串,那麼事實上,你執行的是一個語句,並返回了語句的值;而如果你使用一對括號來強制一個表達式執行,那麼這個括號運算得到的,就是這個表達式的值。

表達式的值,在 ECMAScript 的規範中,稱爲“引用”。

這是一種稱爲“規範類型”的東西。

規範中的“引用”

事實上這個概念出現得也很早。從 JavaScript 1.3 開始,ECMAScript 規範就在語言定義的層面,正式地將上述的天坑補起來,推出了上面說到的這個“(真正的)引用類型”。但是,由於這個時候規範的影響力在開發人員中並不那麼大,所以開發人員還是習慣性地將對象和函數稱爲引用,而其它類型就稱爲值,並且繼續按照傳統的理解來解釋 JavaScript 中對數據的處理。

這種情況下,一個引用只是在語法層面上表達“它是對某種語法元素的引用”,而與在執行層面的值處理或引用處理沒關係。於是,下面這行簡短的語句

delete 0

事實上是在說:JavaScript 將 0 視爲一個表達式,並嘗試刪除它的求值結果。

所以,現在這裏的 0,其實不是值(Value)類型的數據,而是一個表達式運算的結果值(Result)。而在進一步的刪除操作之前,JavaScript 需要檢測這個 Result 的類型:

  • 如果它是值,則按照傳統的 JavaScript 的約定返回 true;
  • 如果它是一個引用,那麼對該引用進行分析,以決定如何操作。

這個檢測過程說明,ECMAScript 約定:任何表達式計算的結果(Result)要麼是一個值,要麼是一個引用。並且需要留意的是,在這個描述中,所謂對象,其實也是值。準確地說,是“非引用類型”。例如:

delete {}

那麼顯然,這裏要刪除的一對大括號是表示一個字面量的對象,當它被作爲表達式執行的時候,結果也是一個值。這也是我常常將所有這類表達式稱爲“單值表達式”的原因,這裏並沒有所謂的“引用”。你可以像下面這樣,非常細緻而準確地解釋這一行代碼:單值表達式的運算結果返回那個“對象字面量”的單值,然後,delete運算髮現它的操作數是“值/非引用類型”,就直接返回了 true。

所以,什麼也沒有發生。

還會發生什麼

那麼到底還會發生什麼呢?

在 JavaScript 的內部,所謂“引用”是可以轉換爲“值”,以便參與值運算的。因爲表達式的本質是求值運算,所以引用是不能直接作爲最終求值的操作數的。這依賴於一個非常核心的、稱爲“GetValue()”的內部操作。所謂內部操作,也稱爲內部抽象操作(internal abstract operations),是 ECMAScript 描述一個符合規範的引擎在具體實現時應當處理的那些行爲。

GetValue()是從一個引用中取出值來的行爲。這有什麼用呢?比如說下面這行代碼:

x = x

我們上面說過,所謂 x 其實是一個引用。上面的表達式其實是一個賦值表達式,那麼“引用 x 賦值給引用 x”有什麼意義呢?其實這在語法層面來解釋是非常直接的:

所有賦值操作的含義,是將右邊的“值”,賦給左邊用於包含該值的“引用”。

那麼上面的x=x,其實就是被翻譯成:

x = GetValue(x)

來執行的。而 JavaScript 識別兩個 x 的不同的方法,就稱爲“手性”,即是所謂“左手端(lhs, left hand side)”和“右手端(rhs)”。它本來是用來描述自然語言的語法中,一個修飾詞應該是放在它的主體的前面或是後面的。而在程序設計語言中,它用來說明一個記號(Token)是放在了賦值符號(例如“=”號)的左邊或是右邊。作爲一個簡單的結論,區別上例中的兩個x的方法就是:

如果 x 放在左邊作爲 lhs,那麼它是引用;如果放在右邊作爲 rhs,那麼就是值。

所以x=x的語義並不是“x賦給x”,而是“把值x賦給引用x”。

所以“delete x”的歸根到底說起來,是在刪除一個表達式的、引用類型的結果(Result),而不是在刪除 x 表達式,或者這個刪除表達式的值(Value)。是的,在JavaScript中的delete是一個很罕見的、能直接操作“引用”的語法元素。由於這裏的“引用”是在 ECMAScript 規範層面的概念,因此在 JavaScript 語言中能操作它的語法元素其實非常非常少。然而很不幸,delete 就是其中之一。

告訴我這些有什麼用

等等,我想你一定會問了:神啊,讓我知道這些究竟又什麼用呢?我永遠也不會去執行delete 0這樣的操作啊!

是的。但是我接下來要告訴你的事實是:obj.x也是一個引用。對象屬性存取是 JavaScript 的面向對象的基本操作之一,所以本質上我們早就在使用“引用”這個東西了,只不過它太習以爲常,所以大家都視而不見。“屬性存取("."運算符)”返回一個關於“x”的引用,然後它可以作爲下一個操作符(例如函數調用運算“()”)的左手端來使用,這纔有了著名的“對象方法調用”運算:

obj.x()

因爲在對象方法調用的時候,函數x()是來自於obj.x這個引用的,所以這個引用將obj這個對象傳遞給 x(),這纔會讓函數 x() 內部通過 this 來訪問到 obj。根本上來說,如果obj.x只是值,或者它作爲右手端,那麼它就不能“攜帶” obj 這個對象,也就完成不了後續的方法調用操作。

對象存取 + 函數調用 = 方法調用

這是 JavaScript 通過連續表達式運算來實現新的語義/語法的經典示例。而所謂“連續運算”其實是函數式運算範式的基本原則。也就是說,obj.x()是在 JavaScript 中集合了“引用規範類型操作”、“函數式”、“面向對象”和“動態語言”等多種特性於一體的一個簡單語法。

而它對語言的基礎特性的依賴,就在於:

  • delete 0中的這個0是一個表達式求值;
  • delete x中的x是一個引用;
  • delete obj.xobj.x是一組表達式連續運算的結果(Result/引用);

於是,我們現在可以解釋,當 x 是全局對象 global 的屬性時,所謂delete x其實只需要返回global.x這個引用就可以了。而當它不是全局對象 global 的屬性時,那麼就需要從當前環境中找到一個名爲x的引用。找到這兩種不同的引用的過程,稱爲 ResolveBinding;而這兩種不同的x,稱爲不同環境下綁定的標識符/名字。

分享回顧

《JavaScript 核心原理解析》專欄下一講中我將給你講述的,就是這個名字從聲明到發現的全過程。在今天分享的內容中,有一些知識點我來帶你回顧一下。

  • delete 運算符嘗試刪除值數據時,會返回 true,用於表示沒有錯誤(Error)。
  • delete 0 的本質是刪除一個表達式的值(Result)。
  • delete x 與上述的區別只在於 Result 是一個引用(Reference)。
  • delete 其實只能刪除一種引用,即對象的成員(Property)。

所以,只有在delete x等值於delete obj.x時 delete 纔會有執行意義。例如with (obj) ...語句中的 delete x,以及全局屬性 global.x。

希望你喜歡我的分享。戳此可以免費試讀我的專欄

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章