類別
若一個人不徹底了解事物的本質,就無權去愛或恨它。偉大的愛源於對所愛之物深刻的理解,若你對它了解甚少,你只能愛一點點或根本不愛。
李奧納多·達文西
我們已經進行了十一章,而你機器上的直譯器幾乎是一個完整的腳本語言。它可以使用一些內建的資料結構,如列表和映射,並且它當然需要一個用於檔案 I/O、使用者輸入等的核心程式庫。但語言本身已經足夠了。我們擁有一個類似 BASIC、Tcl、Scheme(減去巨集)以及早期版本的 Python 和 Lua 的小型程序語言。
如果這是在 80 年代,我們會在這裡停下來。但今天,許多流行的語言都支援「物件導向程式設計」。將其添加到 Lox 中,將為使用者提供一套熟悉的工具來編寫更大的程式。即使你個人不喜歡物件導向程式設計,本章和下一章也會幫助你了解其他人如何設計和構建物件系統。
12.1物件導向程式設計與類別
物件導向程式設計有三種廣泛的途徑:類別、原型和 多重方法。類別最先出現,也是最流行的風格。隨著 JavaScript 的興起(以及在較小程度上 Lua 的興起),原型比以前更廣為人知。我將在稍後更多地談論它們。對於 Lox,我們採取了經典的方法。
由於你已經和我一起編寫了大約一千行 Java 程式碼,我假設你不需要詳細介紹物件導向。主要目標是將資料與作用於該資料的程式碼捆綁在一起。使用者透過宣告一個類別來做到這一點,該類別:
-
公開一個建構子,以建立和初始化該類別的新實例
-
提供一種在實例上儲存和存取欄位的方式
-
定義一組由該類別的所有實例共用的方法,這些方法作用於每個實例的狀態。
這就是它的最低限度。大多數物件導向語言,一直追溯到 Simula,也使用繼承來跨類別重複使用行為。我們將在下一章中新增該功能。即使把它排除在外,我們仍然有很多內容要處理。這是一個重要的章節,在我們擁有以上所有部分之前,一切都無法完全結合在一起,所以請集中精力。
12.2類別宣告
和我們一樣,我們將從語法開始。class
語句引入一個新名稱,因此它位於 declaration
語法規則中。
declaration → classDecl | funDecl | varDecl | statement ; classDecl → "class" IDENTIFIER "{" function* "}" ;
新的 classDecl
規則依賴於我們之前定義的 function
規則。為了喚起你的記憶
function → IDENTIFIER "(" parameters? ")" block ; parameters → IDENTIFIER ( "," IDENTIFIER )* ;
用簡單的英語來說,類別宣告是 class
關鍵字,後面跟著類別的名稱,然後是一個用大括號括起來的主體。該主體內部是一個方法宣告列表。與函式宣告不同,方法沒有前導的 fun
關鍵字。每個方法都是一個名稱、參數列表和主體。這是一個範例
class Breakfast { cook() { print "Eggs a-fryin'!"; } serve(who) { print "Enjoy your breakfast, " + who + "."; } }
像大多數動態型別語言一樣,欄位不會在類別宣告中明確列出。實例是鬆散的資料包,你可以使用正常的命令式程式碼隨意向它們新增欄位。
在我們的 AST 產生器中,classDecl
語法規則會獲得自己的語句 節點。
"Block : List<Stmt> statements",
在 main() 中
"Class : Token name, List<Stmt.Function> methods",
"Expression : Expr expression",
它儲存類別的名稱和其主體內的方法。方法由我們用於函式宣告 AST 節點的現有 Stmt.Function 類別表示。這為我們提供了方法所需的所有狀態位:名稱、參數列表和主體。
類別可以出現在允許命名宣告的任何位置,由前導的 class
關鍵字觸發。
try {
在 declaration() 中
if (match(CLASS)) return classDeclaration();
if (match(FUN)) return function("function");
這會呼叫到
在 declaration() 後新增
private Stmt classDeclaration() { Token name = consume(IDENTIFIER, "Expect class name."); consume(LEFT_BRACE, "Expect '{' before class body."); List<Stmt.Function> methods = new ArrayList<>(); while (!check(RIGHT_BRACE) && !isAtEnd()) { methods.add(function("method")); } consume(RIGHT_BRACE, "Expect '}' after class body."); return new Stmt.Class(name, methods); }
這個方法比大多數其他解析方法有更多內容,但它大致遵循語法。我們已經使用過 class
關鍵字,因此我們接下來尋找預期的類別名稱,然後是左大括號。進入主體後,我們將繼續解析方法宣告,直到遇到右大括號。每個方法宣告都透過呼叫 function()
來解析,我們在引入函式的章節中定義過該方法。
就像我們在解析器中的任何開放式迴圈中所做的那樣,我們也會檢查是否已到達檔案結尾。這在正確的程式碼中不會發生,因為類別的末尾應該有一個右大括號,但它可以確保如果使用者有語法錯誤並忘記正確結束類別主體,解析器不會陷入無限迴圈。
我們將名稱和方法列表包裝到一個 Stmt.Class 節點中,就完成了。以前,我們會直接跳到直譯器,但現在我們需要先將節點透過解析器導入。
在 visitBlockStmt() 後新增
@Override public Void visitClassStmt(Stmt.Class stmt) { declare(stmt.name); define(stmt.name); return null; }
我們目前不打算擔心解析方法本身,因此目前我們只需要使用其名稱來宣告類別。將類別宣告為本機變數並不常見,但 Lox 允許這樣做,因此我們需要正確處理它。
現在我們直譯類別宣告。
在 visitBlockStmt() 後新增
@Override public Void visitClassStmt(Stmt.Class stmt) { environment.define(stmt.name.lexeme, null); LoxClass klass = new LoxClass(stmt.name.lexeme); environment.assign(stmt.name, klass); return null; }
這看起來類似於我們執行函式宣告的方式。我們在目前的環境中宣告類別的名稱。然後,我們將類別語法節點轉換為 LoxClass,這是類別的執行階段表示。我們迴圈並將類別物件儲存在我們先前宣告的變數中。這個兩階段變數繫結過程允許在類別自身的方法中參考類別。
我們將在本章中對其進行改進,但 LoxClass 的第一個草稿如下所示
建立新檔案
package com.craftinginterpreters.lox; import java.util.List; import java.util.Map; class LoxClass { final String name; LoxClass(String name) { this.name = name; } @Override public String toString() { return name; } }
實際上是一個名稱的包裝函式。我們甚至還沒有儲存方法。不是很有用,但它確實有一個 toString()
方法,因此我們可以編寫一個簡單的腳本並測試類別物件是否真的正在被解析和執行。
class DevonshireCream { serveOn() { return "Scones"; } } print DevonshireCream; // Prints "DevonshireCream".
12.3建立實例
我們有類別,但它們還沒有任何作用。Lox 沒有可以直接在類別本身上呼叫的「靜態」方法,因此沒有實際的實例,類別就沒有用處。因此,實例是下一步。
雖然某些語法和語意在物件導向程式設計語言中相當標準,但你建立新實例的方式卻不是。Ruby 遵循 Smalltalk,透過在類別物件本身上呼叫方法來建立實例,這是一種遞迴式優雅的方法。有些語言(如 C++ 和 Java)有一個專門用於產生新物件的 new
關鍵字。Python 讓你可以像呼叫函式一樣「呼叫」類別本身。(JavaScript 則像以往一樣古怪,同時做到了兩者。)
我對 Lox 採取了最小化的方法。我們已經有類別物件,並且已經有函式呼叫,因此我們將使用類別物件上的呼叫表達式來建立新實例。這就好像一個類別是一個工廠函式,產生它自己的實例。對我來說,這感覺很優雅,而且也省去了我們引入 new
等語法的需要。因此,我們可以跳過前端,直接進入執行階段。
現在,如果你嘗試這個
class Bagel {} Bagel();
你會收到執行階段錯誤。visitCallExpr()
檢查呼叫的物件是否實作 LoxCallable
,並報告錯誤,因為 LoxClass 沒有實作。至少目前還沒有。
import java.util.Map;
取代 1 行
class LoxClass implements LoxCallable {
final String name;
實作該介面需要兩個方法。
在 toString() 後新增
@Override public Object call(Interpreter interpreter, List<Object> arguments) { LoxInstance instance = new LoxInstance(this); return instance; } @Override public int arity() { return 0; }
有趣的是 call()
方法。當你「呼叫」一個類別時,它會為被呼叫的類別實例化一個新的 LoxInstance 並返回它。 arity()
方法是解譯器驗證你是否傳遞了正確數量的參數給可呼叫物件的方式。目前,我們假設你不能傳遞任何參數。當我們處理使用者定義的建構子時,我們會重新檢視這一點。
這引導我們來到 LoxInstance,它是 Lox 類別實例的執行時表示。同樣地,我們的第一個實作從簡單開始。
建立新檔案
package com.craftinginterpreters.lox; import java.util.HashMap; import java.util.Map; class LoxInstance { private LoxClass klass; LoxInstance(LoxClass klass) { this.klass = klass; } @Override public String toString() { return klass.name + " instance"; } }
跟 LoxClass 一樣,它相當簡陋,但我們才剛開始。如果你想試試看,這裡有一個腳本可以執行
class Bagel {} var bagel = Bagel(); print bagel; // Prints "Bagel instance".
這個程式沒有做太多事情,但它開始做一些事情了。
12 . 4實例上的屬性
我們有了實例,所以我們應該讓它們變得有用。我們正處於一個分岔路口。我們可以先加入行為—方法—或者我們可以從狀態開始—屬性。我們將選擇後者,因為,正如我們將看到的,這兩者以一種有趣的方式糾纏在一起,如果我們先讓屬性運作,會更容易理解它們。
Lox 在處理狀態方面遵循 JavaScript 和 Python 的方式。每個實例都是一個開放的具名值集合。實例類別上的方法可以存取和修改屬性,但是 外部程式碼也可以。屬性使用 .
語法存取。
someObject.someProperty
一個運算式後面跟著 .
和一個識別符號,從運算式計算結果的物件讀取具有該名稱的屬性。該點與函式呼叫運算式中的括號具有相同的優先順序,因此我們通過替換現有的 call
規則將其放入文法中
call → primary ( "(" arguments? ")" | "." IDENTIFIER )* ;
在主要運算式之後,我們允許一系列任何混合的括號呼叫和點狀屬性存取。「屬性存取」有點拗口,所以從現在開始,我們將這些稱為「get 運算式」。
12 . 4 . 1Get 運算式
語法樹節點是
"Call : Expr callee, Token paren, List<Expr> arguments",
在 main() 中
"Get : Expr object, Token name",
"Grouping : Expr expression",
依照文法,新的剖析程式碼會進入我們現有的 call()
方法。
while (true) {
if (match(LEFT_PAREN)) {
expr = finishCall(expr);
在 call() 中
} else if (match(DOT)) { Token name = consume(IDENTIFIER, "Expect property name after '.'."); expr = new Expr.Get(expr, name);
} else { break; } }
那裡的外部 while
迴圈對應於文法規則中的 *
。當我們找到括號和點時,我們會沿著 token 建立一個呼叫和 get 的鏈,如下所示

新的 Expr.Get 節點的實例會傳遞到解析器。
在 visitCallExpr() 之後加入
@Override public Void visitGetExpr(Expr.Get expr) { resolve(expr.object); return null; }
好的,沒什麼特別的。由於屬性是 動態 查找的,因此它們不會被解析。在解析過程中,我們只會遞迴到點左邊的運算式。實際的屬性存取發生在解譯器中。
在 visitCallExpr() 之後加入
@Override public Object visitGetExpr(Expr.Get expr) { Object object = evaluate(expr.object); if (object instanceof LoxInstance) { return ((LoxInstance) object).get(expr.name); } throw new RuntimeError(expr.name, "Only instances have properties."); }
首先,我們評估正在存取其屬性的運算式。在 Lox 中,只有類別的實例才具有屬性。如果該物件是其他類型(例如數字),則在其上調用 getter 會導致執行階段錯誤。
如果物件是 LoxInstance,那麼我們要求它查找屬性。現在必須給 LoxInstance 一些實際的狀態了。一個 map 會很合適。
private LoxClass klass;
在類別 LoxInstance 中
private final Map<String, Object> fields = new HashMap<>();
LoxInstance(LoxClass klass) {
map 中的每個鍵都是屬性名稱,而對應的值是屬性的值。若要查找實例上的屬性
在 LoxInstance() 之後加入
Object get(Token name) { if (fields.containsKey(name.lexeme)) { return fields.get(name.lexeme); } throw new RuntimeError(name, "Undefined property '" + name.lexeme + "'."); }
我們需要處理的一個有趣的邊緣情況是,如果實例沒有具有給定名稱的屬性,會發生什麼情況。我們可以默默地返回一些虛擬值,例如 nil
,但是我使用 JavaScript 等語言的經驗是,這種行為掩蓋了錯誤,而不是做了任何有用的事情。相反,我們將其設為執行階段錯誤。
因此,我們做的第一件事是查看實例是否真的具有具有給定名稱的欄位。只有在這樣做之後,我們才返回它。否則,我們會引發錯誤。
請注意我如何從談論「屬性」轉向談論「欄位」。兩者之間存在細微的差異。欄位是直接儲存在實例中的具名狀態位元。屬性是具名的、嗯、東西,get 運算式可能會傳回。每個欄位都是一個屬性,但是正如我們稍後將看到的,並非每個屬性都是欄位。
理論上,我們現在可以讀取物件上的屬性。但是由於沒有辦法將任何狀態實際放入實例中,因此沒有要存取的欄位。在我們可以測試讀取之前,我們必須支援寫入。
12 . 4 . 2Set 運算式
Setters 使用與 getters 相同的語法,只是它們出現在指派的左側。
someObject.someProperty = value;
在文法方面,我們擴展了指派規則,以允許左側的點狀識別符號。
assignment → ( call "." )? IDENTIFIER "=" assignment | logic_or ;
與 getters 不同,setters 不會鏈接。但是,對 call
的參照允許最後一個點之前有任何高優先順序的運算式,包括任意數量的 getters,例如

請注意,這裡只有最後一部分,.meat
是 setter。.omelette
和 .filling
部分都是 get 運算式。
正如我們有兩個單獨的 AST 節點用於變數存取和變數指派一樣,我們需要一個第二個 setter 節點來補充我們的 getter 節點。
"Logical : Expr left, Token operator, Expr right",
在 main() 中
"Set : Expr object, Token name, Expr value",
"Unary : Token operator, Expr right",
如果你不記得的話,我們在剖析器中處理指派的方式有點奇怪。在我們到達 =
之前,我們無法輕易判斷一系列 token 是指派的左側。既然我們的指派文法規則在左側有 call
,它可以擴展到任意大的運算式,那麼最終的 =
可能離我們需要知道我們正在剖析指派的位置有許多 token 的距離。
相反,我們採取的技巧是將左側剖析為普通運算式。然後,當我們在它之後偶然發現等號時,我們會採用已經剖析的運算式,並將其轉換為指派的正確語法樹節點。
我們在該轉換中加入另一個子句,以處理將左側的 Expr.Get 運算式轉換為對應的 Expr.Set。
return new Expr.Assign(name, value);
在 assignment() 中
} else if (expr instanceof Expr.Get) { Expr.Get get = (Expr.Get)expr; return new Expr.Set(get.object, get.name, value);
}
這是在剖析我們的語法。我們將該節點推送至解析器中。
在 visitLogicalExpr() 之後加入
@Override public Void visitSetExpr(Expr.Set expr) { resolve(expr.value); resolve(expr.object); return null; }
同樣,與 Expr.Get 一樣,屬性本身是動態評估的,因此沒有任何東西需要解析。我們需要做的就是遞迴到 Expr.Set 的兩個子運算式中,即正在設定其屬性的物件,以及正在設定的值。
這引導我們來到解譯器。
在 visitLogicalExpr() 之後加入
@Override public Object visitSetExpr(Expr.Set expr) { Object object = evaluate(expr.object); if (!(object instanceof LoxInstance)) { throw new RuntimeError(expr.name, "Only instances have fields."); } Object value = evaluate(expr.value); ((LoxInstance)object).set(expr.name, value); return value; }
我們評估正在設定其屬性的物件,並檢查它是否為 LoxInstance。如果不是,那就是執行階段錯誤。否則,我們評估要設定的值並將其儲存在實例上。這依賴於 LoxInstance 中的一個新方法。
在 get() 之後加入
void set(Token name, Object value) { fields.put(name.lexeme, value); }
這裡沒有真正的魔法。我們將這些值直接放入欄位所在的 Java map 中。由於 Lox 允許在實例上自由建立新欄位,因此無需查看金鑰是否已存在。
12 . 5類別上的方法
你可以建立類別的實例並將資料放入其中,但是類別本身並不是真的做任何事情。實例只是 map,所有實例或多或少都相同。為了讓它們感覺像是類別的實例,我們需要行為—方法。
我們有用的剖析器已經剖析了方法宣告,因此我們在這方面沒問題。我們也不需要為方法呼叫新增任何新的剖析器支援。我們已經有 .
(getters)和 ()
(函式呼叫)。「方法呼叫」只是將這些鏈接在一起。

這提出了一個有趣的問題。當這兩個運算式被分開時會發生什麼?假設此範例中的 method
是 object
類別上的方法,而不是實例上的欄位,以下程式碼應該做什麼?
var m = object.method; m(argument);
這個程式會「查詢」方法並將結果—無論是什麼—儲存在變數中,然後稍後再呼叫該物件。這樣可以嗎?您可以像對待實例上的函式一樣對待方法嗎?
反過來呢?
class Box {} fun notMethod(argument) { print "called function with " + argument; } var box = Box(); box.function = notMethod; box.function("argument");
這個程式建立一個實例,然後將函式儲存在其欄位中。接著,它使用與方法呼叫相同的語法來呼叫該函式。這樣行得通嗎?
不同的語言對這些問題有不同的答案。可以寫一篇關於此的論文。對於 Lox,我們說這兩個問題的答案都是肯定的,它確實可以運作。我們有幾個理由來證明這一點。對於第二個範例—呼叫儲存在欄位中的函式—我們希望支援它,因為一級函式很有用,並且將它們儲存在欄位中是很正常的事情。
第一個範例比較模糊。一個動機是,使用者通常希望能夠將子表達式提升到局部變數中,而不會改變程式的含義。您可以採用這個
breakfast(omelette.filledWith(cheese), sausage);
並將其轉換為這個
var eggs = omelette.filledWith(cheese); breakfast(eggs, sausage);
並且它們執行相同的操作。同樣地,由於方法呼叫中的 .
和 ()
*是*兩個單獨的表達式,看起來您應該能夠將*查詢*部分提升到變數中,然後稍後呼叫它。我們需要仔細思考當您查詢方法時獲得的*東西*是什麼,以及它的行為方式,即使在像這樣的奇怪情況下也是如此
class Person { sayName() { print this.name; } } var jane = Person(); jane.name = "Jane"; var method = jane.sayName; method(); // ?
如果您取得某個實例上的方法控制代碼並稍後呼叫它,它是否會「記住」它是從哪個實例提取的?方法中的 this
是否仍然指向原始物件?
這裡有一個更病態的範例來鍛鍊您的腦力
class Person { sayName() { print this.name; } } var jane = Person(); jane.name = "Jane"; var bill = Person(); bill.name = "Bill"; bill.sayName = jane.sayName; bill.sayName(); // ?
最後一行會印出「Bill」,因為那是我們透過其*呼叫*方法的實例,還是會印出「Jane」,因為那是我們首次取得方法的實例?
Lua 和 JavaScript 中的等效程式碼會印出「Bill」。這些語言並沒有真正「方法」的概念。一切都像是欄位中的函式,因此不清楚 jane
是否比 bill
更「擁有」sayName
。
但是,Lox 具有真正的類別語法,因此我們知道哪些可呼叫的東西是方法,哪些是函式。因此,像 Python、C# 和其他語言一樣,我們將讓方法在首次取得時將 this
「綁定」到原始實例。Python 將這些稱為**綁定方法**。
在實務上,這通常是您想要的。如果您取得對某個物件上方法的參考,以便稍後將其用作回呼,您會想要記住它所屬的實例,即使該回呼碰巧儲存在其他物件上的欄位中。
好的,這有很多語義需要載入您的腦海。暫時忘記邊緣情況。我們稍後會回到這些問題。現在,讓我們讓基本方法呼叫運作。我們已經在類別主體內剖析方法宣告,因此下一步是解析它們。
define(stmt.name);
在 visitClassStmt() 中
for (Stmt.Function method : stmt.methods) { FunctionType declaration = FunctionType.METHOD; resolveFunction(method, declaration); }
return null;
我們迭代類別主體中的方法,並呼叫我們已經為處理函式宣告而編寫的 resolveFunction()
方法。唯一的區別是,我們傳遞新的 FunctionType 列舉值。
NONE,
FUNCTION,
在列舉 FunctionType 中
將 “,” 新增至上一行
METHOD
}
當我們解析 this
表達式時,這將很重要。現在,不要擔心它。有趣的東西在解譯器中。
environment.define(stmt.name.lexeme, null);
在 visitClassStmt() 中
取代 1 行
Map<String, LoxFunction> methods = new HashMap<>(); for (Stmt.Function method : stmt.methods) { LoxFunction function = new LoxFunction(method, environment); methods.put(method.name.lexeme, function); } LoxClass klass = new LoxClass(stmt.name.lexeme, methods);
environment.assign(stmt.name, klass);
當我們解譯類別宣告語句時,我們會將類別的語法表示形式—其 AST 節點—轉換為其執行階段表示形式。現在,我們也需要對類別中包含的方法執行此操作。每個方法宣告都會產生一個 LoxFunction 物件。
我們將所有這些方法包裝到一個地圖中,並以方法名稱作為索引鍵。這會儲存在 LoxClass 中。
final String name;
在類別 LoxClass 中
取代 4 行
private final Map<String, LoxFunction> methods; LoxClass(String name, Map<String, LoxFunction> methods) { this.name = name; this.methods = methods; }
@Override public String toString() {
實例儲存狀態,而類別儲存行為。LoxInstance 有其欄位地圖,而 LoxClass 取得方法地圖。即使方法由類別擁有,它們仍然是透過該類別的實例來存取的。
Object get(Token name) { if (fields.containsKey(name.lexeme)) { return fields.get(name.lexeme); }
在 get() 中
LoxFunction method = klass.findMethod(name.lexeme); if (method != null) return method;
throw new RuntimeError(name,
"Undefined property '" + name.lexeme + "'.");
當在實例上查詢屬性時,如果我們沒有找到符合的欄位,我們會在其類別的實例上尋找具有該名稱的方法。如果找到,我們會傳回該方法。這就是「欄位」和「屬性」之間的區別變得有意義的地方。當存取屬性時,您可能會取得欄位—儲存在實例上的少量狀態—或者您可能會觸發在實例類別上定義的方法。
使用這個來查詢方法
在 LoxClass() 之後新增
LoxFunction findMethod(String name) { if (methods.containsKey(name)) { return methods.get(name); } return null; }
您可能可以猜到這個方法稍後會變得更有趣。現在,在類別的方法表格上進行簡單的地圖查詢就足以讓我們開始。試試看
class Bacon { eat() { print "Crunch crunch crunch!"; } } Bacon().eat(); // Prints "Crunch crunch crunch!".
12 . 6這個
我們可以在物件上定義行為和狀態,但它們尚未連結在一起。在方法內部,我們無法存取「目前」物件的欄位—呼叫該方法的實例—我們也無法在同一物件上呼叫其他方法。
為了取得該實例,它需要一個名稱。Smalltalk、Ruby 和 Swift 使用「self」。Simula、C++、Java 和其他語言使用「this」。Python 按照慣例使用「self」,但您可以在技術上隨意呼叫它。
對於 Lox,由於我們通常遵循類似 Java 的風格,我們將使用「this」。在方法主體內部,this
表達式會評估為呼叫該方法的實例。或者,更具體地說,由於方法是以兩個步驟存取然後調用的,因此它將參照從中*存取*方法物件。
這讓我們的任務變得更加困難。看看
class Egotist { speak() { print this; } } var method = Egotist().speak; method();
在倒數第二行,我們從類別的實例中取得對 speak()
方法的參考。這會傳回一個函式,而且該函式需要記住它所提取的實例,以便*稍後*,在最後一行,它在呼叫該函式時仍然可以找到它。
我們需要在存取方法時取得 this
,並將其附加到函式,以便它在我們需要時保持存在。嗯 . . . 一種儲存一些額外資料的方法,這些資料會保留在函式周圍,是嗎?這聽起來很像*閉包*,不是嗎?
如果我們將 this
定義為環境中某種隱藏的變數,該環境會包圍查詢方法時傳回的函式,那麼主體中使用 this
將能夠稍後找到它。LoxFunction 已經有能力保留周圍的環境,因此我們擁有所需的機制。
讓我們逐步瀏覽一個範例,看看它是如何運作的
class Cake { taste() { var adjective = "delicious"; print "The " + this.flavor + " cake is " + adjective + "!"; } } var cake = Cake(); cake.flavor = "German chocolate"; cake.taste(); // Prints "The German chocolate cake is delicious!".
當我們首次評估類別定義時,我們會為 taste()
建立 LoxFunction。它的閉包是類別周圍的環境,在此情況下為全域環境。因此,我們儲存在類別方法地圖中的 LoxFunction 如下所示

當我們評估 cake.taste
get 表達式時,我們會建立一個新環境,將 this
繫結到從中存取方法的物件 (此處為 cake
)。然後,我們建立一個*新*的 LoxFunction,其程式碼與原始程式碼相同,但使用該新環境作為其閉包。

這是當評估方法名稱的 get 表達式時傳回的 LoxFunction。當該函式稍後被 ()
表達式呼叫時,我們會像往常一樣為方法主體建立一個環境。

主體環境的父系是我們先前建立的環境,目的是將 this
繫結到目前的物件。因此,主體中任何使用 this
的地方都會成功解析為該實例。
將我們的環境程式碼重複用於實作 this
也會處理方法和函式互動的有趣情況,例如
class Thing { getCallback() { fun localFunction() { print this; } return localFunction; } } var callback = Thing().getCallback(); callback();
在 JavaScript 中,通常會從方法內部傳回回呼。該回呼可能想要保留並保留對原始物件的存取權—與該方法關聯的 this
值—。我們現有的對閉包和環境鏈的支援應能正確地完成這一切。
讓我們來編寫程式碼。第一步是為 this
新增新語法。
"Set : Expr object, Token name, Expr value",
在 main() 中
"This : Token keyword",
"Unary : Token operator, Expr right",
剖析很簡單,因為它是我們的詞法分析器已經識別為保留字的單一符號。
return new Expr.Literal(previous().literal); }
在 primary() 中
if (match(THIS)) return new Expr.This(previous());
if (match(IDENTIFIER)) {
當我們開始使用解析器時,您就可以開始了解 this
如何像變數一樣運作。
在 visitSetExpr() 之後新增
@Override public Void visitThisExpr(Expr.This expr) { resolveLocal(expr, expr.keyword); return null; }
我們使用「this」作為「變數」的名稱,像任何其他局部變數一樣解析它。當然,這現在行不通,因為任何範圍中都*未*宣告「this」。讓我們在 visitClassStmt()
中修正此問題。
define(stmt.name);
在 visitClassStmt() 中
beginScope(); scopes.peek().put("this", true);
for (Stmt.Function method : stmt.methods) {
在我們開始解析方法主體之前,我們會推送一個新範圍,並在其中將「this」定義為變數。然後,當我們完成時,我們會捨棄該周圍的範圍。
}
在 visitClassStmt() 中
endScope();
return null;
現在,每當遇到 this
表達式(至少在方法內部)時,它會解析為在方法主體區塊外部的隱式範圍中定義的「區域變數」。
解析器對於 this
有一個新的作用域,因此直譯器需要為它創建一個相對應的環境。請記住,我們必須始終保持解析器的作用域鏈和直譯器的連結環境同步。在執行階段,我們在實例上找到方法後才創建環境。我們將先前簡單返回方法 LoxFunction 的程式碼行替換為以下程式碼:
LoxFunction method = klass.findMethod(name.lexeme);
在 get() 中
取代 1 行
if (method != null) return method.bind(this);
throw new RuntimeError(name,
"Undefined property '" + name.lexeme + "'.");
注意新的 bind()
呼叫。它看起來像這樣:
在 LoxFunction() 之後新增
LoxFunction bind(LoxInstance instance) { Environment environment = new Environment(closure); environment.define("this", instance); return new LoxFunction(declaration, environment); }
程式碼不多。我們在方法原始的閉包內建立一個新的環境,類似於閉包中的閉包。當方法被呼叫時,它將成為方法主體環境的父環境。
我們在該環境中將「this」宣告為一個變數,並將其繫結到給定的實例,也就是方法正在被存取的實例。瞧,返回的 LoxFunction 現在攜帶它自己的小型的持久世界,其中「this」繫結到該物件。
剩餘的工作是解釋那些 this
表達式。與解析器類似,它與解釋變數表達式相同。
在 visitSetExpr() 之後新增
@Override public Object visitThisExpr(Expr.This expr) { return lookUpVariable(expr.keyword, expr); }
請嘗試使用先前的蛋糕範例。僅用不到二十行的程式碼,我們的直譯器就能處理方法內部的 this
,即使在它與巢狀類別、方法內部的函式、方法處理等所有奇怪的互動方式中也是如此。
12 . 6 . 1this
的無效使用
等一下。如果您嘗試在方法外部使用 this
會發生什麼?例如:
print this;
或是:
fun notAMethod() { print this; }
如果您不在方法中,則沒有 this
可以指向的實例。我們可以給它一些預設值,例如 nil
,或使其成為執行階段錯誤,但使用者顯然犯了錯誤。他們越早發現並修復該錯誤,他們就會越開心。
我們的解析過程是靜態偵測此錯誤的好地方。它已經偵測到函式外部的 return
語句。我們將對 this
執行類似的操作。根據我們現有的 FunctionType 列舉,我們定義一個新的 ClassType 列舉。
}
在列舉 FunctionType 之後新增
private enum ClassType { NONE, CLASS } private ClassType currentClass = ClassType.NONE;
void resolve(List<Stmt> statements) {
是的,它可以是布林值。當我們接觸到繼承時,它將獲得第三個值,因此現在使用列舉。我們還新增一個相應的欄位 currentClass
。它的值會告訴我們,在遍歷語法樹時,目前是否在類別宣告內。它從 NONE
開始,這表示我們不在類別內。
當我們開始解析類別宣告時,我們會變更它。
public Void visitClassStmt(Stmt.Class stmt) {
在 visitClassStmt() 中
ClassType enclosingClass = currentClass; currentClass = ClassType.CLASS;
declare(stmt.name);
與 currentFunction
一樣,我們將欄位的先前值儲存在區域變數中。這讓我們可以依靠 JVM 來保留 currentClass
值的堆疊。這樣,如果一個類別巢狀在另一個類別內部,我們就不會遺失先前的值。
方法解析完成後,我們透過還原舊值來「彈出」該堆疊。
endScope();
在 visitClassStmt() 中
currentClass = enclosingClass;
return null;
當我們解析 this
表達式時,如果該表達式沒有巢狀在方法主體內,則 currentClass
欄位會提供我們回報錯誤所需的資料。
public Void visitThisExpr(Expr.This expr) {
在 visitThisExpr() 中
if (currentClass == ClassType.NONE) { Lox.error(expr.keyword, "Can't use 'this' outside of a class."); return null; }
resolveLocal(expr, expr.keyword);
這應該可以幫助使用者正確使用 this
,並且避免我們必須在直譯器中於執行階段處理誤用。
12 . 7建構函式和初始化器
我們現在幾乎可以使用類別完成所有操作,而且隨著我們接近本章的結尾,我們發現自己奇怪地關注於開始。方法和欄位讓我們將狀態和行為封裝在一起,以便物件始終保持在有效的配置中。但是,我們如何確保一個全新的物件開始時處於良好的狀態?
為此,我們需要建構函式。我認為它們是語言設計中最棘手的部分之一,而且如果您仔細查看大多數其他語言,您會看到物件建構周圍存在裂縫,其中設計的接縫並未完全吻合。也許在誕生的那一刻,有些東西本質上是混亂的。
「建構」物件實際上是一對操作
-
執行階段會配置一個新實例所需的記憶體。在大多數語言中,此操作處於使用者程式碼無法存取的基礎層級。
-
然後,會呼叫使用者提供的程式碼區塊,該區塊會初始化未成形的物件。
後者是我們在聽到「建構函式」時傾向於想到的,但是語言本身通常在我們到達該點之前為我們做了一些基礎工作。事實上,我們的 Lox 直譯器在建立新的 LoxInstance 物件時已經涵蓋了這一點。
我們現在將完成其餘部分—使用者定義的初始化—。語言對於設定類別新物件的程式碼區塊有多種表示法。C++、Java 和 C# 使用名稱與類別名稱相符的方法。Ruby 和 Python 將其稱為 init()
。後者很簡短,因此我們將使用它。
在 LoxClass 的 LoxCallable 實作中,我們新增了更多行。
List<Object> arguments) { LoxInstance instance = new LoxInstance(this);
在 call() 中
LoxFunction initializer = findMethod("init"); if (initializer != null) { initializer.bind(instance).call(interpreter, arguments); }
return instance;
當類別被呼叫時,在建立 LoxInstance 之後,我們會尋找「init」方法。如果我們找到一個,我們會立即繫結並呼叫它,就像一般的函式呼叫一樣。引數清單會向前傳遞。
該引數清單表示我們還需要調整類別宣告其數目的方式。
public int arity() {
在 arity() 中
取代 1 行
LoxFunction initializer = findMethod("init"); if (initializer == null) return 0; return initializer.arity();
}
如果存在初始化器,則該方法的數目決定您在呼叫類別本身時必須傳遞多少個引數。但為了方便起見,我們不要求類別定義初始化器。如果您沒有初始化器,則數目仍然為零。
基本上就是這樣。由於我們在呼叫 init()
方法之前繫結它,因此它可以在其主體內存取 this
。這以及傳遞給類別的引數,都是您能夠以您想要的任何方式設定新實例所需要的一切。
12 . 7 . 1直接呼叫 init()
與往常一樣,探索這個新的語義領域會產生一些奇怪的生物。考慮一下:
class Foo { init() { print this; } } var foo = Foo(); print foo.init();
您可以透過直接呼叫物件的 init()
方法來「重新初始化」物件嗎?如果您這樣做,它會傳回什麼?一個合理的答案將是 nil
,因為它看起來像是主體傳回的值。
但是—而且我通常不喜歡為了滿足實作而妥協—如果我們說 init()
方法始終傳回 this
,即使是直接呼叫時,也會讓 clox 的建構函式實作變得容易得多。為了讓 jlox 與其相容,我們在 LoxFunction 中新增了一些特殊案例程式碼。
return returnValue.value; }
在 call() 中
if (isInitializer) return closure.getAt(0, "this");
return null;
如果函式是初始化器,我們將覆寫實際的傳回值並強制傳回 this
。這依賴於新的 isInitializer
欄位。
private final Environment closure;
在類別 LoxFunction 中
取代 1 行
private final boolean isInitializer; LoxFunction(Stmt.Function declaration, Environment closure, boolean isInitializer) { this.isInitializer = isInitializer;
this.closure = closure; this.declaration = declaration;
我們不能簡單地查看 LoxFunction 的名稱是否為「init」,因為使用者可能已定義一個名稱為該名稱的函式。在這種情況下,沒有要傳回的 this
。為了避免這種奇怪的邊緣案例,我們將直接儲存 LoxFunction 是否代表初始化器方法。這表示我們需要返回並修復我們建立 LoxFunction 的幾個位置。
public Void visitFunctionStmt(Stmt.Function stmt) {
在 visitFunctionStmt() 中
取代 1 行
LoxFunction function = new LoxFunction(stmt, environment, false);
environment.define(stmt.name.lexeme, function);
對於實際的函式宣告,isInitializer
始終為 false。對於方法,我們檢查名稱。
for (Stmt.Function method : stmt.methods) {
在 visitClassStmt() 中
取代 1 行
LoxFunction function = new LoxFunction(method, environment, method.name.lexeme.equals("init"));
methods.put(method.name.lexeme, function);
然後在 bind()
中,我們建立將 this
繫結到方法的閉包,我們傳遞原始方法的值。
environment.define("this", instance);
在 bind() 中
取代 1 行
return new LoxFunction(declaration, environment, isInitializer);
}
12 . 7 . 2從 init() 傳回
我們尚未擺脫困境。我們一直假設使用者編寫的初始化器不會明確傳回值,因為大多數建構函式都不會。如果使用者嘗試執行以下操作,應該會發生什麼?
class Foo { init() { return "something else"; } }
它絕對不會按照他們想要的方式運作,所以我們不妨讓它成為靜態錯誤。回到解析器中,我們向 FunctionType 新增另一個案例。
FUNCTION,
在列舉 FunctionType 中
INITIALIZER,
METHOD
我們使用已訪問方法的名稱來判斷我們是否正在解析初始化器。
FunctionType declaration = FunctionType.METHOD;
在 visitClassStmt() 中
if (method.name.lexeme.equals("init")) { declaration = FunctionType.INITIALIZER; }
resolveFunction(method, declaration);
當我們稍後遍歷到 return
語句時,我們會檢查該欄位,並使其成為從 init()
方法內部傳回值的錯誤。
if (stmt.value != null) {
在 visitReturnStmt() 中
if (currentFunction == FunctionType.INITIALIZER) { Lox.error(stmt.keyword, "Can't return a value from an initializer."); }
resolve(stmt.value);
我們仍然沒有完成。我們靜態地不允許從初始化器傳回值,但是您仍然可以使用空的早期 return
。
class Foo { init() { return; } }
這有時實際上很有用,因此我們不想完全禁止它。相反,它應該傳回 this
而不是 nil
。這是 LoxFunction 中一個簡單的修復。
} catch (Return returnValue) {
在 call() 中
if (isInitializer) return closure.getAt(0, "this");
return returnValue.value;
如果我們在初始化器中並且執行 return
語句,則我們再次傳回 this
,而不是傳回值(該值將始終為 nil
)。
呼!那是一整串的任務,但我們的獎勵是我們的小直譯器已經成長為一個完整的程式設計範例。類別、方法、欄位、this
和建構函式。我們的小語言看起來非常成熟了。
挑戰
-
我們在實例上有方法,但是沒有辦法定義可以直接在類別物件本身上呼叫的「靜態」方法。新增對它們的支援。在方法前面使用
class
關鍵字來指示掛在類別物件上的靜態方法。class Math { class square(n) { return n * n; } } print Math.square(3); // Prints "9".
您可以隨心所欲地解決此問題,但是 Smalltalk 和 Ruby 使用的「元類別」是一種特別優雅的方法。提示:讓 LoxClass 擴充 LoxInstance,然後從那裡開始。
-
大多數現代程式語言都支援「getter」和「setter」—類別中的成員,它們看起來像欄位讀取和寫入,但實際上會執行使用者定義的程式碼。擴展 Lox 以支援 getter 方法。這些方法的宣告不帶參數列表。當存取具有該名稱的屬性時,getter 的主體會被執行。
class Circle { init(radius) { this.radius = radius; } area { return 3.141592653 * this.radius * this.radius; } } var circle = Circle(4); print circle.area; // Prints roughly "50.2655".
-
Python 和 JavaScript 允許你從物件自身的方法之外自由存取物件的欄位。Ruby 和 Smalltalk 則封裝了實例狀態。只有類別的方法才能存取原始欄位,並且由類別決定要公開哪些狀態。大多數靜態類型語言都提供類似
private
和public
的修飾符,以控制類別的哪些部分可從外部以每個成員為基礎進行存取。這些方法之間有什麼權衡取捨?為什麼一種程式語言會偏好其中一種方法?
設計筆記:原型和威力
在本章中,我們介紹了兩個新的執行時實體:LoxClass 和 LoxInstance。前者是物件的行為所在,後者是物件的狀態所在。如果可以在單個物件(LoxInstance 內部)直接定義方法會怎麼樣?在這種情況下,我們根本不需要 LoxClass。LoxInstance 將會是一個完整的一攬子方案,用於定義物件的行為和狀態。
我們仍然希望在沒有類別的情況下,以某種方式在多個實例之間重用行為。我們可以讓 LoxInstance 委派 直接到另一個 LoxInstance 以重用其欄位和方法,有點像繼承。
使用者將會把他們的程式建模為一系列物件,其中一些物件會互相委派以反映共同點。用作委派的物件代表了其他物件會加以完善的「規範」或「原型」物件。結果是一個更簡單的執行時,只有一個內部結構:LoxInstance。
這就是這個典範的名稱 原型 的由來。它是由 David Ungar 和 Randall Smith 在一種名為 Self 的語言中發明的。他們從 Smalltalk 開始,遵循上述心智練習,看看他們可以把它簡化多少。
在很長一段時間裡,原型只是一種學術上的好奇,一個引人入勝的好奇,它產生了有趣的研究,但並沒有在更廣大的程式設計世界中產生影響。直到 Brendan Eich 將原型塞進 JavaScript 中,然後 JavaScript 迅速接管了世界。關於 JavaScript 中的原型,已經寫了很多(很多)文字。這是否表明原型很出色或令人困惑—或是兩者兼具!—這是一個懸而未決的問題。
我不會深入探討我是否認為原型是一種適合程式語言的好主意。我開發過 原型式 和 基於類別 的語言,我對這兩者的看法都很複雜。我想討論的是簡潔性在程式語言中的作用。
原型比類別更簡單—語言實作者需要編寫的程式碼更少,使用者需要學習和理解的概念也更少。這是否意味著它們更好?我們這些程式語言的愛好者往往會對極簡主義抱有迷戀。我個人認為,簡潔性只是方程式的一部分。我們真正想給使用者的是威力,我將其定義為
power = breadth × ease ÷ complexity
這些都不是精確的數值度量。我這裡使用數學作為類比,而不是實際的量化。
-
廣度是程式語言允許你表達的不同事物的範圍。C 語言具有很大的廣度—它已被用於從作業系統到使用者應用程式再到遊戲的各種領域。像 AppleScript 和 Matlab 這樣的特定領域語言的廣度較小。
-
易用性是指讓程式語言完成你想要的事情所需付出的努力程度。「可用性」可能是另一個術語,但它帶有的包袱比我想要引入的要多。「高階」程式語言往往比「低階」程式語言具有更高的易用性。大多數程式語言都有一個「粒度」,其中某些事情比其他事情更容易表達。
-
複雜性是指程式語言(包括其執行時、核心函式庫、工具、生態系統等)的大小。人們談論程式語言的規範有多少頁,或者有多少關鍵字。這是使用者在系統中有效率地工作之前必須載入到他們的大腦中的東西。它是簡潔性的反義詞。
降低複雜性確實會增加威力。分母越小,結果值越大,因此我們認為簡潔性是好的直覺是有效的。但是,在降低複雜性的時候,我們必須注意不要在此過程中犧牲廣度或易用性,否則總體威力可能會下降。如果 Java 刪除字串,它將會是一個嚴格來說更簡單的程式語言,但它可能無法很好地處理文字操作任務,也不會像現在這麼容易完成任務。
因此,藝術在於找到可以省略的偶然複雜性—那些未能通過增加程式語言的廣度或易用性來發揮其作用的語言特性和互動。
如果使用者想以物件類別的形式表達他們的程式,那麼將類別整合到程式語言中會增加這樣做的易用性,希望增加的幅度足以彌補增加的複雜性。但如果這不是使用者使用你的程式語言的方式,那麼請務必省略類別。