第 23 章:跳转来回(Jumping Back and Forth)
原文:Robert Nystrom, Crafting Interpreters, Chapter 23。原书以 CC BY-NC-SA 4.0 协议发布;本译文用于学习与研究。
心智所想象的秩序,如同一张为了抵达某物而搭起的网,或一架梯子;但事后必须丢掉梯子,因为你会发现它纵然有用,却并无意义。
Umberto Eco,《玫瑰之名》
终于可以为虚拟机加入控制流。在树遍历的 jlox 中,Lox 的 if 直接借助 Java 的 if 执行;能用,但不够令人满足。JVM 或原生 CPU 本身又如何实现 if?有了自己的字节码 VM,答案变得直接。
所谓控制流,是执行如何穿过程序文本。可把计算机想成有个小机器人在代码中行走、逐段执行;流就是它走的路径,控制机器人便控制哪些代码被执行。jlox 中当前位置隐含在 Java 变量里保存的 AST 节点和正在执行的 Java 代码中;clox 则明确得多,VM 的 ip 字段保存当前字节码指令地址,即程序“现在在哪里”。
通常执行通过递增 ip 前进;要实现控制流,只需以更有趣的方式改变它。最简单的是没有 else 的 if:
if (condition) print("condition was truthy");
条件为真时继续执行主体;为假时跳过主体。跳过一段代码就是令 ip 指向该段后第一条指令。条件跳转需要查看栈顶:假值时给 ip 加偏移量,真值时不做事。
编译成字节码后,源代码显式嵌套的块结构消失,只剩一条扁平指令序列。Lox 是结构化语言,clox 字节码却不是;错误的指令组合可以跳进块中间或从一个作用域跳到另一个作用域,VM 仍会执行,可能使栈处于未知且不一致的状态。因此虽字节码无结构,编译器必须只生成保持 Lox 原有嵌套结构的干净代码。现实 CPU 亦如此:高级语言的结构化控制流最终都会降成原始跳转,归根到底 goto 才是唯一真正的控制流。
23.1 if 语句(If Statements)
新增特性从前端一路接入管线。if 是语句,因此先在 statement() 中识别它:
if (match(TOKEN_PRINT)) {
printStatement();
} else if (match(TOKEN_IF)) {
ifStatement();
} else if (match(TOKEN_LEFT_BRACE)) {
/* ... */
}
if 后的左圆括号其实不提供语法歧义消解,右圆括号才用于分隔条件和主体;左括号主要是因为不配对的括号对人看起来别扭。初始的编译逻辑如下:
static void ifStatement() {
consume(TOKEN_LEFT_PAREN, "Expect '(' after 'if'.");
expression();
consume(TOKEN_RIGHT_PAREN, "Expect ')' after condition.");
int thenJump = emitJump(OP_JUMP_IF_FALSE);
statement();
patchJump(thenJump);
}
条件执行后,值在栈顶。OP_JUMP_IF_FALSE 应跳过后面的 then 主体,但在发出此指令时,还不知道主体会生成多少字节码。解决方法是经典的回填(backpatching):先写入带占位操作数的跳转,记录它的位置;编译完主体后,才能计算距离并回写占位符。


这两个工具函数将技巧编码起来:
static int emitJump(uint8_t instruction) {
emitByte(instruction);
emitByte(0xff);
emitByte(0xff);
return currentChunk()->count - 2;
}
static void patchJump(int offset) {
// -2 accounts for the jump offset's own two bytes.
int jump = currentChunk()->count - offset - 2;
if (jump > UINT16_MAX) error("Too much code to jump over.");
currentChunk()->code[offset] = (jump >> 8) & 0xff;
currentChunk()->code[offset + 1] = jump & 0xff;
}
跳转偏移使用两字节,可跨越至多 65,535 字节码;有些指令集会另设操作数更大的 long jump。emitJump() 返回操作数首字节的位置,而不是 opcode 的位置。patchJump() 应在即将发出目标位置的下一条指令前调用,因此以当前 chunk 长度计算距离。
新增 opcode:
OP_JUMP_IF_FALSE,
它是第一条含 16 位操作数的指令,故 VM 需要读取宏:
#define READ_SHORT() \
(vm.ip += 2, (uint16_t)((vm.ip[-2] << 8) | vm.ip[-1]))
case OP_JUMP_IF_FALSE: {
uint16_t offset = READ_SHORT();
if (isFalsey(peek(0))) vm.ip += offset;
break;
}
宏取接下来的两个字节并组合为无符号 16 位整数,解释结束时也应 #undef READ_SHORT。假值时 ip 前移,下一轮分派直接落在 then 主体之后;真值时 ip 保持不变,照常进入下一指令。这里虽然用了 C 的 if 来决定是否改变 ip,但 VM 的控制流也可算术化,例如 vm.ip += falsey() * offset。
注意条件跳转不弹出栈顶条件值,这会暂时留下多余值;很快清理它。运行时只用这一条新指令便已有可用的无 else 条件语句。
23.1.1 else 子句(Else clauses)
只在回填 then 跳转后检测并编译 else 是不够的:条件为假跳过 then 后会正确落到 else 开头,但条件为真执行完 then 会顺序落入 else,两个分支都会执行。

then 结束时还需无条件跳过 else:

static void ifStatement() {
consume(TOKEN_LEFT_PAREN, "Expect '(' after 'if'.");
expression();
consume(TOKEN_RIGHT_PAREN, "Expect ')' after condition.");
int thenJump = emitJump(OP_JUMP_IF_FALSE);
statement();
int elseJump = emitJump(OP_JUMP);
patchJump(thenJump);
if (match(TOKEN_ELSE)) statement();
patchJump(elseJump);
}
OP_JUMP 无条件应用偏移:
OP_JUMP,
case OP_JUMP: {
uint16_t offset = READ_SHORT();
vm.ip += offset;
break;
}
还要恢复每条语句的零栈效果。不能让 OP_JUMP_IF_FALSE 自己弹值,因为逻辑运算符也将使用它,并且需要保留值。因此由编译器为两个执行路径各发出一个 OP_POP:真路径在 then 之前弹出;假路径在 else 开头弹出。没有显式 else 时仍有一个隐式 else 分支,只做这次清理:
int thenJump = emitJump(OP_JUMP_IF_FALSE);
emitByte(OP_POP);
statement();
int elseJump = emitJump(OP_JUMP);
patchJump(thenJump);
emitByte(OP_POP);
if (match(TOKEN_ELSE)) statement();
patchJump(elseJump);

反汇编器要理解 16 位跳转:
static int jumpInstruction(const char* name, int sign,
Chunk* chunk, int offset) {
uint16_t jump = (uint16_t)(chunk->code[offset + 1] << 8);
jump |= chunk->code[offset + 2];
printf("%-16s %4d -> %d\n", name, offset, offset + 3 + sign * jump);
return offset + 3;
}
case OP_JUMP:
return jumpInstruction("OP_JUMP", 1, chunk, offset);
case OP_JUMP_IF_FALSE:
return jumpInstruction("OP_JUMP_IF_FALSE", 1, chunk, offset);
23.2 逻辑运算符(Logical Operators)
and 与 or 不是普通二元运算符:它们会短路,右操作数是否求值依赖左操作数,因此更像控制流表达式。
and 的解析规则和实现为:
[TOKEN_AND] = {NULL, and_, PREC_AND},
static void and_(bool canAssign) {
int endJump = emitJump(OP_JUMP_IF_FALSE);
emitByte(OP_POP);
parsePrecedence(PREC_AND);
patchJump(endJump);
}
调用它时左操作数已编译并位于栈顶。它是假值时,整个 and 必是假,跳过右操作数并保留左值作为结果;为真时,弹出左值、计算右值,让右值成为结果。于是 Lox 逻辑运算返回操作数本身,而不是强制转为布尔值。

这也解释了条件跳转为何不弹栈:假值左操作数必须留下。可以增加“跳转时隐式弹出”的专用指令,但本书刻意保持最小指令集;实践中的 VM 值得测量更专用指令带来的性能影响。
23.2.1 逻辑 or 运算符(Logical or operator)
or 左值为真时应跳过右操作数。虽可新设“为真跳转”指令,本书只用已有指令演示编译器可任意映射语言语义:
[TOKEN_OR] = {NULL, or_, PREC_OR},
static void or_(bool canAssign) {
int elseJump = emitJump(OP_JUMP_IF_FALSE);
int endJump = emitJump(OP_JUMP);
patchJump(elseJump);
emitByte(OP_POP);
parsePrecedence(PREC_OR);
patchJump(endJump);
}
左值为假时,条件跳转越过接下来的无条件跳转;无条件跳转则越过右操作数,因此组合起来等效于“真时跳转”。这种实现比 and 多分派指令、没有理由更慢,却展示了无需新增指令也能完成两种逻辑运算。

至此 Lox 有三种只向前跳过代码的分支构造:if、and、or。其他语言常有 switch 或 ?:,Lox 保持简单。
23.3 while 语句(While Statements)
循环通过向后跳转令代码重复执行。Lox 只有 while 与 for,先实现更简单的 while:
} else if (match(TOKEN_WHILE)) {
whileStatement();
}
编译形式大致复用 if:条件为假则跳出,两个路径均弹出条件;区别是主体后跳回条件之前:
static void whileStatement() {
int loopStart = currentChunk()->count;
consume(TOKEN_LEFT_PAREN, "Expect '(' after 'while'.");
expression();
consume(TOKEN_RIGHT_PAREN, "Expect ')' after condition.");
int exitJump = emitJump(OP_JUMP_IF_FALSE);
emitByte(OP_POP);
statement();
emitLoop(loopStart);
patchJump(exitJump);
emitByte(OP_POP);
}
loopStart 记录条件表达式前的字节码偏移,使每次迭代都重新求条件。反向跳转不需回填,因为目标已编译:
static void emitLoop(int loopStart) {
emitByte(OP_LOOP);
int offset = currentChunk()->count - loopStart + 2;
if (offset > UINT16_MAX) error("Loop body too large.");
emitByte((offset >> 8) & 0xff);
emitByte(offset & 0xff);
}
case OP_LOOP: {
uint16_t offset = READ_SHORT();
vm.ip -= offset;
break;
}
+ 2 计入 OP_LOOP 自己的两个操作数字节。语义上 OP_LOOP 与 OP_JUMP 都是调整 ip,本可使用含有符号 16 位偏移的同一条指令;但手工打包有符号值更麻烦,opcode 空间又充足,故分开。反汇编器以负号显示目标:
case OP_LOOP:
return jumpInstruction("OP_LOOP", -1, chunk, offset);

23.4 for 语句(For Statements)
来自 C 的 for 有三个可选子句:初始化器(只在开头执行一次,可为变量声明或表达式)、条件(假时退出)和增量表达式(每轮末尾执行一次)。jlox 曾将它降糖为带额外代码的 while AST;clox 没有 AST,直接用已有跳转指令实现。
先将 for 接入语句解析:
} else if (match(TOKEN_FOR)) {
forStatement();
} else if (match(TOKEN_IF)) {
ifStatement();
}
若只支持空子句 for (;;), 无穷循环仅需消费标点、记录主体前位置并在主体后回跳:
consume(TOKEN_LEFT_PAREN, "Expect '(' after 'for'.");
consume(TOKEN_SEMICOLON, "Expect ';'.");
int loopStart = currentChunk()->count;
consume(TOKEN_SEMICOLON, "Expect ';'.");
consume(TOKEN_RIGHT_PAREN, "Expect ')' after for clauses.");
statement();
emitLoop(loopStart);
目前没有 return,除运行时错误外还不能终止这种循环。
23.4.1 初始化器子句(Initializer clause)
初始化器只执行一次。它可以缺省、变量声明或表达式语句;表达式用 expressionStatement() 是因为它消费分号并发出 OP_POP,不让结果留在栈上。循环变量的作用域必须覆盖整个循环,故先开启、结束时关闭作用域:
static void forStatement() {
beginScope();
consume(TOKEN_LEFT_PAREN, "Expect '(' after 'for'.");
if (match(TOKEN_SEMICOLON)) {
// No initializer.
} else if (match(TOKEN_VAR)) {
varDeclaration();
} else {
expressionStatement();
}
int loopStart = currentChunk()->count;
/* condition, increment, body */
endScope();
}
23.4.2 条件子句(Condition clause)
条件同样可选。若下一个 token 不是分号,则编译条件、消费分号、发出条件假时离开的跳转;条件为真时进入主体前弹出它,循环结束后回填跳转并在假路径也弹出它:
int exitJump = -1;
if (!match(TOKEN_SEMICOLON)) {
expression();
consume(TOKEN_SEMICOLON, "Expect ';' after loop condition.");
exitJump = emitJump(OP_JUMP_IF_FALSE);
emitByte(OP_POP); // Condition.
}
/* body and its loop */
if (exitJump != -1) {
patchJump(exitJump);
emitByte(OP_POP); // Condition.
}
没有条件便没有跳转需要回填,也没有值需要清理。
23.4.3 增量子句(Increment clause)
增量最绕:文本上在主体之前,语义上却在主体之后。两遍编译或 AST 可先编译主体再编译增量;单遍编译器无法如此,只能先跳过增量、执行主体、反向跳到增量、执行它再回到下轮条件:
if (!match(TOKEN_RIGHT_PAREN)) {
int bodyJump = emitJump(OP_JUMP);
int incrementStart = currentChunk()->count;
expression();
emitByte(OP_POP);
consume(TOKEN_RIGHT_PAREN, "Expect ')' after for clauses.");
emitLoop(loopStart);
loopStart = incrementStart;
patchJump(bodyJump);
}
statement();
emitLoop(loopStart);
先发出的无条件跳转令首次进入绕过增量而落到主体;增量表达式通常为赋值,其值只为副作用而被弹出。增量结尾的回跳前往循环开头(条件之前);随后把 loopStart 改为增量起点,主体结尾的回跳便先执行增量。线性字节码次序虽与源代码不同,跳转恢复了语义。

和 jlox 一样,for 不需要新的运行时支持,全部降为原始控制流。至此 clox 已图灵完备;新增三个语句和两个表达式形式,却只需三条简单新指令,投入与收获相当可观。
挑战(Challenges)
-
为
clox加入多路switch。语法如下;逐个比较 case 值,命中后执行其语句并退出,不支持 fallthrough 与break:switchStmt -> "switch" "(" expression ")""{" switchCase* defaultCase? "}" ;switchCase -> "case" expression ":" statement* ;defaultCase -> "default" ":" statement* ; -
加入
continueStmt -> "continue" ";" ;。它跳至最近循环的开头;在for中应跳至增量子句(若有)。循环外使用是编译错误。思考跳过循环主体或嵌套块时,已声明局部变量应如何清理。 -
控制流自 Algol 68 以来基本未变;语言演化更重视声明式和高层结构。尝试为 Lox 发明一个有用的新控制流特性,可以改进已有形式或全新设计,并权衡其收益与学习新语法行为的成本。
设计笔记:重新审视 goto 的危害(Considering Goto Harmful)
发现 Lox 漂亮的结构化控制流最终编译为无结构跳转,就像揭下面具后才发现一直是 goto。大家都知道 goto 邪恶,但为什么?它确实能写出极难维护的代码,不过如今少有人亲眼见到这种风格;它更像围在篝火旁讲的吓人传说。
Edsger Dijkstra 1968 年 3 月在 Communications of the ACM 发表的著名短文《Go To Statement Considered Harmful》有效终结了长期激烈的结构化编程争论,今日多数新语言都没有无结构跳转。这篇不足两页的文章几乎摧毁了一项语言特性,值得阅读:它既是计算机科学史上的经典,也是练习阅读学术 CS 文体的好材料,尽管 Dijkstra 的故作谦逊式自我褒扬颇令人不耐。
作者读过原文和一些评论后态度复杂。他赞同其高层论证:程序员书写的是静态文本,关心的却是运行程序的动态行为;人比起动态事物更擅长推理静态事物;因而动态执行越能反映文本结构越好。这个切分本身富有洞见,但 Dijkstra 对“对应关系”的定义很松散。其问题可以理解为:两台机器以相同输入运行确定性程序,任意时刻暂停其中一台,需要传给另一台什么数据,才能让它准确暂停在同一执行进度?
只有赋值等简单语句时,只需知道最近执行语句之后的位置,即断点、VM 的 ip 或报错行号;if、switch 等分支不增加额外信息。加入函数调用后,当前位置还不够:函数可从多处调用,必须知道尚未返回调用的调用点,也就是调用栈。循环又要求迭代计数,嵌套循环则需一栈计数器。
此处 Dijkstra 的结论是,毫无限制的 goto 让人难以找到描述执行进度的有意义坐标;用“自启动以来执行的动作数”虽唯一,却毫无帮助。作者认为论证没有证明为何困难,而循环计数本质上也类似这种计数。以 clox 的字节码分派循环为例,知道它执行了 6,201 条用户字节码,对 VM 维护者未必有什么启发。
Böhm 和 Jacopini 证明任意 goto 控制流都可转换为仅含顺序、循环和分支的控制流;clox 的解释器循环正是活例子:它用结构化 C 控制流解释无结构字节码。似乎可以反驳 Dijkstra:先转换掉 goto,再使用结构化程序的对应关系。但作者也承认这同样是薄弱论证,二人都用貌似数学的推理处理本应经验性、以人为中心的问题。
goto 确会产生糟糕代码,许多地方应换成清晰的结构化控制流;彻底取消它,确实可以防止写出那类坏代码,强制用户使用结构化形式或许整体提高生产力。但也可能连同有用能力一起丢掉。缺少 goto 时,人们常用更复杂的结构化模式,例如在嵌套循环中设置 found 守卫变量、逐层 break 来寻找矩阵中的零;一条跳至结束标签的跳转有时反而更直白。break 本身也是受限的 goto 式结构。
最终,作者不喜欢基于恐惧作语言设计决策。今日人们对 goto 的问题和益处往往缺少细致理解,只记得它“被认为有害”;而教条通常不是高质量创造性工作的好起点。