继承
我们曾经是海洋中的水滴,然后是鱼类,然后是蜥蜴、老鼠,最后是猴子,以及它们之间数百种生物。这双手曾经是鱼鳍,这双手曾经长着爪子!在我人类的口中,我拥有狼的尖牙,兔子凿子的牙齿,以及牛的研磨齿!我们的血液和我们曾经居住的海洋一样咸!当我们害怕时,我们皮肤上的毛发会竖起来,就像我们有毛皮的时候一样。我们是历史!我们成为我们之前的所有生物,我们依然存在。
特里·普拉切特,《天空中的帽子》
你能相信吗?我们已经来到了第二部分的最后一章。我们几乎完成了我们的第一个 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,而不是 Token。语法将超类子句限制为单个标识符,但在运行时,该标识符将作为变量访问进行评估。在解析器中尽早将名称包装在 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对象,现在我们已经进入运行时了)。然后我们为每个方法创建LoxFunctions。它们将捕获当前环境(我们刚刚绑定“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表达式的method代码完全相同,只是我们在超类而不是当前对象的类上调用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将抛出异常并让我们的解释器瘫痪。
heck,甚至还有更简单的错误使用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结论
我们成功了!最后的错误处理部分是完成Lox的Java实现所需的最后一段代码。这是一个真正的成就,你应该为此感到自豪。在过去的十几个章节和一千多行代码中,我们学习并实现了 . . .
- 标记和词法分析,
- 抽象语法树,
- 递归下降解析,
- 前缀和中缀表达式
- 对象的运行时表示
- 使用访问者模式解释代码,
- 词法作用域,
- 用于存储变量的环境链
- 控制流,
- 带有参数的函数,
- 闭包
- 静态变量解析和错误检测,
- 类,
- 构造函数
- 字段
- 方法,最后
- 继承。
我们从头开始做到了这一切,没有任何外部依赖或神奇工具。只有你和我的文本编辑器,Java标准库中的几个集合类,以及JVM运行时。
这标志着第二部分的结束,但不是本书的结束。休息一下。也许编写一些有趣的Lox程序并在你的解释器中运行它们。(你可能想添加一些本地方法,比如读取用户输入。)当你精神焕发并准备好了,我们将踏上我们的下一个冒险。
挑战
-
Lox只支持单继承(一个类可以有一个超类,这是跨类重用方法的唯一方法)。其他语言探索了各种方法,以便更自由地跨类重用和共享功能:mixin、trait、多重继承、虚拟继承、扩展方法等。
如果你要将类似的功能添加到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 的章节中,我向您挑战提出一些您认为该语言缺少的功能。现在您已经知道如何构建解释器了,请实现其中一项功能。