Skip to main content

第 29 章:超类(Superclasses)

原文:Robert Nystrom, Crafting Interpreters, Chapter 29。原书以 CC BY-NC-SA 4.0 协议发布;本译文用于学习与研究。

朋友可以自己选,家人却不能;无论你承不承认,他们仍是你的亲人,不承认只会显得很傻。

Harper Lee,《杀死一只知更鸟》

这是 VM 最后一个添加新功能的章节。Lox 几乎都已装进去了,剩下的只有继承方法和调用超类方法。下一章只让已有功能更快;完成本章,便拥有完整的 Lox 实现。性能当然重要,毕竟第二个 VM 的目的正是超过 jlox;甚至过去十五章都可算作“优化”。

本章有些内容会让人想起 jloxsuper 的静态解析几乎相同,只是 clox 用栈保存状态。继承方法则采用完全不同、且更快的实现。

29.1 继承方法(Inheriting Methods)

Lox 的继承语法如下:

class Doughnut {
cook() { print "Dunk in the fryer."; }
}

class Cruller < Doughnut {
finish() { print "Glaze with icing."; }
}

Cruller 的实例继承 Doughnut.cook()。编译类名之后若匹配 <,就消费超类标识符并调用 variable();它把刚消费的 token 当变量引用,生成按名字查找超类并压栈的代码。接着调用 namedVariable() 重新把子类压栈,再发出 OP_INHERIT

if (match(TOKEN_LESS)) {
consume(TOKEN_IDENTIFIER, "Expect superclass name.");
variable(false);
namedVariable(className, false);
emitByte(OP_INHERIT);
}

OP_METHOD 会修改已有类对象,把一个方法加入其表;OP_INHERIT 类似,给已有子类对象施加继承效果。对于 class Cruller < Doughnut {,超类与子类会依次在栈上供该指令使用。

Cruller 继承 Doughnut 时的一系列字节码指令。

类不能继承自己:

if (identifiersEqual(&className, &parser.previous)) {
error("A class can't inherit from itself.");
}

除非有失常核物理学家和重度改装的 DeLorean,否则不能从自己继承。有趣的是,本章的 copy-down 实现即便容许环也未必崩溃或无限循环,只是毫无用处。

29.1.1 执行继承(Executing inheritance)

OP_INHERIT 没有操作数,超类和子类都在栈中,反汇编也很简单:

OP_INHERIT,

case OP_INHERIT:
return simpleInstruction("OP_INHERIT", offset);

栈顶是子类、下一格是超类。jlox 的子类保存超类引用;每次查找失败便沿祖先链递归查询。祖先越远,调用越慢:

jlox 为 Cruller 实例解析 cook() 时沿超类链查找。

clox 改为在声明子类时把超类所有方法复制进子类自己的方法表;调用时直接在子类表中命中,继承方法和普通方法一样快,只需一次哈希查找(严格说因字段可遮蔽方法,仍先查字段)。

case OP_INHERIT: {
Value superclass = peek(1);
if (!IS_CLASS(superclass)) {
runtimeError("Superclass must be a class.");
return INTERPRET_RUNTIME_ERROR;
}
ObjClass* subclass = AS_CLASS(peek(0));
tableAddAll(&AS_CLASS(superclass)->methods, &subclass->methods);
pop();
break;
}

clox 将继承方法复制到 Cruller 自己的方法表,因此直接查到 cook()。

这种技术叫 copy-down inheritance。它适用于 Lox,因为类是封闭的:类声明执行完后,方法集合不能再改变。Ruby、Python、JavaScript 可以在运行时给已有类增删方法;若超类在子类声明后被修改,复制的表不会更新,会破坏继承应反映超类当前状态的预期。动态修改类很强大,也很危险,故有“猴子补丁(monkey patching)”甚至更不雅的称呼。

自然,该猴子戴着眼罩。

覆写不会冲突。OP_INHERIT 在创建空子类的 OP_CLASS 之后、任何 OP_METHOD 之前执行;复制时子类表为空,随后子类同名方法自然覆盖复制进来的条目。

还须防御非类超类:

var NotClass = "So not a class";
class OhNo < NotClass {}

前述 IS_CLASS 检查会以运行时错误拒绝它。

29.2 保存超类(Storing Superclasses)

继承方法时,复制后便可忘掉超类;但 super 调用必须访问超类的方法表。它不是基于接收者动态决定的,而是基于包含该调用的方法所定义的类静态决定。

class A { method() { print "A method"; } }
class B < A {
method() { print "B method"; }
test() { super.method(); }
}
class C < B {}
C().test(); // A method

尽管 thisC 实例,super.method() 位于 B.test(),所以应从 B 的超类 A 开始,而不是从 C 的超类 B 开始。jlox 将超类放在词法 Environmentclox 用值栈与 upvalue 达到同样效果,可把程序理解成每个子类有一个隐藏变量保存超类。

29.2.1 超类局部变量(A superclass local variable)

编译器已经把超类值压栈。若有超类,从而开启一个新作用域并把这个栈槽声明为隐藏局部变量 super。每个子类独占作用域,避免同一作用域中的两个同名隐藏变量冲突;super 是保留字,不会与用户变量撞名。

if (match(TOKEN_LESS)) {
consume(TOKEN_IDENTIFIER, "Expect superclass name.");
variable(false);
beginScope();
addLocal(syntheticToken("super"));
defineVariable(0);

namedVariable(className, false);
emitByte(OP_INHERIT);
}

syntheticToken() 为常量 C 字符串构造 token:token 不管理 lexeme 内存,使用堆字符串会泄漏,而字符串字面量驻留在可执行文件的常量数据段,无须释放。

static Token syntheticToken(const char* text) {
Token token;
token.start = text;
token.length = (int)strlen(text);
return token;
}

类体和方法编译完后,若存在超类便结束此作用域,丢弃隐藏变量;方法仍能以既有闭包捕获机制保存它,甚至方法内嵌的函数也可以。ClassCompiler 新增 bool hasSuperclass,初始化为 false,遇到 < 时设为 true,便能让其他编译函数知道当前类是否为子类。

if (classCompiler.hasSuperclass) endScope();

29.3 super 调用(Super Calls)

super 是解析表中最后一个新增条目:

[TOKEN_SUPER] = {super_, NULL, PREC_NONE},

它不像 this 可独立作为表达式,点号和方法名是不可分割的语法;但可选择只取超类方法的引用而不立即调用:

class A { method() { print "A"; } }
class B < A {
method() {
var closure = super.method;
closure(); // A
}
}

解析 super 后必须消费 . 与方法名,把方法名存入常量表。运行时需要当前接收者与包含类的超类:先加载隐藏变量 this,再加载隐藏变量 super,随后发出 OP_GET_SUPER name

static void super_(bool canAssign) {
if (currentClass == NULL) {
error("Can't use 'super' outside of a class.");
} else if (!currentClass->hasSuperclass) {
error("Can't use 'super' in a class with no superclass.");
}
consume(TOKEN_DOT, "Expect '.' after 'super'.");
consume(TOKEN_IDENTIFIER, "Expect superclass method name.");
uint8_t name = identifierConstant(&parser.previous);
namedVariable(syntheticToken("this"), false);
namedVariable(syntheticToken("super"), false);
emitBytes(OP_GET_SUPER, name);
}

因此 super.finish("icing") 先得到实例与静态解析的超类,再用操作数携带方法名;之后才是普通的参数求值和调用:

调用 super.finish() 的字节码序列。

super 只能出现在有超类的类的方法体中(或该方法内部嵌套函数中)。currentClass == NULL!hasSuperclass 分别报告“类外使用”和“无超类的类中使用”。

29.3.1 执行超类访问(Executing super accesses)

OP_GET_SUPER 含一个方法名常量操作数,反汇编和普通属性指令一样:

OP_GET_SUPER,

case OP_GET_SUPER:
return constantInstruction("OP_GET_SUPER", chunk, offset);

解释时读取方法名,弹出栈顶超类,调用已有 bindMethod()。弹出超类后实例正好位于栈顶;成功则绑定方法会替换实例,失败则报告运行时错误。

case OP_GET_SUPER: {
ObjString* name = READ_STRING();
ObjClass* superclass = AS_CLASS(pop());
if (!bindMethod(superclass, name)) {
return INTERPRET_RUNTIME_ERROR;
}
break;
}

普通属性访问以实例自身类为起点,得到动态分派;super 则以编译器准备好的静态超类为起点,跳过子类中所有覆写,同时仍包含该超类继承来的方法。这里不检查遮蔽字段:字段不继承,super 表达式始终解析为方法。若是基于原型的委托语言,实例可继承另一个实例的字段,便必须检查。

29.3.2 更快的 super 调用(Faster super calls)

super.method() 已正确却仍慢:每次先分配绑定方法,下一条 OP_CALL 又立即拆开并丢弃它。对超类调用,这种“取引用后不调用”的情况比普通方法更少,因此同样融合为超级指令。

若方法名后匹配 (,先编译参数,再加载 super,发出 OP_SUPER_INVOKE name argCount;否则继续生成 OP_GET_SUPER

namedVariable(syntheticToken("this"), false);
if (match(TOKEN_LEFT_PAREN)) {
uint8_t argCount = argumentList();
namedVariable(syntheticToken("super"), false);
emitBytes(OP_SUPER_INVOKE, name);
emitByte(argCount);
} else {
namedVariable(syntheticToken("super"), false);
emitBytes(OP_GET_SUPER, name);
}

OP_SUPER_INVOKE 的两个操作数与 OP_INVOKE 相同,故复用同一反汇编辅助函数。解释器读取名称与参数个数,弹出位于参数之上的超类,并使用上一章的 invokeFromClass();成功新建调用帧后,记得刷新缓存的 frame

case OP_SUPER_INVOKE: {
ObjString* method = READ_STRING();
int argCount = READ_BYTE();
ObjClass* superclass = AS_CLASS(pop());
if (!invokeFromClass(superclass, method, argCount)) {
return INTERPRET_RUNTIME_ERROR;
}
frame = &vm.frames[vm.frameCount - 1];
break;
}

未优化路径会在求参数把超类替换成绑定方法,保证 OP_CALL 时绑定方法位于参数之下。优化路径在调用时才解析方法,故参数已经在栈中、超类位于参数之上;弹出超类恰好使栈重新符合方法调用布局。

用 OP_SUPER_INVOKE 调用 super.finish() 的字节码和栈布局。

29.4 完整虚拟机(A Complete Virtual Machine)

回头看,我们用约 2,500 行清晰直接的 C,完成了相当高层的 Lox:完整表达式优先级表、控制流、变量、函数、闭包、类、字段、方法与继承。它可移植到任何有 C 编译器的平台,并足以用于现实生产:单趟字节码编译器、紧凑 VM 指令解释器、紧凑对象表示、避免堆分配的变量栈、精确 GC。

现在去阅读 Lua、Python 或 Ruby 的实现,会惊讶于其中多少内容已熟悉。你不仅会开车,也能掀开引擎盖修发动机。两套 Lox 实现到这里已完整;想继续榨取性能,下一章有几项经典优化,但不再增加语言能力。

挑战(Challenges)

  1. 类应保证新对象处于有效状态。子类应调用 super.init(),但继承链上两个类可能意外使用同一字段名并破坏不变量。若 Lox 是你的语言,你会如何处理?若要改语言,请实现该改动。
  2. copy-down 继承依赖于类声明后方法不可变。Ruby 等允许事后修改类,实现者如何既支持这种修改又保持高效的方法解析?
  3. jlox 的继承章节中曾要求实现 BETA 语言的覆写语义。BETA 与 Lox 相反:调用方法时从继承链顶端向下,超类优先;超类方法可调用 inner 查找包含类与 this 所属类之间最近子类的同名方法,若没有则不做事。移除 Lox 当前覆写与 super,高效实现 BETA 语义。以下程序应依次打印炸、填馅、装盒三个步骤:
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();