- 编程语言
- 解释器
- 编译器
- 语言运行时
- 教程
【免费下载链接】craftinginterpreters
Repository for the book "Crafting Interpreters"
继承(inheritance)是对象导向语言的核心能力之一。在《Crafting Interpreters》一书的 Part II 收官章节中,作者 Robert Nystrom 为纯 Java 实现的树遍历解释器 jlox 补齐了 Lox 语言的类继承:从<超类语法与语法树改造,到findMethod()沿继承链递归查找方法,再到利用环境链机制实现super表达式。本文以 book/inheritance.md 为主线,逐一拆解这些设计决策,并结合仓库中的真实源码(java/com/craftinginterpreters/lox)与测试用例(test/inheritance、test/super)进行验证,读完本文你将能完整理解并复刻 jlox 的继承实现。
背景:Part II 的最后一块拼图
在 classes 一章 中,jlox 已经实现了类的声明、实例化、字段、方法和构造器,但彼时的类之间彼此孤立,缺少“复用”能力。这一章的目标就是补上 Lox 的类继承——继承机制最早可追溯到 Simula,其设计者 Kristen Nygaard 与 Ole-Johan Dahl 发现仿真程序中不同类之间存在大量共性代码,继承正是为“跨类复用代码”而生。
在阅读本章之前,建议读者先掌握三块前置知识:词法作用域与变量解析(见 resolving-and-binding.md)、环境链(Environment)与闭包捕获(见 closures.md)、以及this的绑定方式(见 classes.md)。本章实现的关键点——把超类像变量一样放进环境链——正是对既有闭包机制的复用,这也是 jlox 中“用最少的新机制完成新功能”的典型手法。
超类与子类:语法设计与语法树改造
术语与设计取舍
作者借 C. A. R. Hoare 提出的“subclass”一词梳理了术语:Simula 用它指“继承自其他类的类”,Smalltalk 反用拉丁前缀造出“superclass”指关系的另一侧,C++ 语境下则常说“base/derived class”。在类型论中,子类通常是超类的子类型(subtype),子类的实例集合是超类实例集合的子集。
真正值得关注的是语法选择。C++/C# 用冒号:,Java 用extends,Python 用圆括号,Simula 甚至把超类名放在class关键字之前。到本章为止,jlox 的词法分析器并没有:或extends这些保留字,作者也不愿意为了一个特性新增 token,于是仿照 Ruby 采用了小于号<:
class Doughnut { // General doughnut stuff... } class BostonCream < Doughnut { // Boston Cream-specific stuff... }文法:给 classDecl 增加可选子句
<之后的超类名是可选的——Lox 不像 Java 那样存在根类Object,省略超类子句意味着该类没有任何超类,而非隐式继承某个根类。文法如下:
classDecl → "class" IDENTIFIER ( "<" IDENTIFIER )? "{" function* "}" ;AST 节点:超类名为什么用 Expr.Variable 而非 Token
与直觉相反,Stmt.Class中存储超类的字段类型是Expr.Variable而不是Token(见 Stmt.java#L35-L52):
static class Class extends Stmt { Class(Token name, Expr.Variable superclass, List<Stmt.Function> methods) { this.name = name; this.superclass = superclass; this.methods = methods; } final Token name; final Expr.Variable superclass; final List<Stmt.Function> methods; }原因在于:文法虽然把超类子句限制为单个标识符,但运行时该标识符会被当作一次变量访问来求值。在解析阶段就把它包进Expr.Variable,解析器(Resolver)就能在后续的变量解析过程中把“跳转距离”挂到这个节点上——这与普通变量访问共用同一套解析基础设施。
解析器:直接翻译文法
Parser.java 的 classDeclaration() 几乎逐行对应文法:读完类名后,若match(LESS)命中,就consume(IDENTIFIER, "Expect superclass name.")并构造Expr.Variable;超类子句缺省时superclass保持null,后续所有环节都必须显式判空:
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); }解析器:解析超类引用并拦截自继承
Resolver.visitClassStmt() 中新增了两处逻辑。其一是解析超类表达式本身:虽然类通常在顶层声明、超类名多为全局变量(此时解析往往“无事可做”),但 Lox 允许在块内声明类,因此超类名可能指向局部变量,必须保证它被正确解析:
if (stmt.superclass != null) { currentClass = ClassType.SUBCLASS; resolve(stmt.superclass); }其二是一个必须静态拦截的边界情况——类不能继承自身:
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."); }对应测试 test/class/inherit_self.lox 专门覆盖了这一场景。
解释器:运行时校验“超类必须是类”
AST 经过解析后进入求值阶段。Interpreter.visitClassStmt() 首先对超类表达式求值,由于求值结果可能是任意对象,必须做运行时类型检查:
var NotAClass = "I am totally not a class"; class Subclass < NotAClass {} // ?!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."); } }该行为有明确的测试证据:test/inheritance/inherit_from_nil.lox 期望输出Superclass must be a class.运行时错误;同目录下的 inherit_from_number.lox 与 inherit_from_function.lox 分别验证了把数字、函数当作超类时同样会被拒绝。
检查通过后,超类被传入LoxClass构造函数。先通过environment.define(stmt.name.lexeme, null)预声明类名(这是 Lox 支持类内部引用自身/递归的关键步骤),再在 LoxClass.java#L29-L36 中把超类存入superclass字段:
final LoxClass superclass; // LoxClass.java#L14-L16 LoxClass(String name, LoxClass superclass, Map<String, LoxFunction> methods) { this.superclass = superclass; this.name = name; this.methods = methods; }至此,“子类拥有超类”这一关系已经建立,接下来要回答的问题是:拥有超类之后,类能获得什么能力?
继承方法:三行 Java 代码的查找链
动态类型语言对继承的要求比静态类型语言宽松得多:不必保证子类型关系与内存布局,核心语义只有一条——能在超类实例上调用的方法,也能在子类实例上调用,即方法沿继承链向上查找。实现它只需要改动LoxClass.findMethod()一处(LoxClass.java#L38-L52):
LoxFunction findMethod(String name) { if (methods.containsKey(name)) { return methods.get(name); } if (superclass != null) { return superclass.findMethod(name); } return null; }逻辑一目了然:先在当前类的方法表中查找,找不到就递归到超类。由于超类本身也是LoxClass,只要继承链无环,递归必然终止。配合 test/inheritance/inherit_methods.lox 可以完整验证三种行为:
class Foo { methodOnFoo() { print "foo"; } override() { print "foo"; } } class Bar < Foo { methodOnBar() { print "bar"; } override() { print "bar"; } } var bar = Bar(); bar.methodOnFoo(); // expect: foo bar.methodOnBar(); // expect: bar bar.override(); // expect: barmethodOnFoo()在Bar自身找不到,便沿着超类链在Foo中找到(继承);methodOnBar()是子类独有方法;override()在两个类中都存在,查找从继承链底端开始、子类优先,因此打印bar(覆盖)。test/inheritance/set_fields_from_base_class.lox 和 test/inheritance/constructor.lox 则分别验证了字段与构造器在继承场景下的行为。作者幽默地指出:仅这三行代码,就完成了本章一半的继承特性。
调用超类方法:super 表达式的语法与语义
为什么需要 super
findMethod()从继承链底端向上查找,子类方法覆盖(override)超类同名方法——类似内层作用域变量遮蔽外层变量。如果子类想彻底替换超类行为,覆盖机制已经足够;但实践中更常见的是精炼(refine):在超类行为之外追加子类特有的逻辑。由于同名方法已被覆盖,子类方法若按名字调用,只会递归地命中自己的覆盖版本,无法触达原始实现。因此需要一种语法表达“忽略我的覆盖,直接从我的超类上查找这个方法”,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();运行输出(可在 test/super/call_same_method.lox 中看到同样结构的期望输出):
Fry until golden brown. Pipe full of custard and coat with chocolate.语法:super 表达式不可独立存在
与this不同,this像一个魔法变量、是孤零零的单个 token;而super后续的.与属性名是表达式的不可分割部分,裸写print super;是语法错误。文法上,新的子句加入primary规则,把属性访问一并包含:
primary → "true" | "false" | "nil" | "this" | NUMBER | STRING | IDENTIFIER | "(" expression ")" | "super" "." IDENTIFIER ;同时,和普通方法一样,参数列表不属于表达式本身——super.cook()是一个super访问接一个函数调用,因此你可以先取到方法句柄再单独调用:
var method = super.cook; method();对应的 AST 节点只包含super关键字与要查找的方法名(Expr.java#L155-L170):
static class Super extends Expr { Super(Token keyword, Token method) { ... } final Token keyword; final Token method; }解析代码位于 Parser.java 的 primary() 内,命中super后依次消费.与方法名:
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); }语义:从“哪个超类”开始查找
最直观的答案是“this的超类”,但它恰好只在一部分场景下正确。关键在于——查找起点应是包含该super表达式的那个类的超类,而非调用对象的超类。作者给出了经典的反例:
class A { method() { print "A method"; } } class B < A { method() { print "B method"; } test() { super.method(); } } class C < B {} C().test();程序运行到test()内部时,this是C的实例,C的超类是B;但如果从这里开始查找,会命中B.method(),打印B method。而 Java/C#/C++ 乃至 Lox 期望的结果都是A method。正确起点是定义test()的类B的超类A:因为test()定义在B内部,其中的super表达式应从B的超类A开始查找。
这带来一个实现难题:求值super表达式时,解释器手头并没有“当前方法所属的类”这一信息。一个直白但笨重的方案是给LoxFunction加字段保存所属LoxClass,再维护“当前正在执行的函数”指针——管道冗长。作者借鉴上一章绑定this的思路,改用环境与闭包机制,但有一处关键差异:this在方法每次被访问时绑定(同一方法可被不同实例调用,各自需要独立的this);而super的超类是类声明本身的固定属性,每次求值super表达式结果都相同。
因此可以“在类定义执行的那一刻”一次性创建超类环境:在执行类声明、定义各方法之前,新建一个环境并把该类的超类绑定到名字super上;随后创建的每个LoxFunction都会把这个环境捕获进闭包;方法被调用并绑定this时,超类环境就成为方法环境的父级,环境链如下:
解析器侧:为 super 建立作用域
Resolver.visitClassStmt() 中,若类声明带超类,则在解析其所有方法之前压入一个新作用域,并把super标记为已定义;方法解析完毕后弹出该作用域(Resolver.java#L125-L127):
if (stmt.superclass != null) { beginScope(); scopes.peek().put("super", true); } // ...解析 methods... if (stmt.superclass != null) endScope();只有确实存在超类时才创建该作用域,这是个小优化——没有超类就没有可存放的对象。
有了作用域,visitSuperExpr() 把super当作普通变量来解析,通过resolveLocal(expr, expr.keyword)记录环境链跳数:
@Override 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; }解释器侧:运行时创建超类环境
求值子类声明时(Interpreter.java#L128-L160),解释器新建一个以当前环境为父级的环境,把真正的超类LoxClass对象绑定到super名;随后创建的每个方法LoxFunction都会捕获当前环境作为闭包,从而“携带”超类;方法创建完毕再弹出该环境:
if (stmt.superclass != null) { environment = new Environment(environment); environment.define("super", superclass); } // ...为每个方法创建 LoxFunction... LoxClass klass = new LoxClass(stmt.name.lexeme, (LoxClass)superclass, methods); if (superclass != null) { environment = environment.enclosing; }求值 super 表达式:一次查找、绑定 this、调用
Interpreter.visitSuperExpr() 分三步完成:
- 取出超类:根据解析器记录的跳数
distance,从环境中取得super绑定的LoxClass。 - 取出
this:super.cook中实例必须是当前对象this——即便方法在超类上查找,实例仍是this。麻烦在于super表达式节点上没有可供解析器挂接this跳数的位置;幸好环境链布局由解释器自己控制:绑定this的环境总是紧贴在绑定super的环境内侧,所以把距离减一即可:
int distance = locals.get(expr); LoxClass superclass = (LoxClass)environment.getAt( distance, "super"); LoxInstance object = (LoxInstance)environment.getAt( distance - 1, "this");作者坦言这段代码不算优雅,但“写一本书就要展示每一行代码,没法把 hack 藏进‘留给读者的练习’”。
- 查找并绑定方法:与普通 get 表达式几乎一致,唯一区别是
findMethod()作用于超类而非this的类;找不到时抛出Undefined property运行时错误;最后method.bind(object)把this绑定进方法闭包(LoxFunction.bind()),返回可调用的绑定方法:
LoxFunction method = superclass.findMethod(expr.method.lexeme); if (method == null) { throw new RuntimeError(expr.method, "Undefined property '" + expr.method.lexeme + "'."); } return method.bind(object);test/super 目录下近二十个测试全面覆盖了super的各种合法与非法场景,例如 call_same_method.lox(子类覆盖后经super调回超类)、call_other_method.lox(调用超类中非同名的方法)、this_in_superclass_method.lox(超类方法内使用this)等。
拦截 super 的非法使用:把错误提前到静态阶段
Lox 虽是动态类型语言,但“尽早帮用户发现错误”仍值得做。以下两类super用法按当前实现会在运行时崩溃——例如无超类的类中使用super时,运行时假定super已被解析成功并能在环境中找到,但根本没有包围它的超类环境,JVM 会抛出异常击穿解释器:
class Eclair { cook() { super.cook(); print "Pipe full of crème pâtissière."; } }更简单的病态用法:
super.notEvenInAClass();这两者都能仅凭源码静态判定。做法是在 Resolver 中扩展类类型枚举,新增SUBCLASS区分“带超类的类”与“不带超类的类”(Resolver.java#L38-L47):
private enum ClassType { NONE, CLASS, SUBCLASS }解析类声明时,若该类是子类则置为SUBCLASS(Resolver.java#L89-L91);解析super表达式时检查当前类类型,越界即报错(Resolver.java#L282-L291):
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."); }这一小段错误处理,也是完成 jlox 的最后一块代码。
结语:Part II 的里程碑
至此,纯 Java 实现的 Lox 解释器(jlox)宣告完成。回望整个 Part II,从扫描器(token 与词法分析)、抽象语法树、递归下降解析器、前缀/中缀表达式、对象的运行时表示、基于 Visitor 模式的求值、词法作用域与环境链、控制流、带参数的函数、闭包、静态变量解析与错误检测,到类、构造器、字段、方法,最终以继承收尾——全部从零实现,零外部依赖,只靠 JDK 标准库的少量集合类与 JVM 运行时。
在仓库中可以直观对照这套实现的完整脉络:Scanner.java、Parser.java、Resolver.java、Interpreter.java,以及支撑对象模型的 LoxClass.java、LoxInstance.java、LoxFunction.java。阅读 java/com/craftinginterpreters/tool/GenerateAst.java 还能看到Expr.Super、Stmt.Class等节点如何自动生成。
Part II 结束并非全书终点——Part III 将用 C 语言实现一个字节码虚拟机 clox。休息片刻、用你的 jlox 写几个有趣的 Lox 程序后,就可以踏上下一段冒险了。
延伸思考:三类挑战
本章末尾给出了三道开放挑战,恰好可以检验对继承语义的理解深度:
更自由的代码复用机制:Lox 只支持单继承,这是唯一的跨类复用手段。若要在 mixins、traits、多继承、虚继承、扩展方法等方案中为 Lox 挑选一种并落地实现,你会选哪种、为什么?
BETA 的
inner反向语义:Lox(以及大多数 OO 语言)从继承链底端向上查找、子类优先,用super触达超类方法;而 BETA 语言从顶端向下查找、超类优先,超类方法通过inner调用链上下一个子类方法,由超类控制子类何时何地精炼行为。若把 Lox 的覆盖与super换成 BETA 语义,即调用方法时优先继承链上最高的方法、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.。
- 补上你心心念念的语言特性:在 the-lox-language.md 的挑战中,你曾设想 Lox 缺少的特性;如今你已掌握解释器构建的全流程,不妨真正实现其中一个。
- 编程语言
- 解释器
- 编译器
- 语言运行时
- 教程
【免费下载链接】craftinginterpreters
Repository for the book "Crafting Interpreters"
相关推荐
现代JavaScript继承完全指南:掌握extends与super的终极技巧
现代JavaScript继承完全指南:掌握extends与super的终极技巧 现代JavaScript(ES6及以上)引入了类(class)语法,让面向对象编
Traceur Compiler类继承编译:原型链与super关键字实现
Traceur Compiler类继承编译:原型链与super关键字实现 编译原理概述 Traceur Compiler(TC)通过将ES6+类语法转换为ES5
编译器开发工具Slang 编译器 AST 表达式参考:从 Expr 继承体系到解析与类型检查的完整指南
Slang 编译器 AST 表达式参考:从 Expr 继承体系到解析与类型检查的完整指南 本篇技术指南以 Slang 开源编译器仓库中的表达式 AST(抽象语法
编译器图形学编程语言
创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考