plox 实现简介

10 min

使用 GPT-5 生成,本人做了小幅度改动。

GitHub: https://github.com/KinnariyaMamaTanha/plox

本项目是用 Python 实现的 Lox 语言解释器(来源于《Crafting Interpreters》),但在工程组织、静态解析与运行时设计上做了更贴合 Python 的细化。

整体思路与执行流程

解释器采用典型的多阶段流水线:扫描(词法)→ 解析(语法)→ 语义解析(作用域/解析距离)→ 执行(解释)。命令行既支持执行脚本,也支持带历史与多行输入的 REPL。

顶层入口位于 plox.pyrun()

scanner = Scanner(source)
tokens = scanner.scan_tokens()
parser = Parser(tokens)
statements = parser.parse()
if error.has_error: return

_interpreter = interpreter or Interpreter()
_interpreter.locals.clear()
resolver = Resolver(_interpreter)
resolver.resolve(statements)
if error.has_error: return

_interpreter.interpret(statements)

这段展示了编译期错误(扫描/解析/解析器)与运行期错误(解释执行)的分层处理,也为 REPL 复用同一个 Interpreter 保留了空间(从而实现同一会话内变量与函数的持久化)。

AST 与访问者模式

语法树使用 dataclass 建模在 src/lox/expr.pysrc/lox/stmt.py 中,并通过 accept(self, visitor)visitor 双分派实现扩展。比如表达式:

@dataclass(eq=False)
class Binary(Expr):
    left: Expr
    op: Token
    right: Expr
    def accept(self, visitor: ExprVisitor):
        return visitor.visit_binary(self)

这让不同“阶段”(如 AST 打印器、语义解析器、解释器)都能以独立的 Visitor 实现,彼此解耦。例如 ast_printer.py 专注结构可视化,resolver.py 专注作用域与闭包分析,interpreter.py 专注运行时语义。

Visitor Pattern

这是我在写 plox 的时候收获最大的一个地方。在传统的 OOP 模式下,每次新增加一种方法,都需要找到对应多个(子)类的相关源码进行修改。也就是说,每次增加功能都会导致类的源码被修改。这显然违背了软件设计中的“开闭原则”(对扩展开放,对修改关闭)。而访问者模式巧妙地解决了这个问题。它引入了一个“访问者”的角色,这个访问者专门负责对对象结构中的每个元素执行特定的操作。由此,实现一个新的功能只需要新写一个 visitor 类即可:被访问类本身保持稳定,无需任何修改,而新的操作可以作为新的访问者类被轻松地添加进来。

在 plox 中,则体现为对 ExprStmt 这两个类的数十个子类,为了实现 parse, resolve 以及 interpret 的功能,我们并不需要每次都修改这些子类,而是新定义 Parser, Resolver, Interpreter 这三个类,对后两者而言继承父类 ExprVisitorStmtVistior,实现若干 visit_xxx 功能即可。

在此视角下,ExprStmt 这两个类的数十个子类可以被看作是不同类型的数据,或者说,节点(node)。它们抛出可以 accept Visitor 抽象类的方法,将自己的一切 fields(甚至还可以有 methods)交给一个 Visitor 处理。node 决定了有哪些形式的数据可以利用,而 visitor 则决定了如何利用这些数据。

解析器与语法糖

Parser 采用递归下降法实现 Lox 的表达式与语句子集,同时做了两点实用处理:

  • for 循环被语法糖化为 var/init + while + increment 的组合,生成更基础的 AST,解释器无需关心语法糖细节。
  • break/continue 通过 loop_depth 做静态检查,防止在循环外使用;在运行时通过轻量异常传递到最近一层循环;return 同理用于从函数体中携带返回值的“非本地跳转”。这种做法让解释器主循环保持整洁,同时在 Python 上有非常直接的实现路径。命令行执行文件时,若发生编译期错误会以退出码 65 结束,运行期错误则以退出码 70 结束,便于外部脚本感知失败类型。

另外,Scanner 使用 Python 的 match/case 清晰映射字符到 Token 类型,并内置关键字表(含 break/continuethis/super 等)。

作用域解析与“距离”

Resolver 是一大亮点:它在执行前对每个变量引用计算其与声明点的“作用域距离”,并记录到解释器的 locals: Dict[Expr, int] 中。解释器取值时可以 O(1) 定位到正确的嵌套环境,避免运行时从外向内逐层搜寻。

解析的核心逻辑大致是:

# resolver.py
for i, scope in enumerate(reversed(self.scopes)):
    if name.lexeme in scope:
        self.interpreter.resolve(expr, i)  # 记录距离
        return

配合解释器侧的查询:

# interpreter.py
def lookup_variable(self, expr, name):
    distance = self.locals.get(expr)
    if distance is not None:
        return self.environment.get_at(distance, name.lexeme)
    return self.globals.get(name)

这套机制还支持 thissuper 的静态合法性检查与绑定,使类与继承的语义更健壮(例如禁止类从自身继承、禁止非子类中使用 super)。

运行时与闭包/类

运行时以 Environment 串起词法作用域链,Interpreter 以 Visitor 实现求值逻辑。函数、类与实例通过统一的 LoxCallable 协议抽象,统一了“可调用”与“参数个数(arity)”语义:

# 调用表达式
callee = self.evaluate(expr.callee)
arguments = [self.evaluate(a) for a in expr.arguments]
if not isinstance(callee, LoxCallable):
    raise PloxRuntimeError(expr.paren, "Can only call functions and classes.")
if len(arguments) != callee.arity():
    raise PloxRuntimeError(expr.paren, f"Expected {callee.arity()} but got {len(arguments)}.")
return callee(self, arguments)
  • 函数 LoxFunction 捕获定义时环境形成闭包,return 通过抛出 ReturnException 回传值;构造器 init 无论显式 return 与否都返回绑定的 this
  • LoxClass 自身也是 LoxCallable,调用它会创建实例并自动查找并执行 init;方法查找支持在原类和父类中逐层向上。
  • 原生函数以 Clock 为例被注入到全局环境,扩展新原生函数非常容易。

REPL 与工程化小细节

REPL 使用 prompt_toolkit 提供历史记录与良好交互;utils.is_complete_source() 基于括号/花括号平衡与末尾字符的简单启发式,帮助判断是否需要继续读取多行,体验友好。更重要的是 REPL 在同一 Interpreter 实例上运行,这意味着你在上一行定义的变量、函数和类,在下一行立即可用,贴近日常脚本探索。

错误处理方面,编译期与运行期各有独立的全局标志位与上报函数,命令行在执行文件时会基于这些标志以不同退出码结束,便于脚本化集成与测试。

一个小例子

下面这段 Lox 代码展示了类与继承、闭包、以及循环控制。你可以把它粘到 REPL(支持多行输入与历史),或保存为脚本运行。

fun makeCounter() {
    var i = 0;
    fun inc() {
        i = i + 1;
        return i;
    }
    return inc; // 返回闭包,捕获上层 i
}

class Animal {
    init(name) { this.name = name; }
    say() { print this.name; }
}

class Dog < Animal {
    say() {
        super.say();
        print "woof";
    }
}

var d = Dog("Pluto");
d.say(); // Pluto\nwoof

var next = makeCounter();
print next(); // 1
print next(); // 2

for (var j = 0; j < 3; j = j + 1) {
    if (j == 1) continue; // 跳过 1
    print j;
}

这段代码运行时,Resolver 会为诸如 thissuper、以及闭包中的 i 计算解析距离;解释器据此在 Environment 链上精确取值或赋值。构造 Dog("Pluto") 时,类作为可调用对象会创建实例并自动查找并调用 init,方法查找支持在父类中向上解析;循环中的 continue 则通过异常快速跳回条件判断,从而跳过当次循环体剩余语句。

总结

整体架构沿着“阶段清晰、关注点分离”的路线推进:扫描/解析/解析器/执行彼此独立,AST + Visitor Pattern 可以在任一阶段安全扩展能力;Resolver 的“距离”机制在保证静态合法性的同时又让运行期查找高效;控制流用异常传递实现 break/continue/return,实现简单且语义一目了然;类/函数/实例遵循统一的调用契约,新增原生能力只需实现 LoxCallable 并注册到全局环境。

当然这只是对 lox 语言的一个简单实现,如果想要继续扩展,可以考虑:添加更多原生函数(I/O、数据结构)、为数字/字符串引入更多内置方法、或在解析阶段支持新的语法糖;这些改动都能在现有模块清晰落位,不会相互牵连。