Skip to main content

第 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++ 中,它们也常叫基类和派生类。静态类型语言里,子类通常还是超类的子类型:每个 BostonCream 都是 Doughnut,但并非每个 Doughnut 都是 BostonCream

Lox 在类名后使用小于号指定超类,和 Ruby 的写法相近:

class Doughnut {
cook() {
print "Fry until golden brown.";
}
}

class BostonCream < Doughnut {
fill() {
print "Pipe full of custard and coat with chocolate.";
}
}

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

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 时会继承父类初始化器,类调用的参数个数也随之正确继承。

解释类声明时,先求值超类表达式并检查类型:

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.";
}
}

若写成 this.cook(),动态分派会再次找到子类重写后的 cook(),从而无限递归。super 的含义是“从当前类的超类开始查找”,但仍要把得到的方法绑定到当前实例;它改变的是查找起点,不是接收者。

13.3.1 语法与 AST

super 后只能跟点与方法名:

primary -> "super" "." IDENTIFIER
| ... ;
"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 为 super 建立环境

类声明执行期间,如果存在超类,临时创建一层环境并定义 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 不能简单保存为类对象上的一个全局字段:它是按词法位置绑定的。即使子类的某个方法被取出后在别处调用,它仍应从定义该方法的类的父类开始查找。

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();
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 记录的距离取得 superthis 紧挨在它的内层环境,所以距离减一即可取得接收者:

@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 看似脆弱,但它并不是对运行时栈帧布局的猜测,而是上述两个词法环境构造顺序的直接结果。superthis 的绑定始终相邻。

13.4 完成解释器(Conclusion)

至此,Lox 具备了原书定义的全部语言特性:表达式、变量、控制流、函数与闭包、类、字段、方法、初始化器和继承。jlox 的运行模型有意朴素:它直接遍历语法树,依靠 JVM 的对象、集合和垃圾收集设施。这让语义实现清晰可读,但并不快。

下一部分会用 C 从更低层重新实现 Lox。我们将不再保存整棵 AST,而是把源代码编译成紧凑的字节码,并手写虚拟机、对象表示、哈希表和垃圾收集器。两种实现语言语义相同,却呈现了实现解释器的两条重要路线。

挑战(Challenges)

  1. 让 Lox 支持多重继承。方法查找顺序应是什么?菱形继承会带来什么问题?
  2. 允许在类体中声明静态方法,并设计其查找与 thissuper 的语义。
  3. super 能读取超类字段。字段属于实例而非类时,这个要求究竟意味着什么?
  4. 为类增加可见性控制,例如私有方法。动态字段和方法重写会如何影响这一设计?
  5. 当前实现只阻止直接自继承。思考能否在保持 Lox 动态性的同时检测更复杂的继承环。