Skip to main content

第 7 章:求值表达式(Evaluating Expressions)

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

人是一个由器官构成的消化系统,外覆皮肤。

Aristophanes

原书插图:闪电击中维多利亚式宅邸。

前面已经能扫描源代码、解析表达式并构造 AST。现在终于让树运行起来:解释器按树结构递归求值,并产生 Lox 的运行时值。本章实现的语言仍只包含表达式,但它已经是一个货真价实的解释器。

7.1 表示值(Representing Values)

Lox 中的值由字面量创建、由表达式计算、由变量保存。用户看到的是 Lox 对象,而解释器必须在 Java 的静态类型与 Lox 的动态类型之间搭桥:同一个 Lox 变量可在不同时间保存不同类型的值。Lox 有四类内建值:数字、字符串、布尔值和 nil。Java 已恰好提供相应表示:DoubleStringBooleannull。最简单的做法是用 Object 表示任意 Lox 值。

Lox 类型Java 表示
任意 Lox 值Object
nilnull
布尔值Boolean
数字Double
字符串String

借助 instanceof,我们能在运行时辨认 Object 的具体种类。Java 的装箱对象与垃圾收集正好满足目前的需要;函数、类和实例出现后会再加入自己的 Lox 对象。这并非最终实现的唯一选择。静态语言实现动态语言时,常用带标签的联合(tagged union)、专门 Value 基类或带类型标签的结构;但 Java 的装箱对象足以让我们专注于解释器逻辑。代价是每次取用值都要检查和转换类型。

7.2 求值表达式(Evaluating Expressions)

每种表达式节点都需要对应的求值代码。可以把 interpret() 塞进 AST 节点,这便是 GoF 的解释器模式;但随着功能增加,树类会混入太多无关逻辑。因此复用上一章的访问者模式:AstPrinter 递归遍历树并返回字符串,解释器做的几乎相同,只是返回计算出的值。

解释器实现 AST 的访问者接口,访问者结果类型是 Object

package com.craftinginterpreters.lox;

class Interpreter implements Expr.Visitor<Object> {
private Object evaluate(Expr expr) {
return expr.accept(this);
}
}

evaluate() 是递归核心:对某个节点调用 accept(this),由双重分派进入对应的 visit...Expr()。每种节点的实现直接对应其语义。

7.2.1 求值字面量(Evaluating Literals)

字面量是产生值的一段语法,必须出现于源代码;计算得到的值则未必在源码中出现。扫描器已急切地将字面量转换为运行时对象,解析器再把它放进字面量节点,因此求值时直接取回即可:

@Override
public Object visitLiteralExpr(Expr.Literal expr) {
return expr.value;
}

7.2.2 求值圆括号(Evaluating Parentheses)

圆括号仅改变语法分组,不改变内部表达式的运行时含义。因此分组节点只对内容再次求值:

@Override
public Object visitGroupingExpr(Expr.Grouping expr) {
return evaluate(expr.expression);
}

有些解析器不会为括号创建节点,而直接返回内部表达式。Lox 保留它,是为了以后正确处理赋值表达式的左侧。

7.2.3 求值一元表达式(Evaluating Unary Expressions)

先求值右操作数,再依据运算符处理:

@Override
public Object visitUnaryExpr(Expr.Unary expr) {
Object right = evaluate(expr.right);

switch (expr.operator.type) {
case BANG:
return !isTruthy(right);
case MINUS:
checkNumberOperand(expr.operator, right);
return -(double) right;
}

return null; // 不可达。
}

- 要求操作数为数字;! 则依赖语言对“真”与“假”的定义。子节点先于自身求值,所以解释器进行的是后序遍历(post-order traversal)。

原书插图:被取负的一块松饼。

7.2.4 真值与假值(Truthiness and Falsiness)

Lox 规定 falsenil 为假,其他一切值为真:

private boolean isTruthy(Object object) {
if (object == null) return false;
if (object instanceof Boolean) return (boolean) object;
return true;
}

因此数字 0 和空字符串 "" 都是真。这是语言设计选择,不是 Java 行为。JavaScript 将空字符串和 0 视为假,但空数组仍为真;Python 会将空序列视为假;PHP 又把字符串 "0" 视为假。动态语言常用“真值性”简化条件表达式,但各语言的具体规则不同。

7.2.5 求值二元运算符(Evaluating Binary Operators)

二元表达式先从左到右求值左、右子树,再按运算符执行。操作数的求值顺序在存在副作用时对用户可见,因此属于语言语义,而非实现细节。所有数字运算先使用辅助函数验证类型:

@Override
public Object visitBinaryExpr(Expr.Binary expr) {
Object left = evaluate(expr.left);
Object right = evaluate(expr.right);

switch (expr.operator.type) {
case GREATER:
checkNumberOperands(expr.operator, left, right);
return (double) left > (double) right;
case GREATER_EQUAL:
checkNumberOperands(expr.operator, left, right);
return (double) left >= (double) right;
case LESS:
checkNumberOperands(expr.operator, left, right);
return (double) left < (double) right;
case LESS_EQUAL:
checkNumberOperands(expr.operator, left, right);
return (double) left <= (double) right;
case MINUS:
checkNumberOperands(expr.operator, left, right);
return (double) left - (double) right;
case SLASH:
checkNumberOperands(expr.operator, left, right);
return (double) left / (double) right;
case STAR:
checkNumberOperands(expr.operator, left, right);
return (double) left * (double) right;
case PLUS:
if (left instanceof Double && right instanceof Double) {
return (double) left + (double) right;
}
if (left instanceof String && right instanceof String) {
return (String) left + (String) right;
}
throw new RuntimeError(expr.operator,
"Operands must be two numbers or two strings.");
case BANG_EQUAL:
return !isEqual(left, right);
case EQUAL_EQUAL:
return isEqual(left, right);
}

return null;
}

Lox 的 + 允许两个数字相加或两个字符串拼接,但不做隐式数字/字符串转换;这能避免许多动态语言中难以预测的行为。

相等性对所有类型有效:

private boolean isEqual(Object a, Object b) {
if (a == null && b == null) return true;
if (a == null) return false;
return a.equals(b);
}

两个 nil 相等;一个 nil 与非 nil 不等;其余值借助 Java 的 equals() 比较。Java 的 Double.equals() 与 IEEE 754 原始双精度 ==NaN 上有所不同:Lox 因此令 (0 / 0) == (0 / 0) 为真。辅助检查函数把重复的错误逻辑集中起来:

private void checkNumberOperand(Token operator, Object operand) {
if (operand instanceof Double) return;
throw new RuntimeError(operator, "Operand must be a number.");
}

private void checkNumberOperands(
Token operator, Object left, Object right) {
if (left instanceof Double && right instanceof Double) return;
throw new RuntimeError(operator, "Operands must be numbers.");
}

7.3 运行时错误(Runtime Errors)

解析器能发现缺少括号、错误记号等语法错误,但 "muffin" - 3 的问题要等执行时才知道。这是运行时错误。它与 Java 的意外异常不同:运行时错误是 Lox 程序的正常失败模式,应指向 Lox 源码位置并让解释器干净退出。C 允许将指针解释为不匹配的类型,换取灵活性与速度,却可能误读内存;现代语言通常通过静态和运行时检查维护内存安全。

package com.craftinginterpreters.lox;

class RuntimeError extends RuntimeException {
final Token token;

RuntimeError(Token token, String message) {
super(message);
this.token = token;
}
}

7.3.1 检测运行时错误(Detecting Runtime Errors)

每个类型断言失败时抛出 RuntimeError,携带触发操作的记号。由于表达式节点保存运算符 Token,错误能报告正确行号。不要让 Java 的 ClassCastException 泄漏给用户:它既缺少 Lox 源码位置,也暴露了实现语言细节。异常会展开递归调用栈,停止包含该错误的整棵表达式,但 REPL 中的解释器仍可继续接受下一行。

7.4 接入解释器(Hooking Up the Interpreter)

解释器对外只暴露一个入口。它求值、将结果转换为 Lox 的文本表示并打印;运行时错误则在此处捕获:

void interpret(Expr expression) {
try {
Object value = evaluate(expression);
System.out.println(stringify(value));
} catch (RuntimeError error) {
Lox.runtimeError(error);
}
}

stringify() 把 Java 的 null 显示成 nil,并删除整数值 Double 文本末尾的 .0,确保 jlox 和后续 clox 的可见行为一致:

private String stringify(Object object) {
if (object == null) return "nil";

if (object instanceof Double) {
String text = object.toString();
if (text.endsWith(".0")) {
text = text.substring(0, text.length() - 2);
}
return text;
}

return object.toString();
}

7.4.1 报告运行时错误(Reporting Runtime Errors)

Lox 增加专门的报告函数:

static void runtimeError(RuntimeError error) {
System.err.println(error.getMessage() + "\n[line " + error.token.line + "]");
hadRuntimeError = true;
}

并添加运行时错误标记:

static boolean hadRuntimeError = false;

从文件执行时,语法错误用退出码 65,运行时错误用 70

run(new String(bytes, Charset.defaultCharset()));

if (hadError) System.exit(65);
if (hadRuntimeError) System.exit(70);

在 REPL 中,报告后直接进入下一轮输入;运行时错误不应终止整个交互会话。

7.4.2 运行解释器(Running the Interpreter)

最后,把原先的 AST 打印器替换为解释器:

private static final Interpreter interpreter = new Interpreter();

private static void run(String source) {
Scanner scanner = new Scanner(source);
List<Token> tokens = scanner.scanTokens();

Parser parser = new Parser(tokens);
Expr expression = parser.parse();

if (hadError) return;

interpreter.interpret(expression);
}

解释器字段设为静态,使 REPL 的多次 run() 复用同一解释器;这在稍后的全局变量出现后尤为重要。现在已经有扫描、解析和执行的完整管线,虽仍只是算术计算器,却已具备后续变量、函数等功能所依赖的骨架。

原书插图:向你打招呼的骷髅。

挑战(Challenges)

  1. 是否应允许数字以外的类型参与比较?选择可比较的类型对,并定义排序规则;说明理由,并与其他语言比较。
  2. + 在任一操作数为字符串时把另一侧转换为字符串后拼接,例如 "scone" + 4 得到 scone4
  3. 观察数字除以零目前的行为,决定 Lox 应采用什么规则,并修改 visitBinaryExpr():遇到此情形报告运行时错误。

设计笔记:静态类型与动态类型(Static and Dynamic Typing)

“静态类型”与“动态类型”常被误解为语言是否有类型。所有值都有某种分类;真正差异在于类型检查在何时发生、程序员要显式写多少类型信息、编译器能在运行前证明什么。

静态类型系统在执行前检查程序,能更早发现大量错误,并为优化、重构工具和 API 文档提供强大信息。它也会迫使设计者面对类型推断、泛型、多态、子类型和错误消息等复杂问题。动态类型把许多检查延迟到运行时,能更灵活地组合值、快速实验,也让语言实现和表面语法更小;代价是某些错误更晚出现。

二者并非绝对分界。Java 的向下转型在编译时被允许,却在运行时检查;协变数组也如此:Object[] stuff = new Integer[1]; 允许通过静态检查,但写入字符串时 JVM 必须抛出 ArrayStoreException。若把数组改为不变类型就可消除该检查,却会禁止只读数组等常见安全用法。许多静态语言都在某处用运行时检查换取灵活性;延迟得过多,则会削弱用户对静态保证的信心。

这不是“安全”与“不安全”的二元对立。静态语言仍有运行时错误,动态语言也能通过测试、契约、静态分析和良好 API 获得高可靠性。更重要的问题是:语言的用户需要哪些保证,愿意承担哪些注解和限制,又希望在何时获得反馈。Lox 选择动态类型,是为了将注意力放在解释器其余部分;Java 和 C 两个宿主语言则展示了静态类型的另一侧价值。