3

Lox 語言

你能為某人做的最美好的事情是什麼,比為他們做早餐更好?

安東尼·波登

我們將在本本書的剩餘部分闡明 Lox 語言的每一個陰暗角落,但讓您立即開始為直譯器編寫程式碼,而沒有至少瞥見我們最終將獲得的東西,這似乎很殘酷。

同時,我不想在您接觸文字編輯器之前,就拖著您看完大量的語言法規和規格說明。因此,這將是對 Lox 的溫和友好的介紹。它將省略許多細節和邊角案例。我們稍後有足夠的時間來處理這些。

3 . 1你好,Lox

這是您對Lox的第一個體驗

// Your first Lox program!
print "Hello, world!";

正如 `//` 行註解和尾隨的分號所暗示的那樣,Lox 的語法是 C 語言家族的成員。(字串周圍沒有括號,因為 `print` 是一個內建陳述式,而不是一個函式庫函式。)

現在,我不會聲稱 C 有一個*很棒*的語法。如果我們想要一些優雅的東西,我們可能會模仿 Pascal 或 Smalltalk。如果我們想要完全的斯堪的納維亞家具簡約風格,我們會使用 Scheme。這些都有它們的優點。

C 語言的語法反而具有您在語言中通常會發現更有價值的東西:*熟悉度*。我知道您已經很熟悉這種風格,因為我們將用來*實作* Lox 的兩種語言Java 和 C也繼承了它。為 Lox 使用類似的語法可以減少您需要學習的東西。

3 . 2高階語言

雖然這本書的篇幅比我希望的要長,但它仍然不夠大到可以容納像 Java 這樣的大型語言。為了在這幾頁中容納 Lox 的兩個完整實作,Lox 本身必須非常精簡。

當我想到小型但有用的語言時,我想到的是像 JavaScript、Scheme 和 Lua 這樣的高階「腳本」語言。在這三種語言中,Lox 看起來最像 JavaScript,主要是因為大多數 C 語法的語言都這樣。正如我們稍後將學到的那樣,Lox 的作用域方法與 Scheme 非常相似。我們在 第三部分中建立的 Lox C 風格很大程度上歸功於 Lua 的乾淨、高效的實作。

Lox 與這三種語言還有其他兩個共同點

3 . 2 . 1動態型別

Lox 是動態型別的。變數可以儲存任何型別的值,單個變數甚至可以在不同的時間儲存不同型別的值。如果您嘗試對錯誤型別的值執行操作例如,用數字除以字串那麼該錯誤將在執行時被檢測和報告。

有很多理由喜歡靜態型別,但它們並不能抵消為 Lox 選擇動態型別的務實原因。靜態型別系統需要大量的工作來學習和實作。跳過它會讓您得到一個更簡單的語言和一本更短的書。如果我們將型別檢查延遲到執行時,我們的直譯器將更快地啟動並執行程式碼片段。

3 . 2 . 2自動記憶體管理

高階語言的存在是為了消除容易出錯的低階苦工,還有什麼比手動管理儲存體的分配和釋放更乏味?沒有人會在早晨陽光升起時興奮地說:「我等不及要找出今天我分配的每個記憶體位元組的正確 `free()` 呼叫位置了!」

有兩種主要的記憶體管理技術引用計數追蹤垃圾收集(通常簡稱為垃圾收集GC)。引用計數器實作起來簡單得多我認為這就是為什麼 Perl、PHP 和 Python 最初都使用它們的原因。但是,隨著時間的推移,引用計數的局限性變得太麻煩了。所有這些語言最終都添加了完整的追蹤 GC,或者至少足夠多的追蹤 GC 來清理物件週期。

追蹤垃圾收集具有可怕的聲譽。在原始記憶體層級工作*確實*有點令人痛苦。除錯 GC 有時會讓您在夢中看到十六進位傾印。但是,請記住,這本書是關於消除魔法和殺死那些怪物,所以我們*將*編寫自己的垃圾收集器。我認為您會發現該演算法非常簡單且實作起來很有趣。

3 . 3資料型別

在 Lox 的小宇宙中,組成所有物質的原子是內建的資料型別。只有幾個

3 . 4運算式

如果內建資料型別及其文字是原子,那麼運算式一定是分子。其中大多數您都會很熟悉。

3 . 4 . 1算術

Lox 具有您從 C 和其他語言中了解和喜歡的基本算術運算子

add + me;
subtract - me;
multiply * me;
divide / me;

運算子兩側的子運算式是運算元。因為有*兩個*,所以這些稱為二元運算子。(它與「二元」的 1 和 0 用法無關。)因為運算子是 固定在運算元*之間*,所以這些也稱為中綴運算子(相對於前綴運算子,其中運算子位於運算元之前,以及後綴運算子,其中運算子位於運算元之後)。

一個算術運算子實際上是中綴和前綴運算子。` - ` 運算子也可以用於對數字求負。

-negateMe;

所有這些運算子都適用於數字,將任何其他型別傳遞給它們都是錯誤的。例外的是 ` + ` 運算子您也可以將兩個字串傳遞給它來串連它們。

3 . 4 . 2比較和相等

繼續,我們還有一些總是傳回布林結果的運算子。我們可以使用古老的比較運算子來比較數字(並且只能比較數字)。

less < than;
lessThan <= orEqual;
greater > than;
greaterThan >= orEqual;

我們可以測試任何種類的兩個值是否相等或不相等。

1 == 2;         // false.
"cat" != "dog"; // true.

甚至是不同的型別。

314 == "pi"; // false.

不同型別的值*永遠*不相等。

123 == "123"; // false.

我通常反對隱式轉換。

3 . 4 . 3邏輯運算子

非運算子,一個前綴 `!`,如果其運算元為 true,則傳回 `false`,反之亦然。

!true;  // false.
!false; // true.

其他兩個邏輯運算子實際上是以運算式形式出現的控制流程結構。一個 `and` 運算式決定兩個值是否都為 true。如果左運算元為 false,則傳回左運算元,否則傳回右運算元。

true and false; // false.
true and true;  // true.

而 `or` 運算式決定兩個值(或兩者)中的*任何一個*是否為 true。如果左運算元為 true,則傳回左運算元,否則傳回右運算元。

false or false; // false.
true or false;  // true.

andor 像是控制流程結構的原因是它們會進行短路求值and 不僅會在左運算元為假時回傳左運算元,在這種情況下甚至不會計算右運算元。相反地(反向推論?),如果 or 的左運算元為真,則會跳過右運算元。

3.4.4優先順序和分組

所有這些運算子的優先順序和結合性都與您從 C 語言中預期的一致。(當我們進入語法分析時,我們會更精確地說明這一點。)在優先順序不符合您期望的情況下,您可以使用 () 來對內容進行分組。

var average = (min + max) / 2;

由於它們在技術上不是很重要,我已經從我們的小型語言中刪除了其餘典型的運算子組合。沒有位元、移位、模數或條件運算子。我不會為您評分,但如果您在自己實作的 Lox 中新增它們,我會給您加分。

這些是表達式形式(除了幾個與我們稍後將介紹的特定功能相關的形式),所以讓我們更上一層樓。

3.5陳述式

現在我們來看陳述式。表達式的主要工作是產生一個,而陳述式的工作是產生一個效果。由於根據定義,陳述式不會評估為一個值,因此要使其有用,它們必須以其他方式改變世界通常是修改某些狀態、讀取輸入或產生輸出。

您已經看過幾種類型的陳述式。第一個是

print "Hello, world!";

print 陳述式會評估一個單一表達式,並將結果顯示給使用者。您也看過一些像是這樣的陳述式

"some expression";

在表達式後面加上分號 (;) 會將表達式提升為陳述式。這被稱為(相當有想像力地),表達式陳述式

如果您想在預期只有一個陳述式的地方放入一系列陳述式,您可以將它們包裝在區塊中。

{
  print "One statement.";
  print "Two statements.";
}

區塊也會影響作用域,這會將我們帶到下一節 . . .

3.6變數

您可以使用 var 陳述式來宣告變數。如果您省略初始化式,則變數的值預設為 nil

var imAVariable = "here is my value";
var iAmNil;

一旦宣告完成,您自然可以使用其名稱來存取和指派變數。

var breakfast = "bagels";
print breakfast; // "bagels".
breakfast = "beignets";
print breakfast; // "beignets".

我不會在這裡深入探討變數作用域的規則,因為我們將在後續章節中花費大量的時間來詳細了解這些規則。在大多數情況下,它的運作方式與您從 C 或 Java 中預期的一樣。

3.7控制流程

如果您無法跳過一些程式碼或執行某些程式碼多次,就很難編寫有用的程式。這表示需要控制流程。除了我們已經涵蓋的邏輯運算子之外,Lox 還直接從 C 語言中提取了三個陳述式。

if 陳述式會根據某個條件執行兩個陳述式中的一個。

if (condition) {
  print "yes";
} else {
  print "no";
}

while 迴圈會在條件表達式評估為 true 時重複執行主體。

var a = 1;
while (a < 10) {
  print a;
  a = a + 1;
}

最後,我們有 for 迴圈。

for (var a = 1; a < 10; a = a + 1) {
  print a;
}

此迴圈與先前的 while 迴圈執行相同的操作。大多數現代語言也有某種 for-inforeach 迴圈,用於顯式迭代各種序列類型。在真正的語言中,這比我們這裡得到的粗糙 C 樣式 for 迴圈要好得多。Lox 保持基本。

3.8函式

函式呼叫表達式看起來與在 C 語言中的相同。

makeBreakfast(bacon, eggs, toast);

您也可以呼叫函式而不傳遞任何內容給它。

makeBreakfast();

與 Ruby 等語言不同,在這種情況下,括號是強制性的。如果您省略它們,則名稱不會呼叫該函式,而只是引用它。

如果不能定義自己的函式,語言就沒有什麼樂趣。在 Lox 中,您可以使用 fun 來執行此操作。

fun printSum(a, b) {
  print a + b;
}

現在是澄清一些術語的好時機。有些人會將「參數 (parameter)」和「引數 (argument)」混用,對許多人來說,它們是可以互換的。我們將花費大量時間在語意方面進行最細微的區分,因此讓我們精確地使用我們的詞語。從現在開始

函式的主體始終是一個區塊。在其中,您可以使用 return 陳述式來回傳一個值。

fun returnSum(a, b) {
  return a + b;
}

如果執行達到區塊的末尾而沒有遇到 return,則它會隱式回傳 nil

3.8.1閉包

函式在 Lox 中是一級的,這僅表示它們是可以取得引用、儲存在變數中、傳遞等等的真實值。這樣可以運作

fun addPair(a, b) {
  return a + b;
}

fun identity(a) {
  return a;
}

print identity(addPair)(1, 2); // Prints "3".

由於函式宣告是陳述式,您可以在另一個函式內部宣告區域函式。

fun outerFunction() {
  fun localFunction() {
    print "I'm local!";
  }

  localFunction();
}

如果結合區域函式、一級函式和區塊作用域,您會遇到這種有趣的情況

fun returnFunction() {
  var outside = "outside";

  fun inner() {
    print outside;
  }

  return inner;
}

var fn = returnFunction();
fn();

在這裡,inner() 存取在其主體外部、外圍函式中宣告的區域變數。這樣可以嗎?現在,許多語言都從 Lisp 中借鑒了此功能,您可能知道答案是肯定的。

為了使其運作,inner() 必須「保留」對其使用的任何外圍變數的參照,以便它們在外層函式回傳後仍然存在。我們稱執行此操作的函式為閉包。現在,這個詞通常用於任何一級函式,但如果該函式恰好沒有封閉任何變數,則有點用詞不當。

您可以想像,實作這些會增加一些複雜性,因為我們不能再假設變數作用域的運作方式嚴格像是堆疊,其中區域變數會在函式回傳時立即消失。我們將有一個有趣的時光來學習如何正確且有效地使它們運作。

3.9類別

由於 Lox 具有動態型別、詞法(大致為「區塊」)作用域和閉包,因此它大約有一半是函數式語言。但是您將會看到,它也大約有一半是物件導向語言。這兩種範例都有很多優點,因此我認為值得涵蓋其中一些。

由於類別因未能達到其炒作而受到批評,因此首先讓我解釋一下我為何將它們放入 Lox 和本書中。實際上,有兩個問題

3.9.1為什麼任何語言都可能想要是物件導向的?

現在,像 Java 這樣的物件導向語言已經銷售一空,只在體育館表演,所以不再流行喜歡它們了。為什麼有人會用物件建立新的語言?那不是像在 8 音軌上發行音樂一樣嗎?

確實,90 年代的「所有繼承,所有時間」的狂潮產生了一些可怕的類別層次結構,但物件導向程式設計 (OOP) 仍然相當棒。數十億行的成功程式碼是用 OOP 語言編寫的,將數百萬個應用程式發送給開心的使用者。如今,可能大多數在職程式設計師都在使用物件導向語言。他們不可能都那麼錯誤。

特別是對於動態型別語言而言,物件相當方便。我們需要某種方式來定義複合資料類型,以將一堆東西捆綁在一起。

如果我們也可以將方法附加到這些物件上,那麼我們就無需在我們所有的函式前加上它們操作的資料類型名稱,以避免與不同類型的類似函式發生衝突。例如,在 Racket 中,您最終必須將函式命名為 hash-copy(複製雜湊表)和 vector-copy(複製向量),以免它們相互衝突。方法的作用域限定於物件,因此該問題消失了。

3.9.2為什麼 Lox 是物件導向的?

我可以聲稱物件很棒,但它們仍然超出本書的範圍。大多數程式語言書籍,特別是那些試圖實作整個語言的書籍,都會忽略物件。對我來說,這表示這個主題沒有被充分涵蓋。在這樣一個廣泛使用的範式下,這種遺漏讓我感到難過。

鑑於我們許多人整天都在使用物件導向程式語言,世界似乎可以多了解一些關於如何製作它們的文件。你會發現,它其實相當有趣。沒有你想像的那麼困難,但也沒有你認為的那麼簡單。

3 . 9 . 3類別或原型

當談到物件時,實際上存在兩種方法:類別原型。類別先出現,並且由於 C++、Java、C# 和其他類似語言而更為常見。原型是一種幾乎被遺忘的分支,直到 JavaScript 意外地統治了世界。

在基於類別的語言中,有兩個核心概念:實例和類別。實例儲存每個物件的狀態,並具有對該實例類別的參考。類別包含方法和繼承鏈。要調用實例上的方法,總是會有一層間接層。你查找實例的類別,然後你在那裡找到方法。

How fields and methods are looked up on classes and instances

基於原型的語言合併了這兩個概念。只有物件沒有類別而且每個單獨的物件都可能包含狀態和方法。物件可以直接相互繼承(或在原型術語中“委派給”)。

How fields and methods are looked up in a prototypal system

這表示在某些方面,基於原型的語言比類別更為基礎。它們的實作非常巧妙,因為它們非常簡單。此外,它們可以表達許多不尋常的模式,而類別會引導你避開這些模式。

但我看過許多以基於原型的語言編寫的程式碼包括我自己設計的一些程式碼。你知道人們通常如何使用原型的所有功能和靈活性嗎? . . . 他們用它們來重新發明類別。

我不知道為什麼會這樣,但人們似乎自然而然地偏好基於類別(經典?優雅?)的風格。原型在語言中確實更簡單,但它們似乎只通過將複雜性推給使用者來實現這一點。因此,對於 Lox,我們將為我們的使用者省去麻煩,直接內建類別。

3 . 9 . 4Lox 中的類別

有足夠的理由了,讓我們看看我們實際上擁有哪些。類別在大多數語言中包含一組功能。對於 Lox,我選擇了我認為最亮的星星。你可以像這樣宣告一個類別及其方法

class Breakfast {
  cook() {
    print "Eggs a-fryin'!";
  }

  serve(who) {
    print "Enjoy your breakfast, " + who + ".";
  }
}

類別的主體包含其方法。它們看起來像函式宣告,但沒有 fun 關鍵字。執行類別宣告時,Lox 會建立一個類別物件並將其儲存在以類別名稱命名的變數中。就像函式一樣,類別在 Lox 中也是一級的。

// Store it in variables.
var someVariable = Breakfast;

// Pass it to functions.
someFunction(Breakfast);

接下來,我們需要一種建立實例的方法。我們可以加入某種 new 關鍵字,但為了保持簡單,在 Lox 中,類別本身就是實例的工廠函式。像函式一樣調用類別,它會產生一個新的自身實例。

var breakfast = Breakfast();
print breakfast; // "Breakfast instance".

3 . 9 . 5實例化和初始化

僅具有行為的類別並不是非常有用。物件導向程式設計背後的概念是將行為和狀態封裝在一起。為此,你需要欄位。像其他動態型別語言一樣,Lox 允許你自由地將屬性添加到物件上。

breakfast.meat = "sausage";
breakfast.bread = "sourdough";

如果欄位不存在,則賦值給欄位會建立它。

如果你想從方法內部存取目前物件上的欄位或方法,請使用舊有的 this

class Breakfast {
  serve(who) {
    print "Enjoy your " + this.meat + " and " +
        this.bread + ", " + who + ".";
  }

  // ...
}

將資料封裝在物件中一部分是確保物件在建立時處於有效狀態。為此,你可以定義一個初始化器。如果你的類別有名為 init() 的方法,則會在建構物件時自動調用該方法。傳遞給類別的任何參數都會轉發到其初始化器。

class Breakfast {
  init(meat, bread) {
    this.meat = meat;
    this.bread = bread;
  }

  // ...
}

var baconAndToast = Breakfast("bacon", "toast");
baconAndToast.serve("Dear Reader");
// "Enjoy your bacon and toast, Dear Reader."

3 . 9 . 6繼承

每個物件導向語言不僅允許你定義方法,還允許你在多個類別或物件之間重複使用它們。為此,Lox 支援單一繼承。當你宣告類別時,你可以使用小於(<)運算子指定它從哪個類別繼承。

class Brunch < Breakfast {
  drink() {
    print "How about a Bloody Mary?";
  }
}

在這裡,Brunch 是**衍生類別**或**子類別**,而 Breakfast 是**基礎類別**或**父類別**。

父類別中定義的每個方法也可供其子類別使用。

var benedict = Brunch("ham", "English muffin");
benedict.serve("Noble Reader");

甚至 init() 方法也會被繼承。實際上,子類別通常也希望定義自己的 init() 方法。但是也需要調用原始方法,以便父類別可以維護其狀態。我們需要某種方法來調用我們自己實例上的方法,而不會影響我們自己的方法

與 Java 一樣,你使用 super 來實現這一點。

class Brunch < Breakfast {
  init(meat, bread, drink) {
    super.init(meat, bread);
    this.drink = drink;
  }
}

這就是物件導向的全部內容。我試圖將功能集保持在最小限度。本書的結構確實迫使我做出了一個妥協。Lox 不是純粹的物件導向語言。在真正的 OOP 語言中,每個物件都是類別的實例,即使是數字和布林值等原始值也是如此。

由於我們在開始使用內建型別之後才實作類別,因此這會很困難。因此,原始型別的值並不是類別實例意義上的真實物件。它們沒有方法或屬性。如果我試圖讓 Lox 成為適合真實使用者的真實語言,我會修正這一點。

3 . 10標準函式庫

我們快完成了。這就是整個語言,所以剩下的就是「核心」或「標準」函式庫直接在直譯器中實作的功能集,並且所有使用者定義的行為都基於此而建立。

這是 Lox 最可悲的部分。它的標準函式庫超出了簡約主義的範疇,並接近徹底的虛無主義。對於本書中的範例程式碼,我們只需要證明程式碼正在執行並按照其應有的方式執行即可。為此,我們已經有了內建的 print 陳述式。

稍後,當我們開始最佳化時,我們將編寫一些基準測試,並查看執行程式碼需要多長時間。這表示我們需要追蹤時間,因此我們將定義一個內建函式 clock(),該函式返回自程式啟動以來經過的秒數。

並且 . . . 就是這樣了。我知道,對吧?這令人尷尬。

如果你想將 Lox 變成一種實際有用的語言,那麼你應該做的第一件事就是充實它。字串操作、三角函式、檔案 I/O、網路,甚至從使用者讀取輸入都會有所幫助。但是我們不需要本書的任何內容,而且添加它不會教你任何有趣的東西,所以我省略了它。

別擔心,我們將在語言本身中有很多令人興奮的東西讓我們忙碌。

挑戰

  1. 編寫一些範例 Lox 程式並執行它們(你可以使用我的儲存庫中的 Lox 實作)。嘗試提出我在此處未指定的邊緣情況行為。它是否符合你的預期?為什麼或為什麼不?

  2. 這個非正式的介紹遺漏了很多未指定的內容。列出你對該語言語法和語意的一些開放性問題。你認為答案應該是什麼?

  3. Lox 是一種非常小的語言。你認為它缺少哪些功能,這會使其難以用於實際程式?(當然,除了標準函式庫之外。)

設計注意事項:運算式和陳述式

Lox 同時具有運算式和陳述式。有些語言省略了後者。相反,它們將宣告和控制流程結構也視為運算式。這些「一切都是運算式」的語言往往具有函式式血統,包括大多數 Lisp、SML、Haskell、Ruby 和 CoffeeScript。

為此,對於語言中的每個「類似陳述式」的結構,你需要決定它評估為什麼值。其中一些很容易

  • if 運算式會評估為選擇的任何分支的結果。同樣,switch 或其他多向分支會評估為選擇的任何情況。

  • 變數宣告會評估為變數的值。

  • 區塊會評估為序列中最後一個運算式的結果。

有些會變得有點奇怪。迴圈應該評估為什麼?CoffeeScript 中的 while 迴圈會評估為一個陣列,其中包含主體評估的每個元素。如果你不需要陣列,這可能很方便,也可能浪費記憶體。

你還必須決定這些類似陳述式的運算式如何與其他運算式組合你必須將它們放入語法的優先順序表中。例如,Ruby 允許

puts 1 + if true then 2 else 3 end + 4

這是否符合你的預期?它是否符合你的使用者的預期?這會如何影響你設計「陳述式」語法的方式?請注意,Ruby 有一個明確的 end 來告知 if 運算式何時完成。如果沒有它,+ 4 很可能會被解析為 else 子句的一部分。

將每個敘述都轉為表達式會迫使你回答一些棘手的問題,就像上面那樣。相對地,你也會消除一些冗餘。C 語言同時擁有區塊(block)來排序敘述,以及逗號運算子(comma operator)來排序表達式。它也有 if 敘述和 ?: 條件運算子。如果 C 語言中所有東西都是表達式,你就可以將它們統一起來。

捨棄敘述的語言通常也具有隱式回傳函式會自動回傳其程式碼區塊所評估出的值,而不需要明確的 return 語法。對於小型函式和方法來說,這非常方便。事實上,許多擁有敘述的語言都加入了像 => 這樣的語法,以便定義其程式碼區塊是單一表達式評估結果的函式。

但讓所有函式都以這種方式運作可能會有點奇怪。如果你不小心,你的函式即使僅打算產生副作用,也會洩漏回傳值。然而,實際上,這些語言的使用者並不覺得這是個問題。

對於 Lox 語言,我加入敘述是基於實際的考量。我為了熟悉度而選擇了類似 C 語言的語法,而且試圖將現有的 C 語言敘述語法解釋為表達式會很快變得非常奇怪。