第 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 已恰好提供相应表示:Double、String、Boolean 与 null。最简单的做法是用 Object 表示任意 Lox 值。
| Lox 类型 | Java 表示 |
|---|---|
| 任意 Lox 值 | Object |
nil | null |
| 布尔值 | 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 规定 false 与 nil 为假,其他一切值为真:
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)
- 是否应允许数字以外的类型参与比较?选择可比较的类型对,并定义排序规则;说明理由,并与其他语言比较。
- 让
+在任一操作数为字符串时把另一侧转换为字符串后拼接,例如"scone" + 4得到scone4。 - 观察数字除以零目前的行为,决定 Lox 应采用什么规则,并修改
visitBinaryExpr():遇到此情形报告运行时错误。
设计笔记:静态类型与动态类型(Static and Dynamic Typing)
“静态类型”与“动态类型”常被误解为语言是否有类型。所有值都有某种分类;真正差异在于类型检查在何时发生、程序员要显式写多少类型信息、编译器能在运行前证明什么。
静态类型系统在执行前检查程序,能更早发现大量错误,并为优化、重构工具和 API 文档提供强大信息。它也会迫使设计者面对类型推断、泛型、多态、子类型和错误消息等复杂问题。动态类型把许多检查延迟到运行时,能更灵活地组合值、快速实验,也让语言实现和表面语法更小;代价是某些错误更晚出现。
二者并非绝对分界。Java 的向下转型在编译时被允许,却在运行时检查;协变数组也如此:Object[] stuff = new Integer[1]; 允许通过静态检查,但写入字符串时 JVM 必须抛出 ArrayStoreException。若把数组改为不变类型就可消除该检查,却会禁止只读数组等常见安全用法。许多静态语言都在某处用运行时检查换取灵活性;延迟得过多,则会削弱用户对静态保证的信心。
这不是“安全”与“不安全”的二元对立。静态语言仍有运行时错误,动态语言也能通过测试、契约、静态分析和良好 API 获得高可靠性。更重要的问题是:语言的用户需要哪些保证,愿意承担哪些注解和限制,又希望在何时获得反馈。Lox 选择动态类型,是为了将注意力放在解释器其余部分;Java 和 C 两个宿主语言则展示了静态类型的另一侧价值。