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: Mapstring, 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 x1; { int x2; }是合法的——内层块可以遮蔽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 就会改变程序行为。有了 genConditionif 语句的生成就直白了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都不丢人。优化的冲动留到下一篇等函数、数组、结构体都进来了那时候再回头看控制流你会比现在清楚得多。我在这个阶段砍掉的所有小聪明优化后来都用更合理的姿势重新实现了反而省了返工的力气。
阅读完成 · 觉得有帮助?