繼承
我們曾經是海中的團塊,然後是魚,然後是蜥蜴和老鼠,然後是猴子,以及介於其中的數百種生物。這隻手曾經是鰭,這隻手曾經有爪子!在我的嘴裡,我有人狼的尖牙、兔子的鑿牙和牛的磨牙!我們的血液和我們曾經居住的海洋一樣鹹!當我們害怕時,我們皮膚上的毛髮會豎起來,就像我們有毛皮時一樣。我們就是歷史!我們曾經成為現在的我們所經歷的一切,我們仍然是。
泰瑞·普萊契,《戴滿天空的帽子》
你相信嗎?我們已經來到第二部分的最後一章。我們的第一個 Lox 直譯器快要完成了。前一章是一大團交織在一起的物件導向特性。我無法將它們彼此分離,但我確實設法理清了一部分。在本章中,我們將透過新增繼承來完成 Lox 的類別支援。
繼承出現在物件導向語言中,可以追溯到第一個語言,Simula。早期,Kristen Nygaard 和 Ole-Johan Dahl 注意到他們編寫的模擬程式中的類別之間存在共同點。繼承讓他們能夠重複使用這些相似部分的程式碼。
13.1超類別與子類別
鑑於這個概念是「繼承」,你會希望他們會選擇一個一致的隱喻,並稱它們為「父」類別和「子」類別,但那太容易了。很久以前,C. A. R. Hoare 創造了術語「子類別」來指稱改進另一種類型的記錄類型。Simula 借用了這個術語來指稱繼承自另一個的類別。我認為直到 Smalltalk 出現,才有人翻轉拉丁字首,得到「超類別」來指稱關係的另一方。從 C++ 中,你也會聽到「基底」類別和「衍生」類別。我主要會堅持使用「超類別」和「子類別」。
我們在 Lox 中支援繼承的第一步是在宣告類別時指定一個超類別。這方面的語法有很多種。C++ 和 C# 在子類別的名稱後面放置一個 :
,後面跟著超類別的名稱。Java 使用 extends
而不是冒號。Python 將超類別放在類別名稱後面的括號中。Simula 將超類別的名稱放在 class
關鍵字之前。
到了這個階段,我寧願不在詞法分析器中新增新的保留字或權杖。我們沒有 extends
,甚至沒有 :
,因此我們將遵循 Ruby 並使用小於號 (<
)。
class Doughnut { // General doughnut stuff... } class BostonCream < Doughnut { // Boston Cream-specific stuff... }
為了將此納入語法中,我們在現有的 classDecl
規則中新增一個新的可選子句。
classDecl → "class" IDENTIFIER ( "<" IDENTIFIER )? "{" function* "}" ;
在類別名稱之後,你可以有一個 <
,後面跟著超類別的名稱。超類別子句是可選的,因為你不需要有超類別。與 Java 等其他一些物件導向語言不同,Lox 沒有任何事物繼承的根「Object」類別,因此當你省略超類別子句時,該類別沒有任何超類別,甚至沒有隱含的超類別。
我們希望在類別宣告的 AST 節點中捕獲這個新的語法。
"Block : List<Stmt> statements",
在 main() 中
取代 1 行
"Class : Token name, Expr.Variable superclass," + " List<Stmt.Function> methods",
"Expression : Expr expression",
你可能會驚訝,我們將超類別名稱儲存為 Expr.Variable,而不是權杖。語法將超類別子句限制為單個識別符號,但在執行時,該識別符號會被評估為變數存取。在解析器中早期將名稱包裝在 Expr.Variable 中,可為我們提供一個解析器可以附加解析資訊的物件。
新的解析器程式碼直接遵循語法。
Token name = consume(IDENTIFIER, "Expect class name.");
在 classDeclaration() 中
Expr.Variable superclass = null; if (match(LESS)) { consume(IDENTIFIER, "Expect superclass name."); superclass = new Expr.Variable(previous()); }
consume(LEFT_BRACE, "Expect '{' before class body.");
一旦我們(可能)解析了超類別宣告,我們會將其儲存在 AST 中。
consume(RIGHT_BRACE, "Expect '}' after class body.");
在 classDeclaration() 中
取代 1 行
return new Stmt.Class(name, superclass, methods);
}
如果我們沒有解析超類別子句,超類別運算式將為 null
。我們必須確保後續的傳遞會檢查這一點。第一個傳遞是解析器。
define(stmt.name);
在 visitClassStmt() 中
if (stmt.superclass != null) { resolve(stmt.superclass); }
beginScope();
類別宣告 AST 節點有一個新的子運算式,因此我們遍歷並解析該子運算式。由於類別通常在最上層宣告,因此超類別名稱很可能是一個全域變數,因此這通常不會執行任何有用的操作。但是,Lox 允許在區塊內宣告類別,因此超類別名稱可能會參照本機變數。在這種情況下,我們需要確保它已解析。
因為即使是善意的程式設計師有時也會編寫奇怪的程式碼,所以我們在這裡時需要擔心一個愚蠢的邊緣情況。看看這個
class Oops < Oops {}
這不可能做任何有用的事情,如果我們讓執行時嘗試執行此操作,它會破壞直譯器對繼承鏈中沒有迴圈的期望。最安全的事情是靜態偵測這種情況並將其報告為錯誤。
define(stmt.name);
在 visitClassStmt() 中
if (stmt.superclass != null && stmt.name.lexeme.equals(stmt.superclass.name.lexeme)) { Lox.error(stmt.superclass.name, "A class can't inherit from itself."); }
if (stmt.superclass != null) {
假設程式碼解析時沒有錯誤,AST 會傳遞到直譯器。
public Void visitClassStmt(Stmt.Class stmt) {
在 visitClassStmt() 中
Object superclass = null; if (stmt.superclass != null) { superclass = evaluate(stmt.superclass); if (!(superclass instanceof LoxClass)) { throw new RuntimeError(stmt.superclass.name, "Superclass must be a class."); } }
environment.define(stmt.name.lexeme, null);
如果類別具有超類別運算式,我們會評估它。由於這可能會評估為其他類型的物件,因此我們必須在執行時檢查我們希望作為超類別的內容實際上是一個類別。如果我們允許類似以下的程式碼,就會發生不好的事情
var NotAClass = "I am totally not a class"; class Subclass < NotAClass {} // ?!
假設檢查通過,我們會繼續。執行類別宣告會將類別的語法表示—其 AST 節點—轉變為其執行時表示,即 LoxClass 物件。我們也需要將超類別傳遞到該物件。我們將超類別傳遞給建構函式。
methods.put(method.name.lexeme, function); }
在 visitClassStmt() 中
取代 1 行
LoxClass klass = new LoxClass(stmt.name.lexeme, (LoxClass)superclass, methods);
environment.assign(stmt.name, klass);
建構函式將其儲存在一個欄位中。
建構函式 LoxClass()
取代 1 行
LoxClass(String name, LoxClass superclass, Map<String, LoxFunction> methods) { this.superclass = superclass;
this.name = name;
我們在這裡宣告它
final String name;
在類別 LoxClass 中
final LoxClass superclass;
private final Map<String, LoxFunction> methods;
有了這個,我們就可以定義其他類別的子類別。現在,擁有超類別實際上會執行什麼?
13.2繼承方法
從另一個類別繼承意味著超類別的一切成立或多或少也應該適用於子類別。在靜態型別語言中,這意味著很多含義。子類別也必須是子類型,並且控制記憶體配置,以便你可以將子類別的實例傳遞給期望超類別的函式,並且它仍然可以正確存取繼承的欄位。
Lox 是一種動態型別語言,因此我們的要求要簡單得多。基本上,這意味著如果你可以呼叫超類別實例上的某些方法,你應該可以在給定子類別實例時呼叫該方法。換句話說,方法是從超類別繼承的。
這符合繼承的目標之一—為使用者提供一種在類別之間重複使用程式碼的方式。在我們的直譯器中實現這一點非常容易。
return methods.get(name); }
在 findMethod() 中
if (superclass != null) { return superclass.findMethod(name); }
return null;
這實際上就是全部。當我們在實例上尋找方法時,如果我們在實例的類別上找不到它,我們會透過超類別鏈向上遞迴並在那裡尋找。試試看
class Doughnut { cook() { print "Fry until golden brown."; } } class BostonCream < Doughnut {} BostonCream().cook();
這就完成了,我們一半的繼承功能僅用了三行 Java 程式碼就完成了。
13.3呼叫超類別方法
在 findMethod()
中,我們在向上走超類別鏈之前,先在目前類別中尋找方法。如果子類別和超類別中都存在具有相同名稱的方法,則子類別的方法優先或覆寫超類別方法。有點像內部範圍中的變數如何遮蔽外部範圍的變數。
如果子類別想要完全取代某些超類別行為,那很棒。但是,實際上,子類別通常想要改進超類別的行為。他們想要做一些特定於子類別的工作,但也執行原始超類別的行為。
但是,由於子類別已覆寫該方法,因此無法參照原始方法。如果子類別方法嘗試按名稱呼叫它,它只會以遞迴方式命中自己的覆寫。我們需要一種方法來說「呼叫此方法,但在我的超類別上直接尋找它,並忽略我的覆寫」。Java 為此使用 super
,我們將在 Lox 中使用相同的語法。這是一個範例
class Doughnut { cook() { print "Fry until golden brown."; } } class BostonCream < Doughnut { cook() { super.cook(); print "Pipe full of custard and coat with chocolate."; } } BostonCream().cook();
如果你執行此程式碼,它應該會列印
Fry until golden brown. Pipe full of custard and coat with chocolate.
我們有一個新的運算式形式。super
關鍵字後面跟著一個點和一個識別符號,會尋找具有該名稱的方法。與 this
上的呼叫不同,搜尋從超類別開始。
13.3.1語法
使用 this
時,關鍵字的作用有點像魔術變數,而運算式就是該單獨的權杖。但是使用 super
時,後續的 .
和屬性名稱是 super
運算式不可分割的部分。你不能單獨擁有裸 super
權杖。
print super; // Syntax error.
因此,我們新增到語法中 primary
規則的新子句也包含屬性存取。
primary → "true" | "false" | "nil" | "this" | NUMBER | STRING | IDENTIFIER | "(" expression ")" | "super" "." IDENTIFIER ;
通常,super
運算式用於方法呼叫,但是,與常規方法一樣,引數清單不是運算式的一部分。相反,超級呼叫是一個超級存取,後跟一個函式呼叫。與其他方法呼叫一樣,你可以取得超類別方法的控制代碼並分別叫用它。
var method = super.cook; method();
所以 super
表達式本身只包含 super
關鍵字的詞彙,以及要查找的方法名稱。對應的 語法樹節點 因此是
"Set : Expr object, Token name, Expr value",
在 main() 中
"Super : Token keyword, Token method",
"This : Token keyword",
依照文法,新的解析程式碼會放在我們現有的 primary()
方法內。
return new Expr.Literal(previous().literal); }
在 primary() 中
if (match(SUPER)) { Token keyword = previous(); consume(DOT, "Expect '.' after 'super'."); Token method = consume(IDENTIFIER, "Expect superclass method name."); return new Expr.Super(keyword, method); }
if (match(THIS)) return new Expr.This(previous());
開頭的 super
關鍵字告訴我們已經遇到了 super
表達式。在那之後,我們消耗預期的 .
和方法名稱。
13 . 3 . 2語意
先前,我說 super
表達式從「超類別」開始方法查找,但是是哪個超類別?最直觀的答案是 this
的超類別,也就是呼叫周圍方法的物件。這在許多情況下恰巧產生了正確的行為,但實際上並非如此。請看
class A { method() { print "A method"; } } class B < A { method() { print "B method"; } test() { super.method(); } } class C < B {} C().test();
將此程式翻譯為 Java、C# 或 C++,它將印出「A method」,這也是我們希望 Lox 做的事情。當此程式執行時,在 test()
的主體內,this
是 C 的一個實例。C 的超類別是 B,但這不是應該開始查找的地方。如果這樣做,我們會找到 B 的 method()
。
相反,查找應該從包含 super
表達式的類別的超類別開始。在這種情況下,由於 test()
是在 B 內部定義的,因此它內部的 super
表達式應該從 B 的超類別—A 開始查找。

因此,為了評估 super
表達式,我們需要存取呼叫周圍類別定義的超類別。唉,在直譯器中,當我們執行 super
表達式時,我們無法輕易地取得該超類別。
我們可以在 LoxFunction 中新增一個欄位來儲存對擁有該方法的 LoxClass 的參考。直譯器會保留對目前正在執行的 LoxFunction 的參考,以便我們稍後在遇到 super
表達式時可以查找它。從那裡,我們會取得該方法的 LoxClass,然後再取得它的超類別。
這需要大量的程式碼。在上一章中,當我們需要新增對 this
的支援時,我們遇到了類似的問題。在這種情況下,我們使用現有的環境和閉包機制來儲存對目前物件的參考。我們是否可以做類似的事情來儲存超類別?如果答案是否定的,我可能就不會談論它了,所以 . . .是的。
一個重要的差異是,我們在方法被存取時綁定 this
。同一個方法可以在不同的實例上呼叫,而且每個實例都需要自己的 this
。對於 super
表達式,超類別是類別宣告本身的固定屬性。每次評估某個 super
表達式時,超類別始終相同。
這表示我們可以在類別定義執行時,建立超類別的環境一次。在我們定義方法之前,我們建立一個新環境來將類別的超類別綁定到名稱 super
。

當我們為每個方法建立 LoxFunction 執行階段表示時,這就是它們在閉包中捕獲的環境。稍後,當方法被調用並且 this
被綁定時,超類別環境會成為方法環境的父環境,如下所示

這需要大量的機制,但我們會逐步完成。在我們可以開始在執行階段建立環境之前,我們需要在解析器中處理對應的作用域鏈。
resolve(stmt.superclass); }
在 visitClassStmt() 中
if (stmt.superclass != null) { beginScope(); scopes.peek().put("super", true); }
beginScope();
如果類別宣告具有超類別,那麼我們會在其所有方法周圍建立一個新的作用域。在該作用域中,我們定義名稱「super」。一旦我們完成解析類別的方法,我們就會捨棄該作用域。
endScope();
在 visitClassStmt() 中
if (stmt.superclass != null) endScope();
currentClass = enclosingClass;
這是一個小的最佳化,但我們只會在類別實際上有超類別時才建立超類別環境。當沒有超類別時,建立它沒有意義,因為無論如何都沒辦法在其中儲存超類別。
在作用域鏈中定義了「super」之後,我們就能解析 super
表達式本身。
在 visitSetExpr() 之後新增
@Override public Void visitSuperExpr(Expr.Super expr) { resolveLocal(expr, expr.keyword); return null; }
我們解析 super
詞彙的方式與解析變數完全相同。解析會儲存直譯器需要沿著環境鏈走多少步,才能找到儲存超類別的環境。
此程式碼在直譯器中會被鏡射。當我們評估子類別定義時,我們會建立一個新環境。
throw new RuntimeError(stmt.superclass.name, "Superclass must be a class."); } } environment.define(stmt.name.lexeme, null);
在 visitClassStmt() 中
if (stmt.superclass != null) { environment = new Environment(environment); environment.define("super", superclass); }
Map<String, LoxFunction> methods = new HashMap<>();
在該環境內部,我們儲存對超類別的參考—也就是超類別的實際 LoxClass 物件,現在我們在執行階段已經有了它。然後,我們為每個方法建立 LoxFunction。它們將會捕獲目前的環境—也就是我們剛綁定「super」的環境—作為它們的閉包,像是我們需要的那樣保留超類別。完成之後,我們會彈出環境。
LoxClass klass = new LoxClass(stmt.name.lexeme, (LoxClass)superclass, methods);
在 visitClassStmt() 中
if (superclass != null) { environment = environment.enclosing; }
environment.assign(stmt.name, klass);
我們已準備好解釋 super
表達式本身。這裡有一些移動的部分,因此我們將逐步建立此方法。
在 visitSetExpr() 之後新增
@Override public Object visitSuperExpr(Expr.Super expr) { int distance = locals.get(expr); LoxClass superclass = (LoxClass)environment.getAt( distance, "super"); }
首先,這是我們一直努力的方向。我們在正確的環境中查找「super」,以此查找周圍類別的超類別。
當我們存取方法時,我們也需要將 this
綁定到存取該方法的物件。在像 doughnut.cook
這樣的表達式中,該物件是我們從評估 doughnut
獲得的任何物件。在像 super.cook
這樣的 super
表達式中,目前的物件隱式地與我們正在使用的相同目前的物件相同。換句話說,this
。即使我們在超類別上查找方法,實例仍然是 this
。
不幸的是,在 super
表達式內部,我們沒有方便的節點讓解析器將跳到 this
的次數掛在其上。幸運的是,我們確實控制了環境鏈的佈局。綁定「this」的環境始終位於我們儲存「super」的環境內部。
LoxClass superclass = (LoxClass)environment.getAt( distance, "super");
在 visitSuperExpr() 中
LoxInstance object = (LoxInstance)environment.getAt( distance - 1, "this");
}
將距離偏移一會在這個內部環境中查找「this」。我承認這並不是最優雅的程式碼,但它確實有效。
現在我們已準備好在超類別開始查找並綁定方法。
LoxInstance object = (LoxInstance)environment.getAt( distance - 1, "this");
在 visitSuperExpr() 中
LoxFunction method = superclass.findMethod(expr.method.lexeme); return method.bind(object);
}
這幾乎與查找 get 表達式方法的方式完全相同,除了我們在超類別上呼叫 findMethod()
,而不是在目前物件的類別上呼叫。
基本上就是這樣。當然,除了我們可能無法找到方法。因此,我們也要檢查這一點。
LoxFunction method = superclass.findMethod(expr.method.lexeme);
在 visitSuperExpr() 中
if (method == null) { throw new RuntimeError(expr.method, "Undefined property '" + expr.method.lexeme + "'."); }
return method.bind(object); }
您看!拿起稍早的 BostonCream 範例並嘗試一下。假設您和我做的一切都正確,它應該先將其油炸,然後再填入奶油。
13 . 3 . 3super 的無效使用
與先前的語言功能一樣,我們的實作會在使用者寫入正確程式碼時執行正確的操作,但我們尚未針對錯誤的程式碼對直譯器進行全面測試。特別是,請考慮
class Eclair { cook() { super.cook(); print "Pipe full of crème pâtissière."; } }
此類別具有 super
表達式,但沒有超類別。在執行階段,評估 super
表達式的程式碼假設「super」已成功解析,並且會在環境中找到。這裡會失敗,因為沒有超類別,因此沒有周圍的超類別環境。JVM 會擲回例外狀況,並使我們的直譯器陷入癱瘓。
哎呀,甚至還有更簡單的 super 錯誤用法
super.notEvenInAClass();
我們可以透過檢查「super」的查找是否成功,在執行階段處理此類錯誤。但是我們可以靜態地得知—僅透過查看原始程式碼—Eclair 沒有超類別,因此它內部沒有 super
表達式可以運作。同樣地,在第二個範例中,我們知道 super
表達式甚至不在方法主體內部。
即使 Lox 是動態類型,這並不表示我們希望將所有事情都延遲到執行階段。如果使用者犯了錯,我們希望盡快協助他們找到錯誤。因此,我們會在解析器中靜態地報告這些錯誤。
首先,我們在用於追蹤目前所走訪程式碼周圍類別種類的列舉中,新增一個新的情況。
NONE,
CLASS,
在列舉 ClassType 中
在上一行新增 “,”
SUBCLASS
}
我們將使用它來區分我們何時在具有超類別的類別內部,以及何時在沒有超類別的類別內部。當我們解析類別宣告時,如果該類別是子類別,我們會設定它。
if (stmt.superclass != null) {
在 visitClassStmt() 中
currentClass = ClassType.SUBCLASS;
resolve(stmt.superclass);
然後,當我們解析 super
表達式時,我們會檢查我們目前是否在允許這樣做的作用域內。
public Void visitSuperExpr(Expr.Super expr) {
在 visitSuperExpr() 中
if (currentClass == ClassType.NONE) { Lox.error(expr.keyword, "Can't use 'super' outside of a class."); } else if (currentClass != ClassType.SUBCLASS) { Lox.error(expr.keyword, "Can't use 'super' in a class with no superclass."); }
resolveLocal(expr, expr.keyword);
如果沒有—哎呀!—使用者犯了一個錯誤。
13 . 4結論
我們做到了!最後的錯誤處理是完成 Java Lox 實作所需的最後一塊程式碼。這是一項真正的成就,您應該為此感到自豪。在過去十幾章和約一千行程式碼中,我們學習並實作了 . . .
- 詞彙和詞法分析,
- 抽象語法樹,
- 遞迴下降解析,
- 前置和中置表達式,
- 物件的執行階段表示,
- 使用訪問者模式解釋程式碼,
- 詞法作用域,
- 用於儲存變數的環境鏈,
- 控制流程,
- 帶有參數的函數,
- 閉包,
- 靜態變數解析和錯誤偵測,
- 類別,
- 建構函式,
- 欄位,
- 方法,以及最後,
- 繼承。
我們從頭開始完成了這一切,沒有外部依賴或魔法工具。只有您和我、我們各自的文字編輯器、Java 標準程式庫中的幾個集合類別,以及 JVM 執行階段。
這標誌著第二部分的結束,但並非本書的結束。休息一下。或許可以寫一些有趣的 Lox 程式,並在你的直譯器中執行。(你可能需要為讀取使用者輸入等功能添加一些原生方法。)當你恢復精神並準備好後,我們將開始我們的下一個冒險。
挑戰
-
Lox 僅支援單一繼承—一個類別可以有一個單一的父類別,而這是跨類別重複使用方法的唯一方法。其他程式語言探索了各種方法,以便更自由地在類別之間重複使用和分享功能:混入 (mixins)、特質 (traits)、多重繼承、虛擬繼承、擴展方法等。
如果你要在 Lox 中添加一些類似的功能,你會選擇哪一個,為什麼?如果你覺得自己有膽量(而且你現在應該有),那就去把它加上吧。
-
在 Lox 中,如同大多數其他物件導向程式語言,當查找方法時,我們從類別階層的底部開始,向上尋找—子類別的方法優先於父類別的方法。為了從覆寫方法中取得父類別的方法,你使用
super
。程式語言 BETA 採取了相反的方法。當你呼叫一個方法時,它從類別階層的頂部開始,然後向下尋找。父類別的方法優先於子類別的方法。為了取得子類別的方法,父類別的方法可以呼叫
inner
,這有點像是super
的反向。它會鏈接到階層中下一個方法。父類別的方法控制著子類別被允許在何時何地調整其行為。如果父類別的方法根本不呼叫
inner
,那麼子類別就無法覆寫或修改父類別的行為。移除 Lox 目前的覆寫和
super
行為,並將其替換為 BETA 的語意。簡而言之-
當在類別上呼叫方法時,優先選擇類別繼承鏈中最高的方法。
-
在方法的主體內,對
inner
的呼叫會在包含inner
的類別與this
的類別之間的繼承鏈上,尋找最近的子類別中具有相同名稱的方法。如果沒有匹配的方法,inner
呼叫不會執行任何操作。
例如
class Doughnut { cook() { print "Fry until golden brown."; inner(); print "Place in a nice box."; } } class BostonCream < Doughnut { cook() { print "Pipe full of custard and coat with chocolate."; } } BostonCream().cook();
這應該會印出
Fry until golden brown. Pipe full of custard and coat with chocolate. Place in a nice box.
-
-
在我介紹 Lox 的章節中,我挑戰你提出你認為程式語言缺少的一些功能。現在你已經知道如何建構一個直譯器,實作其中一個功能吧。