第 13 章:继承(Inheritance)
原文:Robert Nystrom, Crafting Interpreters, Chapter 13。原书以 CC BY-NC-SA 4.0 协议发布;本译文用于学习与研究。
我们曾是海里的微小生命,后来成了鱼、蜥蜴、鼠、猴子,以及其间数不清的形态。我们的手曾是鳍,也曾有爪;人类的嘴里仍有狼的尖牙、兔的凿齿和牛的臼齿。我们的血仍像曾经居住过的海一样咸。恐惧时,皮肤上的毛会竖起,正如我们还有皮毛时一样。我们就是历史;成为今天的我们之前所曾是的一切,仍在我们身上。
Terry Pratchett,《A Hat Full of Sky》
这是树遍历解释器部分的最后一章。上一章一次引入了类、实例、字段、方法、this 与初始化器;其中唯一还可以独立拆出的对象系统特性,就是继承。本章让一个类复用另一个类的方法,并实现 super 调用。完成后,jlox 就是一门完整的 Lox 实现。
13.1 超类与子类(Superclasses and Subclasses)
继承从最早的面向对象语言 Simula 起就存在。模拟程序中常有一组行为相似的对象;把共同部分放在一个类中,再让更具体的类继承它,就能避免复制代码。
术语并不完全统一。这里把被继承的类称为超类(superclass),继承它的类称为子类(subclass)。C. A. R. Hoare 最初用 subclass 指称细化另一记录类型的类型,Simula 借用它来表示继承其他类的类;后来 Smalltalk 才把拉丁前缀翻转,用 superclass 称呼关系的另一端。在 C++ 中,它们也常叫基类和派生类。静态类型语言里,子类通常还是超类的子类型:每个 BostonCream 都是 Doughnut,但并非每个 Doughnut 都是 BostonCream。
“super”和“sub”分别来自拉丁语的“上方”和“下方”。把继承关系画成根在上方的树时,子类自然位于超类下方。集合论中,子集包含在超集中;类型理论借用了同一组概念:若每个 BostonCream 实例都是 Doughnut 实例,那么 BostonCream 的值集合就是 Doughnut 值集合的子集。因此,在许多静态类型的类语言里,子类、子类型和子集这三件事恰好对齐。
各语言的继承语法差异很大:C++ 和 C# 在子类名后写 :,Java 用 extends,Python 把超类放在类名后的括号中,Simula 甚至把超类名放在 class 关键字之前。Lox 在类名后使用小于号指定超类,和 Ruby 的写法相近;本书后段不希望为了它再给扫描器增加新保留字或记号,而现有的 < 正可复用:
class Doughnut {
cook() {
print "Fry until golden brown.";
}
}
class BostonCream < Doughnut {
fill() {
print "Pipe full of custard and coat with chocolate.";
}
}

在静态类型语言里,这种关系还牵涉到内存布局和可替换性;更正式地说,子类应能在需要超类的地方使用,这常被称为里氏替换原则(Liskov substitution principle)。Lox 是动态类型语言,不在运行时维护静态子类型信息;这里继承所保证的核心行为是:能在超类实例上调用的方法,也应能在子类实例上调用。
Lox 没有所有类都会隐式继承的根 Object 类。没有写 < 的类就没有超类。相应地,文法中的超类子句是可选的:
classDecl -> "class" IDENTIFIER ( "<" IDENTIFIER )?
"{" function* "}" ;
13.1.1 在语法树中保存超类
Stmt.Class 多出一个 Expr.Variable superclass 字段:
"Class : Token name, Expr.Variable superclass, List<Stmt.Function> methods"
这里看似只需要一个 Token,但保存为变量表达式更合适。超类名称在运行时本质上是一次变量读取,而解析器现在就把它包装为 Expr.Variable 后,Resolver 才能为它记录词法作用域距离。
private Stmt classDeclaration() {
Token name = consume(IDENTIFIER, "Expect class name.");
Expr.Variable superclass = null;
if (match(LESS)) {
consume(IDENTIFIER, "Expect superclass name.");
superclass = new Expr.Variable(previous());
}
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, superclass, methods);
}
13.1.2 解析阶段的约束
Resolver 需要进入这个新子表达式,并拒绝最明显的环:
class Oops < Oops {}
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) resolve(stmt.superclass);
这项检查不能捕获所有间接循环,例如通过变量和运行时赋值构造的循环;但它能给常见笔误一个清晰的静态错误。解释器仍必须检查超类表达式最终求出的值确实是 LoxClass。
类也可以声明在局部作用域中,因此 resolve(stmt.superclass) 并非多余:< 后的标识符可能正是外层块中的局部变量。解析器先定义类名、再解析超类表达式,因而允许类在自己的方法中递归引用自身,却不会把类名本身误当作超类的值。
13.2 继承方法(Inheriting Methods)
子类实例首先在自己的类中找方法;找不到时,沿着超类链继续找。因此上例中的 BostonCream 自动拥有 cook():
var pastry = BostonCream();
pastry.cook();
pastry.fill();

给 LoxClass 增加对父类的引用:
class LoxClass implements LoxCallable {
final String name;
final LoxClass superclass;
private final Map<String, LoxFunction> methods;
LoxClass(String name, LoxClass superclass,
Map<String, LoxFunction> methods) {
this.name = name;
this.superclass = superclass;
this.methods = methods;
}
LoxFunction findMethod(String name) {
if (methods.containsKey(name)) return methods.get(name);
if (superclass != null) return superclass.findMethod(name);
return null;
}
}
递归查找既表达了继承链,也自然处理了重写:子类方法表先被检查,同名方法便遮蔽超类的方法。初始化器同样通过 findMethod("init") 查找,所以子类没有定义 init 时会继承父类初始化器,类调用的参数个数也随之正确继承。
这部分实现只有递归调用的几行代码,但对应的语言承诺很重要。动态类型的 Lox 不必证明内存布局兼容,也不必在编译时建立子类型关系;只要给定一个子类实例时,同样的超类方法调用仍能成功,继承就达到了复用行为的目的。
字段不沿着类链查找:字段属于各个实例,而方法属于类。实例的 get() 先查自己的字段,再调用类的 findMethod();后者递归处理的只有方法。这样,子类方法可以覆盖超类方法,但实例字段仍可遮蔽同名方法,和上一章定义的属性访问规则保持一致。
解释类声明时,先求值超类表达式并检查类型:
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);
// 创建 methods,见下节。
LoxClass klass = new LoxClass(stmt.name.lexeme,
(LoxClass) superclass, methods);
environment.assign(stmt.name, klass);
没有超类时,null 表示查找链在这里结束。把这一规则封装在 findMethod() 中,实例字段访问不需要知道继承的存在。
13.3 调用超类方法(Calling Superclass Methods)
子类通常不仅要继承方法,也要在重写时调用原实现:
class Doughnut {
cook() {
print "Fry until golden brown.";
}
}
class BostonCream < Doughnut {
cook() {
super.cook();
print "Pipe full of custard and coat with chocolate.";
}
}
运行这段程序依次输出:
Fry until golden brown.
Pipe full of custard and coat with chocolate.
若写成 this.cook(),动态分派会再次找到子类重写后的 cook(),从而无限递归。super 的含义是“从当前类的超类开始查找”,但仍要把得到的方法绑定到当前实例;它改变的是查找起点,不是接收者。
13.3.1 语法与 AST
super 后只能跟点与方法名:
primary -> "super" "." IDENTIFIER
| ... ;
它不能独立出现,下面的程序是语法错误:
print super;
"Super : Token keyword, Token method"
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);
}
将 super.method 作为单独表达式,而不是普通 Get,使解释器能保留“绕过当前类”的语义;调用与否仍由外层已有的调用表达式决定。因此也可以先取出绑定方法再调用:
var method = super.cook;
method();
13.3.2 语义(Semantics)
super 不是保存某个对象的普通变量。它由写下该方法的类决定查找起点,由调用时的实例决定 this。于是,即使把方法作为值取出后再调用,super 也仍准确指向声明该方法的类的超类;这正是词法作用域在对象系统里的又一次应用。
这个区别在继承链更长时才会显露出来:
class A {
method() { print "A method"; }
}
class B < A {
method() { print "B method"; }
test() { super.method(); }
}
class C < B {}
C().test();
结果必须是 A method。调用 test() 的接收者确实是 C 实例,但 super 位于 B 定义的 test() 内,故查找必须从 B 的超类 A 开始;若按照 this 的运行时类 C 找其超类,就会错误地命中 B.method()。
类声明执行期间,如果有超类,解释器应在创建方法闭包前先求值超类,并建立保存 super 的环境:
if (stmt.superclass != null) {
environment = new Environment(environment);
environment.define("super", superclass);
}
Map<String, LoxFunction> methods = new HashMap<>();
for (Stmt.Function method : stmt.methods) {
boolean isInitializer = method.name.lexeme.equals("init");
methods.put(method.name.lexeme,
new LoxFunction(method, environment, isInitializer));
}
if (superclass != null) environment = environment.enclosing;
每个方法闭包都捕获这层环境。稍后调用方法时,bind() 又在它前面放入含 this 的环境。因此方法体从内到外看到的链是:局部变量环境 -> this 环境 -> super 环境 -> 声明类时的外围环境。


这也解释了为什么 super 不能简单保存为类对象上的一个全局字段:它是按词法位置绑定的。另一种可行方案是让每个 LoxFunction 保存所属类,解释器再跟踪当前执行的函数;但超类对于一条类声明中的每个 super 表达式都是固定的,把它一次性放进方法闭包更直接。即使子类的某个方法被取出后在别处调用,它仍应从定义该方法的类的父类开始查找。
13.3.3 解析与执行 super
Resolver 为子类增加状态,并只在合法位置解析 super:
private enum ClassType { NONE, CLASS, SUBCLASS }
if (stmt.superclass != null) {
currentClass = ClassType.SUBCLASS;
beginScope();
scopes.peek().put("super", true);
}
beginScope();
scopes.peek().put("this", true);
// 解析方法。
endScope();
if (stmt.superclass != null) endScope();
这层作用域必须包住 this 的作用域。方法调用时,bind() 创建的 this 环境正好位于捕获 super 的闭包环境内侧;所以解释器通过 distance - 1 取得 this 并非偶然,而是与 Resolver 记录的词法作用域顺序严格对应。
public Void visitSuperExpr(Expr.Super expr) {
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);
return null;
}
解释器从 Resolver 记录的距离取得 super。this 紧挨在它的内层环境,所以距离减一即可取得接收者:
@Override
public Object visitSuperExpr(Expr.Super expr) {
int distance = locals.get(expr);
LoxClass superclass = (LoxClass)
environment.getAt(distance, "super");
LoxInstance object = (LoxInstance)
environment.getAt(distance - 1, "this");
LoxFunction method = superclass.findMethod(expr.method.lexeme);
if (method == null) {
throw new RuntimeError(expr.method,
"Undefined property '" + expr.method.lexeme + "'.");
}
return method.bind(object);
}
此处的 distance - 1 看似脆弱,但它并不是对运行时栈帧布局的猜测,而是上述两个词法环境构造顺序的直接结果。super 和 this 的绑定始终相邻。
13.3.4 super 的非法用法(Invalid Uses of super)
super 脱离子类没有意义。Resolver 以 NONE、CLASS、SUBCLASS 三种 ClassType 状态区分“类外”“无超类的类中”和“子类中”,所以它可以在解析期给出两类明确错误:类外使用 super,以及在没有超类的类中使用 super。在检查自继承后才将状态设为 SUBCLASS,并在解析完类声明后恢复外层状态;这也确保嵌套类不会污染包围类的语义状态。
例如,下列两处错误不应留给 JVM 的环境查找异常处理:
class Eclair {
cook() { super.cook(); }
}
super.notEvenInAClass();
前者所在的类没有超类,后者甚至不在类体中。Resolver 已能只根据源代码判断它们必错,因此应尽早报告人类可读的 Lox 错误,而不是让解释器在运行时崩溃。

13.4 完成解释器(Conclusion)
至此,Lox 具备了原书定义的全部语言特性:表达式、变量、控制流、函数与闭包、类、字段、方法、初始化器和继承。jlox 的运行模型有意朴素:它直接遍历语法树,依靠 JVM 的对象、集合和垃圾收集设施。这让语义实现清晰可读,但并不快。
完成这十三章已经实现了记号与词法分析、抽象语法树、递归下降解析、前缀和中缀表达式、运行时对象表示、解释、变量作用域、控制流、函数、闭包、静态语义分析,以及面向对象编程。它是一门真实可用的语言实现,而不只是零散技巧的集合。
下一部分会用 C 从更低层重新实现 Lox。我们将不再保存整棵 AST,而是把源代码编译成紧凑的字节码,并手写虚拟机、对象表示、哈希表和垃圾收集器。两种实现语言语义相同,却呈现了实现解释器的两条重要路线。到这里为止,整个 jlox 都只依靠文本编辑器、Java 标准库集合和 JVM;不妨先写几个 Lox 小程序试运行,也可添加读取用户输入之类的原生方法,然后再进入下一部分。
挑战(Challenges)
- 让 Lox 支持多重继承。方法查找顺序应是什么?菱形继承会带来什么问题?
- 允许在类体中声明静态方法,并设计其查找与
this、super的语义。 - 让
super能读取超类字段。字段属于实例而非类时,这个要求究竟意味着什么? - 为类增加可见性控制,例如私有方法。动态字段和方法重写会如何影响这一设计?
- 当前实现只阻止直接自继承。思考能否在保持 Lox 动态性的同时检测更复杂的继承环。