1. 表达式是"值",语句是"动作":先把这条分界线划清楚
前四篇做完词法分析、表达式文法、AST 基础结构和表达式求值之后,那个教学编译器已经能对付1 + 2 * 3、(a + b) * c这样的表达式了。但说实话,当时我心里很清楚,这离"能编译出一段能跑的程序"还差着十万八千里。原因很简单:表达式只负责产生值,而程序是由一条条带副作用的"动作"串起来的——声明一个变量、给变量赋值、根据条件决定走哪条路、循环执行某段代码、从函数中返回结果。这些"动作"在编译器里的统称就是语句。
从解析的角度看,语句给语法分析带来两个此前没遇到过的变化。第一,语句引入了大量结构性终结符:分号、花括号、if/else/while/for/return 这些关键字。表达式解析只需要关注运算符优先级和括号匹配,而语句解析要处理的是"一个动作从哪里开始、到哪里结束"。第二,语句把语法分析从"单入口"变成了"多入口"——不再只有一个 parseExpression,而是先有一个 parseStatement 做分发,再由它调用 parseExpression、parseBlock 等子过程。
我这里说的目标语言是前几篇一直在搭的那门教学型小语言,它支持的语句类型其实不多,也就下面这几类:
| 语句类型 | 语法示例 | 说明 |
|---|---|---|
| 空语句 | ; | 什么都不做,占位用 |
| 块语句 | { stmt; stmt; } | 把多条语句合成一条 |
| 变量声明 | int a = 1; | 声明并初始化 |
| 赋值语句 | a = b + 1; | 更新已有变量 |
| 表达式语句 | foo(); | 调用函数、丢弃返回值 |
| if 语句 | if (cond) stmt else stmt | 条件分支 |
| while 语句 | while (cond) stmt | 循环 |
| for 语句 | for (init; cond; inc) stmt | 带初始化段的循环 |
| return 语句 | return expr; | 从函数返回 |
别看就这么几类,把它们全部在解析、语义、代码生成三个层面走通,编译器的主干基本就立住了。这一篇我会按"AST 设计 → 语法分析 → 符号表 → 代码生成 → 踩坑记录"的顺序走一遍,代码片段和思路都是我从这个教学编译器里实际抠出来的。
2. 给AST引入Stmt节点树:字段怎么设计才不返工
2.1 基类设计与"树的分叉"
在上一轮做表达式的时候,我们已经有了一个 Expr 抽象基类和一堆子类,比如 BinaryExpr、UnaryExpr、LiteralExpr、VariableExpr、CallExpr。现在要做语句,第一步是在旁边再开一棵树,根是 Stmt。
有些同学会纠结"为什么不把语句和表达式统一成一个节点"。我的建议是:别统一。虽然表达式在语法上也能被当作"求值语句"用,但它们的核心语义完全不同——表达式是纯函数式的,值进来值出去;语句强调副作用和执行顺序。把两者混在一个类型体系里,后面做代码生成时每条语句都要问"你到底是值还是动作",非常痛苦。我的做法是 Stmt 和 Expr 各自独立成树,中间只在"表达式语句"这一个节点上发生交叉。
// 语句节点基类 abstract class Stmt { abstract kind: string; } // 空语句 class EmptyStmt extends Stmt { kind = "Empty"; } // 块语句 class BlockStmt extends Stmt { kind = "Block"; statements: Stmt[] = []; } // 变量声明 class VarDeclStmt extends Stmt { kind = "VarDecl"; name: string; // 变量名 typeName: string; // int / float / bool / string init: Expr | null; // 初始化表达式,允许 `int a;` 这种无初始化写法 } // 赋值 class AssignStmt extends Stmt { kind = "Assign"; target: VariableExpr; // 目标必须是标识符,后续再放开到数组元素 value: Expr; } // 表达式语句 class ExprStmt extends Stmt { kind = "Expr"; expr: Expr; } // if-else class IfStmt extends Stmt { kind = "If"; condition: Expr; thenBranch: Stmt; elseBranch: Stmt | null; // 没有 else 时为 null } // while class WhileStmt extends Stmt { kind = "While"; condition: Expr; body: Stmt; } // for class ForStmt extends Stmt { kind = "For"; init: Stmt | null; condition: Expr | null; increment: Expr | null; body: Stmt; } // return class ReturnStmt extends Stmt { kind = "Return"; value: Expr | null; // `return;` 允许不带表达式 }2.2 节点设计里三个容易被忽略的细节
第一,AssignStmt 的 target 类型。我一开始偷懒,把 target 直接声明成 string,只存个名字。后来发现,一旦想支持arr[0] = 1这种下标赋值,target 就必须变成一个表达式节点。所以现在写成了 VariableExpr 而不是 string——字符串是表达式节点的特殊情况,宁可一开始多绕一步,也别等后面重构。当然,如果你的目标语言明确不支持数组,那用 string 也没问题。
第二,for 循环的三个部分。for 语句的 init 我直接用 Stmt 而不是单独搞一个"for 初始化语法",是因为 C 系语言里 for 的 init 本质上就是一条语句(变量声明或表达式语句),都能被 parseStatement 处理。condition 和 increment 用 Expr,但都允许为 null——for (;;)无限循环在语法层面是合法的。
第三,return 语句的值允许为空。return;和return 1;是两回事,前者表示返回 void,后者返回 int。如果你在设计节点时把 value 搞成非空,解析return;时就得硬塞一个 null 占位,后面语义分析时还得再判断"这个 null 是用户写的空值,还是初始化没填",给自己挖坑。直接声明为Expr | null最省事。
2.3 访问者模式有没有必要
如果你的编译器后续要做多趟遍历(语义分析一趟、代码生成一趟、打印 AST 一趟),我建议现在就把 visitor 接口定义好。Stmt 每加一个子类,visitor 就多一个 visitXxx 方法。虽然有同学觉得这很啰嗦,但比起 switch 语句里漏掉一个 case、编译器在运行时才发现"这个语句没被处理",visitor 的静态约束能帮你把"漏处理"变成"编译不过"。我实际用下来,写 visitor 的那点模板代码,在排查问题上省的时间远不止这点。如果你嫌 visitor 太重,退而求其次,至少要把 kind 字段的取值写成常量集合,配合 TypeScript 的判别联合也能起到差不多的效果。
3. 递归下降解析语句:调度、歧义与边界
3.1 parseStatement 的分发逻辑
语句解析的入口长这样:
parseStatement(): Stmt { // 类型关键字开头 -> 变量声明 if (this.check(TokenType.INT) || this.check(TokenType.FLOAT) || this.check(TokenType.BOOL) || this.check(TokenType.STRING)) { return this.parseVarDecl(); } if (this.match(TokenType.IF)) return this.parseIf(); if (this.match(TokenType.WHILE)) return this.parseWhile(); if (this.match(TokenType.FOR)) return this.parseFor(); if (this.match(TokenType.RETURN)) return this.parseReturn(); if (this.match(TokenType.LEFT_BRACE)) return this.parseBlock(); if (this.match(TokenType.SEMICOLON)) return new EmptyStmt(); // 剩下的按表达式语句或赋值语句处理 return this.parseExpressionOrAssign(); }这套调度说白了就是看当前 token 的类型决定走哪个分支。有几个地方需要格外注意:
- 关键字检查要用 match(消费掉),类型检查要用 check(只看不消费)。比如
if一旦被 match 掉,parseIf 里就直接期待左括号了。 - 花括号开头的一定是块语句,这条要放在 default 分支之前,否则一个
{会被 parseExpression 当成"分组括号"处理然后立刻报错。 - 类型关键字和变量名要区分开。如果我们把 int、float 这些也收进标识符的合法集合,这里就会跟普通表达式语句撞车。
3.2 "赋值还是表达式语句"的歧义
这是语句解析里最经典的一个岔路口。x = 1;和foo();开头都是表达式,怎么区分?
我的做法很朴素:先按表达式解析,再看有没有跟=。
parseExpressionOrAssign(): Stmt { const expr = this.parseExpression(); if (this.match(TokenType.ASSIGN)) { if (!(expr instanceof VariableExpr)) { throw new ParseError("赋值语句的左侧必须是变量", this.previous()); } const value = this.parseExpression(); this.consume(TokenType.SEMICOLON, "赋值语句后缺少分号"); return new AssignStmt(expr, value); } this.consume(TokenType.SEMICOLON, "表达式语句后缺少分号"); return new ExprStmt(expr); }这个方案有个取舍。它天然不支持x = y = 1;这种链式赋值,因为右值走 parseExpression 时遇到第二个=不会继续处理。我的目标语言里没有链式赋值的刚需,所以这个限制可以接受。如果你想要链式赋值,就得在赋值运算符层面做右结合处理,语法分析会复杂一截,但 AST 和代码生成反而简单——因为赋值本身可以当表达式。这个改动留给后面,不影响当前的主干逻辑。
另外一个被很多人忽略的点:x = 1写成表达式语句再补分号,也可以把赋值当作表达式来解析。也就是说,如果你在表达式语法里就把=定义成右结合运算符,并规定它的结果就是右侧表达式的值,那么赋值语句根本不需要单独立一个节点,x = 1;自然而然地就是 ExprStmt。这种设计在支持"表达式里嵌赋值"的语言(比如 C 的while ((c = getchar()) != EOF))里是必须的。我这个教学编译器暂时不搞这么野,所以保留 AssignStmt 独立节点,在代码生成的时候明确"赋值是一个动作"。
3.3 if、while、for、block 的解析细节
if 语句的解析核心是 else 的处理:
parseIf(): Stmt { this.consume(TokenType.LEFT_PAREN, "if 条件缺少左括号"); const condition = this.parseExpression(); this.consume(TokenType.RIGHT_PAREN, "if 条件缺少右括号"); const thenBranch = this.parseStatement(); let elseBranch: Stmt | null = null; if (this.match(TokenType.ELSE)) { elseBranch = this.parseStatement(); } return new IfStmt(condition, thenBranch, elseBranch); }这样写天然解决了悬挂 else 问题:因为 thenBranch 在检查 else 之前就被完整解析掉了,所以else永远绑定到最近的那个没被匹配的if。你不需要额外的消歧规则。这是递归下降相对 LALR 解析器省心的地方,LALR 在 else 上会有经典的移进-归约冲突,还得专门声明优先级,递归下降压根没这回事。
while和for的解析结构类似,但 for 有个容易踩坑的点——初始化部分的处理:
parseFor(): Stmt { this.consume(TokenType.LEFT_PAREN, "for 缺少左括号"); let init: Stmt | null = null; if (!this.check(TokenType.SEMICOLON)) { init = this.parseStatement(); // 注意这里已经消费了第一个分号 } else { this.advance(); // 空 init 时消费分号 } let condition: Expr | null = null; if (!this.check(TokenType.SEMICOLON)) { condition = this.parseExpression(); } this.consume(TokenType.SEMICOLON, "for 条件后缺少分号"); let increment: Expr | null = null; if (!this.check(TokenType.RIGHT_PAREN)) { increment = this.parseExpression(); } this.consume(TokenType.RIGHT_PAREN, "for 缺少右括号"); const body = this.parseStatement(); return new ForStmt(init, condition, increment, body); }这里的 init 直接复用 parseStatement,省掉了一套单独的"for 初始化"语法。但它有个微妙之处:parseStatement在解析int i = 0;时会消费掉分号,所以在 parseFor 里你不能再消费一次分号。我一开始就犯了这个错,for 循环的 init 解析完又补了一个 consume(SEMICOLON),导致for (int i = 0; i < 10; i++)这种最常见的写法直接报"期望分号"。复盘下来,最稳妥的办法就是:沿着"parseStatement 消费分号"这条路走,空 init 时才手动 advance 一次分号。
block的解析反而最简单,但要注意它跟作用域的配合(下一节详细说):
parseBlock(): Stmt { const block = new BlockStmt(); while (!this.check(TokenType.RIGHT_BRACE) && !this.isAtEnd()) { block.statements.push(this.parseStatement()); } this.consume(TokenType.RIGHT_BRACE, "块语句缺少右花括号"); return block; }3.4 分号的语义与错误恢复
分号在 C 系语言里是语句的结束标记。解析时我全程用 consume 而不是 match,就是为了让报错信息能指出"这里期望分号"。但一旦某个语句少了分号,后面的解析可能连锁崩坏——比如a = 1 b = 2;,parser 会把b当成表达式的一部分,然后报一个莫名其妙的错误。
教学编译器里我不打算做完善的错误恢复,但有一个小技巧值得记录:当 consume 失败时,如果能确定当前 token 属于"下一条语句的合法开头",可以直接跳过这个错误的语句并继续解析。用代码说就是:
consumeStmtEnd() { if (this.match(TokenType.SEMICOLON)) return; // 当前 token 如果是下一条语句的开头,就放过它,只报一次错 if (this.check(TokenType.IF) || this.check(TokenType.WHILE) || this.check(TokenType.INT) || this.check(TokenType.LEFT_BRACE)) { throw new ParseError("语句后缺少分号", this.previous()); } throw new ParseError("期望 ';' 但遇到未知 token", this.peek()); }这种"报错但继续"的策略对测试友好——一次编译能报出多个语法错误,而不是卡在第一个就停。当然,错误恢复是个大坑,做得不好反而会报一堆假错,所以我把它的范围严格控制在下一条语句关键字集合内,宁可少恢复也不要误报。
4. 符号表与作用域:语句带来的第一个全局性难题
4.1 为什么表达式阶段可以先欠着这笔账
前几篇处理表达式时,标识符在 AST 里只是一个 VariableExpr,保存了名字,解析的时候我完全没有检查"这个变量到底存不存在"。这在只算表达式的阶段是可以蒙混过去的——毕竟a + b的 AST 不管 a、b 有没有定义都能画出来。
但到了语句层面,变量声明出现了,这个问题就躲不过去了。int x = 1;需要把 x 记下来;x = 2;需要确认 x 在前面声明过;{ int y; }离开块之后 y 应该消失。这就是符号表要干的事。我见过不少教学编译器把符号表拖到语义分析阶段才做,那样当然更符合工业级编译器的分层,但对于一门没有函数重载、没有类型推导、没有结构体的小语言来说,在解析阶段顺手把符号表和 AST 一起建出来反而更直观——你不需要为它单独维护一棵中间树,解析的递归深度天然对应作用域的嵌套深度。
4.2 栈式符号表的结构
我的实现非常简单,就是一个带 parent 指针的链表,加上一个指向"当前作用域"的游标:
class Scope { parent: Scope | null; symbols: Map<string, SymbolInfo>; constructor(parent: Scope | null) { this.parent = parent; this.symbols = new Map(); } } class SymbolTable { current: Scope | null = null; pushScope() { this.current = new Scope(this.current); } popScope() { if (!this.current) throw new Error("作用域栈不平衡:pop 了一个不存在的栈帧"); this.current = this.current.parent; } declare(name: string, info: SymbolInfo) { if (!this.current) throw new Error("没有作用域可声明"); if (this.current.symbols.has(name)) { throw new SemanticError(`变量 ${name} 在当前作用域重复声明`); } this.current.symbols.set(name, info); } lookup(name: string): SymbolInfo | undefined { let scope = this.current; while (scope) { const found = scope.symbols.get(name); if (found) return found; scope = scope.parent; } return undefined; } }这里有一个关键决策:declare 查重只看当前作用域,不往父作用域看。也就是说,int x=1; { int x=2; }是合法的——内层块可以遮蔽(shadow)外层变量。这是绝大多数 C 系语言的语义。如果你不小心用 lookup 来查重,就会把"内层声明同名变量"误判成错误,后面写循环时for (int i...; ...) { for (int i...; ...) }这种嵌套立刻翻车。
popScope 我加了一个非空断言,看起来有点啰嗦,但它能救一次命:当你忘记在某个分支里 push 却调用了 pop,至少报错的地方是对的。后面讲踩坑时我会展开一个真实的"作用域栈不平衡"案例。
4.3 声明绑定发生在哪个时间点
我选择在解析阶段就直接做声明绑定。具体来说:
parseBlock进入时 pushScope,离开前 popScope。parseVarDecl解析到名字时调用 declare。parseExpressionOrAssign在遇到 VariableExpr 时调用 lookup,找不到直接报"未声明的变量"。- for 循环解析时,init 里的变量声明要单独开一个作用域,让 i 的作用域覆盖整个 for 循环而不是泄漏到循环外。
值得一提的细节是 for 的作用域。for (int i = 0; ...) { }里的 i 理论上在循环结束后就不能访问了,所以在 parseFor 里应该是:
this.symbolTable.pushScope(); const stmt = this.parseFor(); // 内部所有声明都进这个作用域 this.symbolTable.popScope(); return stmt;但 AST 的构建和符号表的生命周期在这里会有一个小错位:parseFor 返回的 ForStmt 里,init 的 VarDeclStmt 节点仍然存在,只是符号表已经弹栈了。这个错位没问题——AST 是持久化的程序结构,符号表是遍历时的临时状态。只要代码生成阶段不再依赖符号表的"当前作用域"来找变量,而是依赖 AST 上记录的绑定(比如直接把符号表项的引用挂在 VariableExpr 上),弹栈就不会造成悬空引用。我做这个教学编译器时,直接在 VariableExpr 上加了一个resolvedSymbol?: SymbolInfo字段,解析时查表成功就挂上去,后续生成代码时只认这个字段,不再回去 lookup。
5. 语句的中间代码生成:控制流标签是核心难点
5.1 IR 的选型:三地址码 + 标签
前一篇做表达式代码生成时,我用的是简单的三地址码,每条指令最多带两个操作数和一个结果,临时变量用t0, t1, t2...命名。比如a + b * 2生成出来是:
t0 = b t1 = 2 t2 = t0 * t1 t3 = a t4 = t3 + t2语句的加入并没有改变这个 IR,但引入了一个新的指令类别:标签和跳转。标签L0:标记位置,跳转指令goto L0和条件跳转if t0 > t1 goto L0改变执行流。表达式代码生成是和位置无关的,语句代码生成则完全围绕标签展开——所以必须先建立一个好用的标签分配器:
let labelSeq = 0; function newLabel(): string { return `L${labelSeq++}`; }标签用全局计数器累加,保证唯一性。别图省事用L0、L1固定命名在局部函数里反复用,多层的 if/while 嵌套叠起来,重名标签会把 IR 搅成一锅粥。
5.2 if/else 的标签布局
条件语句生成的关键是"条件求值 + 跳转"。我拆了一个 genCondition 函数,它专门处理关系比较和逻辑运算:
genCondition(cond: Expr, trueLabel: string, falseLabel: string) { if (cond.op === "&&") { const nextLabel = newLabel(); genCondition(cond.left, nextLabel, falseLabel); // 左为假,直接去 false emit(nextLabel + ":"); genCondition(cond.right, trueLabel, falseLabel); // 左为真,再看右 } else if (cond.op === "||") { const nextLabel = newLabel(); genCondition(cond.left, trueLabel, nextLabel); // 左为真,直接去 true emit(nextLabel + ":"); genCondition(cond.right, trueLabel, falseLabel); } else if (cond.op === "!") { genCondition(cond.operand, falseLabel, trueLabel); // 取反就是交换目标标签 } else { // 普通比较:算出左右两边的值,再 emit 条件跳转到 trueLabel const leftReg = genExpr(cond.left); const rightReg = genExpr(cond.right); emit(`if ${leftReg} ${cond.op} ${rightReg} goto ${trueLabel}`); emit(`goto ${falseLabel}`); } }这里最妙的是 && 和 || 的短路求值。a && b在语义上等于"a 为真时才继续检查 b",所以我把 falseLabel 透传给左分支,左分支为假直接跳走,左分支为真则落入一个中间标签继续求右分支。||则反过来。短路求值不只是优化,还是语义要求——如果 b 有副作用或者会触发除零,无条件求值 b 就会改变程序行为。
有了 genCondition,if 语句的生成就直白了:
genIf(stmt: IfStmt) { const thenLabel = newLabel(); const elseLabel = newLabel(); const endLabel = newLabel(); genCondition(stmt.condition, thenLabel, elseLabel); emit(label(thenLabel)); genStmt(stmt.thenBranch); emit("goto " + endLabel); emit(label(elseLabel)); if (stmt.elseBranch) { genStmt(stmt.elseBranch); } emit(label(endLabel)); }注意一个细节:如果没有 else 分支,elseLabel 和 endLabel 之间是空的,相当于一个空标签。这在 IR 里完全合法,但如果你有洁癖,可以做一个优化——没有 else 时直接把 condition 的 falseLabel 指向 endLabel,省掉一个空标签和一条跳转。这个优化非常安全,我建议顺手做了,因为它能让 IR 更短,后面看 dump 时少些噪声。
来看一个完整例子。if (x > 0) y = 1; else y = 2;生成的 IR 大致是:
L0: t0 = x L1: t1 = 0 L2: if t0 > t1 goto L3 L4: goto L5 L3: t2 = 1 y = t2 goto L6 L5: t3 = 2 y = t3 L6: // 出口5.3 while 和 for 的回边
while 的生成模式是教科书级的:
label beginLabel: genCondition(cond, bodyLabel, endLabel) label bodyLabel: genStmt(body) goto beginLabel label endLabel:注意 body 最后那条goto beginLabel,它叫回边。回边的存在让循环体既能重复执行,又在条件不满足时通过 endLabel 退出。如果条件一开始就是假,bodyLabel 到 endLabel 之间会有一段永远执行不到的代码——没事,我们现在不优化。
for 的生成本质上就是把三段组合起来:
genStmt(init) # 初始化只执行一次 label condLabel: genCondition(cond, bodyLabel, endLabel) label bodyLabel: genStmt(body) genExpr(increment) # 增量表达式的结果直接丢弃 goto condLabel label endLabel:for 和 while 的差别只在开头多了一段 init、循环尾部多了 increment。有人会问为什么不直接把 for desugar 成 while。确实可以,而且很多编译器就是这么干的。我保留 ForStmt 节点,是为了后续做"循环不变代码外提"之类的优化时能识别出循环结构。如果纯粹追求实现简单,desugar 成 while 更省事。
5.4 return 与函数的收尾约定
return 的代码生成是整个语句体系里唯一需要跟"函数栈帧"对话的地方。在单函数教学编译器里,return可以简单粗暴地翻译成:
t0 = <返回值表达式> return t0但一旦支持多函数,return 必须跳到函数的统一出口,执行栈帧清理,再把返回值放到约定位置。我的简单做法是给每个函数生成一个独一无二的出口标签:
genFunction(name: string, body: BlockStmt) { const entryLabel = newLabel(); const exitLabel = newLabel(); funcExitLabels.push(exitLabel); // 当前函数出口标签入栈 emit(label(entryLabel)); genBlock(body); emit("goto " + exitLabel); // 非 return 路径自然溜到出口 emit(label(exitLabel)); emit("return"); // 出口处统一返回 funcExitLabels.pop(); }这样 return 语句的生成就非常简单:求值表达式,然后goto 当前函数的出口标签。由于在解析时我们已经确保了return只能出现在函数体内(不在函数体顶层就报错),所以生成阶段只需要从 funcExitLabels 栈顶取标签即可。这个栈很重要——嵌套函数(如果你的语言支持)或者 return 出现在 if/for 深处时,栈顶永远是当前所在函数。
6. 落地时踩过的坑和调试建议
6.1 分号缺失与"下一个语句开头"的错乱
我在 3.4 节提到了分号错误恢复。实际写测试时,最让我头疼的不是错误恢复本身,而是缺失分号导致的报错位置漂移。比如:
int a = 1 int b = 2;第一行缺分号。由于 parseVarDecl 在解析完1之后执行 consume(SEMICOLON) 失败,如果我的 consume 直接抛异常,异常发生在int这个 token 上——这还算好。但如果赋值语句缺分号,int b会被当成分号之前表达式的延续,parseExpression 会把int当作一个未声明的变量,然后报"未声明变量 int",真正的"缺分号"问题反而被掩盖了。这就是为什么要专门写一个"识别下一条语句开头"的错误恢复逻辑,宁可少恢复,也要把报错指向分号本身。
6.2 作用域栈不平衡的诡异表现
这个 bug 我花了一整个下午才定位。现象是:在一个函数里写
int i = 0; while (i < 3) { int temp = i; i = i + 1; } temp = 5;编译器竟然没报"temp 未声明",反而在函数结束时报"作用域栈不平衡"。原因我在 parseWhile 里调用 parseStatement 解析循环体时,没有为循环体单独 pushScope。结果int temp被声明到了函数作用域里,离开循环还能访问;而函数结束时 parseFunction 的 popScope 把 while 循环对应的那块(其实并没有的)作用域弹掉了,栈深就乱了。修复方法就是第 4 节说的:凡是块、循环体、函数体,进入时 push,离开时 pop,一个都不能少。我建议在 popScope 里加个断言,栈空时 pop 直接崩——烦躁一时,事后再想这个报错反而帮你快速缩小范围。
6.3 用AST打印和IR dump来定位问题
语句阶段的 bug 往往藏在嵌套结构里,光靠"程序输出不对"很难猜出是哪一层出了问题。我的经验是给编译器的每一层都留一个 dump 出口:
- 词法分析结束后可以打印 token 流。
- 语法分析结束后可以打印 AST(带缩进)。比如 IfStmt 打印成
If(cond=..., then=..., else=...),一眼就能看出树结构对不对。 - 代码生成结束后打印 IR,按指令顺序一行行看,配合标签编号,控制流是否合理一目了然。
打印 AST 的递归写法很容易,但要注意循环引用——AssignStmt 的 target 和 value 如果是同一个 Expression 对象的引用,打印函数里不做 visited 标记就会无限递归。我这个阶段还没到共享节点(那是后面做公共子表达式消除才有的结构),所以暂时不需要 visited。等你真做到那一步,再给打印函数加个三色标记也不迟。
6.4 给自己建一个语句回归测试集
语句的测试和表达式不一样,表达式看一两个用例就能放心,语句涉及控制流组合,不建用例库一定会踩漏。我当时的做法是建了一个tests/目录,每个用例一个文件,文件里写清楚期望输出,跑完跟一个模拟器(对着同一份 AST 做解释执行)的结果比对。这个模拟器一开始只是用来验证 IR 对不对,后来发现它的存在让整个教学编译器都有了参考系:解析对不对、生成对不对、执行对不对,三层互相对照,出 bug 时能非常快地定位到具体层级。比如 IR 里条件跳转的目标标签打错了,模拟器跑出和参考程序不同的输出,一对比就知道是生成层的问题,而不是解析层的问题。
写语句解析的顺序我建议也按依赖排:先做空语句和块语句(最简单,建立 parseStatement 的骨架),再做变量声明和赋值(建立符号表),再做 if(引入控制流标签),再做 while 和 for(引入回边),最后做 return(引入函数出口约定)。每一步都能独立测试,前一步稳了再往后走,心里踏实得多。
最后再分享一个小经验:语句解析的代码生成阶段,别急着做任何优化。先把"能跑出正确结果"的 IR 写出来,哪怕每条指令都带一个标签、每个 if 都多两条 goto,都不丢人。优化的冲动留到下一篇,等函数、数组、结构体都进来了,那时候再回头看控制流,你会比现在清楚得多。我在这个阶段砍掉的所有"小聪明优化",后来都用更合理的姿势重新实现了,反而省了返工的力气。