Skip to main content

第 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 前进;要实现控制流,只需以更有趣的方式改变它。最简单的是没有 elseif

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):先写入带占位操作数的跳转,记录它的位置;编译完主体后,才能计算距离并回写占位符。

没有 else 的 if 所编译字节码的控制流。

把真实跳转距离补缝回既有字节码。

这两个工具函数将技巧编码起来:

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 分支。

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

正确的 if/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);

包含所需 OP_POP 的完整 if/else 流程。

反汇编器要理解 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)

andor 不是普通二元运算符:它们会短路,右操作数是否求值依赖左操作数,因此更像控制流表达式。

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 逻辑运算返回操作数本身,而不是强制转为布尔值。

and 表达式所编译字节码的短路流程。

这也解释了条件跳转为何不弹栈:假值左操作数必须留下。可以增加“跳转时隐式弹出”的专用指令,但本书刻意保持最小指令集;实践中的 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 多分派指令、没有理由更慢,却展示了无需新增指令也能完成两种逻辑运算。

or 表达式所编译字节码的短路流程。

至此 Lox 有三种只向前跳过代码的分支构造:ifandor。其他语言常有 switch?:,Lox 保持简单。

23.3 while 语句(While Statements)

循环通过向后跳转令代码重复执行。Lox 只有 whilefor,先实现更简单的 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_LOOPOP_JUMP 都是调整 ip,本可使用含有符号 16 位偏移的同一条指令;但手工打包有符号值更麻烦,opcode 空间又充足,故分开。反汇编器以负号显示目标:

case OP_LOOP:
return jumpInstruction("OP_LOOP", -1, chunk, offset);

while 的控制流:条件失败前跳出,主体后反向跳回。

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 改为增量起点,主体结尾的回跳便先执行增量。线性字节码次序虽与源代码不同,跳转恢复了语义。

含全部子句的 for 循环控制流。

jlox 一样,for 不需要新的运行时支持,全部降为原始控制流。至此 clox 已图灵完备;新增三个语句和两个表达式形式,却只需三条简单新指令,投入与收获相当可观。

挑战(Challenges)

  1. clox 加入多路 switch。语法如下;逐个比较 case 值,命中后执行其语句并退出,不支持 fallthrough 与 break

    switchStmt -> "switch" "(" expression ")"
    "{" switchCase* defaultCase? "}" ;
    switchCase -> "case" expression ":" statement* ;
    defaultCase -> "default" ":" statement* ;
  2. 加入 continueStmt -> "continue" ";" ;。它跳至最近循环的开头;在 for 中应跳至增量子句(若有)。循环外使用是编译错误。思考跳过循环主体或嵌套块时,已声明局部变量应如何清理。

  3. 控制流自 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 或报错行号;ifswitch 等分支不增加额外信息。加入函数调用后,当前位置还不够:函数可从多处调用,必须知道尚未返回调用的调用点,也就是调用栈。循环又要求迭代计数,嵌套循环则需一栈计数器。

此处 Dijkstra 的结论是,毫无限制的 goto 让人难以找到描述执行进度的有意义坐标;用“自启动以来执行的动作数”虽唯一,却毫无帮助。作者认为论证没有证明为何困难,而循环计数本质上也类似这种计数。以 clox 的字节码分派循环为例,知道它执行了 6,201 条用户字节码,对 VM 维护者未必有什么启发。

Böhm 和 Jacopini 证明任意 goto 控制流都可转换为仅含顺序、循环和分支的控制流;clox 的解释器循环正是活例子:它用结构化 C 控制流解释无结构字节码。似乎可以反驳 Dijkstra:先转换掉 goto,再使用结构化程序的对应关系。但作者也承认这同样是薄弱论证,二人都用貌似数学的推理处理本应经验性、以人为中心的问题。

goto 确会产生糟糕代码,许多地方应换成清晰的结构化控制流;彻底取消它,确实可以防止写出那类坏代码,强制用户使用结构化形式或许整体提高生产力。但也可能连同有用能力一起丢掉。缺少 goto 时,人们常用更复杂的结构化模式,例如在嵌套循环中设置 found 守卫变量、逐层 break 来寻找矩阵中的零;一条跳至结束标签的跳转有时反而更直白。break 本身也是受限的 goto 式结构。

最终,作者不喜欢基于恐惧作语言设计决策。今日人们对 goto 的问题和益处往往缺少细致理解,只记得它“被认为有害”;而教条通常不是高质量创造性工作的好起点。