
1. 从表达式跨到语句这一步决定了它是计算器还是编程语言动手实现系列走到第五篇前面几篇我们搞定了词法分析、AST构建和表达式求值这套解释器已经能像计算器一样计算1 2 * 3这种算术表达式了。但从“能算”到“能编程”中间隔着一道关键门槛——语句的实现。表达式解决的是“值是多少”的问题语句解决的是“做什么”的问题。没有语句程序只能是一串孤立的表达式有了语句才有变量、分支、循环和函数调用程序才真正具备描述复杂逻辑的能力。这篇是本系列的分水岭我会从AST扩展、语法解析、执行器改造、作用域管理四个维度把语句实现的完整思路和落地过程拆开讲清楚。内容会覆盖变量声明、赋值、块语句、if/else、while、for、return、break/continue这些最常见语句同时把分号处理、悬空else、控制流信号这些容易踩坑的点单独拎出来说。适合谁看正在自己写解释器或编译器的开发者学过编译原理但缺乏实战经验的同学以及把“写一个能跑起来的语言”当成练手项目、想系统走一遍完整流程的人。前面几篇的内容我们已经有词法分析器和表达式解析器所以这篇直接在这个基础上叠加语句层。如果你是从零开始读这篇建议先补一遍前几篇否则里面的AST和执行器代码会显得突兀。2. 先扩展AST让树的形态匹配语言的语法结构在动手写解析器之前第一件事是把AST节点定义扩展好。表达式那一层的AST是Expr基类加各种子类语句这一层需要一个平行的结构Stmt基类加各语句子类。两条继承线在解释器里对应两套完全不同的处理方式表达式通过evaluate()计算值语句通过execute()产生效果——前者带着返回值走后者改变某种状态或者控制流。这个区分从设计上就得想清楚不要混在一起。2.1 为语句定义独立的节点层级我用 JavaScript 风格描述AST结构但思路是语言无关的。代码只用于表达设计意图你换成 Python、Java、Rust 甚至 C 都行。// 语句基类 class Stmt {} // 表达式语句 expr; class ExprStmt extends Stmt { constructor(expr) { super(); this.expr expr; } } // 变量声明 let name init; class VarDecl extends Stmt { constructor(name, initExpr) { super(); this.name name; this.initExpr initExpr; // 可能为 null如 let a; } } // 赋值语句 target value; class AssignStmt extends Stmt { constructor(target, valueExpr) { super(); this.target target; this.valueExpr valueExpr; } } // 块语句 { stmt1; stmt2; ... } class BlockStmt extends Stmt { constructor(statements) { super(); this.statements statements; } } // 条件语句 if (cond) thenStmt else elseStmt class IfStmt extends Stmt { constructor(condExpr, thenStmt, elseStmt) { super(); this.condExpr condExpr; this.thenStmt thenStmt; this.elseStmt elseStmt; // 可能为 null } } // 循环语句 while (cond) body class WhileStmt extends Stmt { constructor(condExpr, body) { super(); this.condExpr condExpr; this.body body; } } // 返回语句 return value; class ReturnStmt extends Stmt { constructor(valueExpr) { super(); this.valueExpr valueExpr; // 可能为 null } } // 跳出语句 break; / continue; class BreakStmt extends Stmt {} class ContinueStmt extends Stmt {}字段设计上有一个细节很多人会忽略IfStmt的thenStmt和elseStmt我们用的是Stmt类型而不是BlockStmt。这意味着语法上允许if (x) y 1;这种不加花括号的写法解析器只负责把单个语句挂在分支下。这个设计从AST开始就决定了语法风格的自由度后面讲悬空else问题时还会回来看它。2.2 一个关键取舍声明算不算一条语句变量声明的归属值得单独讨论一下。我在设计时把VarDecl直接定义为Stmt的子类这样let a 10;在AST里就是一个语句节点。但也有人会把声明单独做成一个Declaration层级和Stmt平级然后在语句列表里区分。两种设计都能跑通区别在于语义分析阶段的口径。把声明当成普通语句的好处是解析和执行流程非常统一——顶层代码是语句列表函数体也是语句列表声明自然出现在里面。坏处是后期做变量作用域检查和重复声明校验时需要在遍历里特判instanceof VarDecl。但我觉得这个特判不是负担反而清晰声明和普通语句本来语义就不同特判是合理的。还有一个必须想清楚的点赋值语句和初始化表达式的关系。AssignStmt是“给已存在的变量重新赋值”而VarDecl是“创建变量并可能给初值”。在AST层面分开执行时的语义就清晰了——前者查找符号表并更新后者先在当前作用域注册变量再求值初始化表达式。这个区分如果不做好后面做块级作用域时就会陷入“变量到底存不存在”的混乱里。2.3 为什么语句需要统一的执行入口表达式节点可以各有各的求值方式但语句节点必须提供一个统一的execute()接口。为什么因为解释器的执行循环面对的是一个“语句数组”它不可能对每个节点做类型判断来处理控制流。统一入口让执行器代码保持精简function executeBlock(statements, env) { const prevEnv currentEnv; currentEnv new Env(prevEnv); for (const stmt of statements) { const result stmt.execute(); if (result.type RETURN) { /* ... */ } if (result.type BREAK) { /* ... */ } } // 作用域退出要恢复环境 currentEnv prevEnv; }这个统一入口在后续实现函数、闭包时尤其关键——函数体本质也是一个语句序列统一执行入口让函数调用天然复用同一套执行逻辑。3. 语法分析从Token流里“认出”每一种语句AST设计好后重头戏来了让Parser根据Token流识别并构建语句节点。表达式解析器我们已经有了语句解析的关键是分派——根据当前Token决定走哪条解析路径。这一层做得好语法会很干净做得糙整个Parser会变成一串纠缠不清的if-else。3.1 语句的识别策略看第一个Token决定一切parseStatement()是这个模块的调度中心它的逻辑非常简单看当前Token是什么然后交给对应的解析函数。如果什么关键字都不是就按表达式语句处理。parseStatement() { if (this.check(LET) || this.check(CONST) || this.check(VAR)) { return this.parseVarDecl(); } if (this.check(IF)) return this.parseIfStmt(); if (this.check(WHILE)) return this.parseWhileStmt(); if (this.check(FOR)) return this.parseForStmt(); if (this.check(RETURN)) return this.parseReturnStmt(); if (this.check(BREAK)) return this.parseBreakStmt(); if (this.check(CONTINUE)) return this.parseContinueStmt(); if (this.check({)) return this.parseBlockStmt(); if (this.check(;)) { // 空语句直接消费分号返回 null 或一个 EmptyStmt this.advance(); return null; } // 兜底一切普通表达式构成的语句 return this.parseExprStmt(); }这个“首Token分派”的策略看起来简单却是整个Parser稳定性的根基。它逼着语法设计保持整洁关键字不能作为变量名if不能出现在表达式开头{不能同时承担对象字面量和块语句两种角色如果想支持对象字面量这里要做上下文判断这一篇我们先不碰那个复杂度。分派完成后每个parseXxxStmt()函数内部有固定套路消费关键字if消费左括号(解析条件表达式消费右括号)再解析紧跟的语句作为thenStmt。最后看下一个Token是不是else是就消费并解析elseStmt。3.2 分号怎么处理显式终结与自动补全策略表达式语句是语句家族里最容易被忽略、却最容易出问题的成员。问题集中在一点语句到哪里结束很多脚本语言允许换行即结束也允许在同一行写多个语句。我的实现默认采用“显式分号优先行尾自动补全”的混合策略遇到;当然直接消费结束当前Token在行尾上一Token后面有换行时也视为语句结束当前Token是}时说明是块语句的收尾也自动结束。这个策略更像现代脚本语言的体验对写测试用例非常友好。但代价是必须在词法层记录Token的“是否前面有换行”这个信息。如果没有这个信息就无法区分a 1\nb 2和a 1 b 2的差异。判断“是否需要分号”有个非常实用的经验解析表达式语句时先解析一个表达式然后调用consumeSemicolon()或者consumeNewline()。如果两者都不满足就报“意外的Token”错误。这里的报错信息要足够明确像Expected ; after expression, got foo这种格式后面调试能省大量时间。还有一类特殊场景必须要处理好表达式语句以(开头时。比如(a b) * c;这个表达式语句的第一个Token是(而(也可能是块级作用域的前奏不是块语句是{。真正的问题是数组和对象访问模式arr[i] 1 (func)(2)如果实现里允许“换行结束语句”那么arr[i] 1\n(func)(2)很可能被解析成arr[i] 1(func)(2)即把上一句当成函数调用。避免这个问题的经典做法是当新语句以[、(、、-、/等可能延续上一表达式的Token开头时不进行自动补全。这套规则叫做“受限制的自动分号插入”实现时要特别小心。3.3 悬空else问题经典到每次都值得先说写parseIfStmt时你会立刻撞上编译原理教科书里最著名的语法歧义——悬挂else。先看一个例子if (a) if (b) c 1; else d 1;这个else到底匹配哪个if按照C系语言的标准规则else永远匹配最近的未匹配if所以这里匹配的是if (b)。如果解析器不做特别处理用朴素的递归下降很容易错误匹配成外层if。处理方式其实很直接在parseIfStmt里thenStmt解析完后立即检查当前Token是否为else是就消费并解析elseStmt。由于thenStmt本身可能又是一个if语句它内部的解析会先消费掉属于自己的else外层只能拿到剩下的。这个“就近匹配”行为是递归下降天生就能给出的不需要额外的回溯。真正的坑在测试用例里比如像这种写法if (a) { if (b) c 1; } else d 1;因为thenStmt是一个带{的块语句所以块语句结束时内层if (b)已经被块保护住了这个else才能被外层if拿到。这正好印证了2.1节的设计thenStmt是普通Stmt而不是强制要求块语法上才允许这种混合写法。3.4 循环语句的解析for 的“三步走”还原while的解析很简单难点在for。for (let i 0; i n; i i 1)这种语句可以进行两个层次的解析一个是把for拆成init、cond、update三个部分另一个是直接生成一个While循环的AST。第二种方案在实现上更优雅——Parser里解析完三个部分后构造等价于{ init; while (cond) { body; update; } }的结构。parseForStmt() { this.consume(FOR); this.consume((); const init this.match(;) ? null : this.parseVarDeclOrExprStmt(); const cond this.match(;) ? null : this.parseExpression(); this.consume(;); const update this.match()) ? null : this.parseExpression(); this.consume()); const body this.parseStatement(); // 构造等价结构 if (init) { /* 放进一个块语句的开头 */ } const whileBody []; whileBody.push(body); if (update) whileBody.push(new ExprStmt(update)); // 返回一个合成的 BlockStmt 或 WhileStmt }这个策略最妙的地方是执行器只需要支持while一种循环for就自动生效了。易懂、省代码、不容易出执行bug。但要注意init里的变量声明如果放到外层块里变量作用域会扩大一点——严格来说for循环的初始化变量应该只在循环内可见。这个细微差异在后续做作用域时可以修正但初期不影响使用。4. 执行器改造从线性求值到控制流跳转AST和Parser都就位后最后一块拼图是执行器。之前执行表达式是递归evaluate返回一个值。现在执行语句需要考虑三类情况普通语句没有返回值条件分支要决定走哪条路径循环体要反复执行某段子树。最关键的是return、break、continue需要某种“非正常完成”的信号让执行器知道提前终止当前块或循环。4.1 控制流信号用一个小对象统一表达“跳出”我见过两种常见做法一种是用异常机制抛一个ReturnException、BreakException另一种是让execute()返回一个枚举值或对象。异常机制写起来省事但会把流程控制本身变成异常处理调试时堆栈会被打断我主观上不太推荐。我更倾向用返回对象的方式// 控制流信号常量 const Flow { NORMAL: 0, RETURN: 1, BREAK: 2, CONTINUE: 3 };execute()的约定是正常执行完返回{ type: Flow.NORMAL }遇到ReturnStmt返回{ type: Flow.RETURN, value: 表达式求值结果 }遇到BreakStmt返回{ type: Flow.BREAK }以此类推。这样执行块语句时就有了明确的判断依据executeBlock(statements) { for (const stmt of statements) { const flow stmt.execute(); if (flow.type ! Flow.NORMAL) { return flow; // 需要往上层传播由外层决定如何处理 } } return { type: Flow.NORMAL }; }这个设计的精髓是“逐层上报由最近的相关层处理”return信号让函数体执行层停止并带回值continue信号让循环体执行层停止这一次迭代并进入条件重新判断break信号让循环体执行层直接结束整个循环。如果某个信号没有被对应的层接收到就会一层层往上冒最终在顶层报错——比如在if的thenStmt里出现break但没有循环包裹这种错误能自然暴露出来。4.2 if语句的实现条件求值和分支选择的顺序IfStmt.execute()的逻辑不复杂顺序要严格控制execute() { const condValue this.condExpr.evaluate(); const isTrue isTruthy(condValue); if (isTrue) { return this.thenStmt.execute(); } else if (this.elseStmt) { return this.elseStmt.execute(); } return { type: Flow.NORMAL }; }这里有个被反复问到的细节isTruthy规则怎么定不同语言差别很大。我的实现采用“宽松真值”规则false、0、空串、null、undefined视为假其余为真包括空数组、空对象都算真这一点和多数脚本语言一致。这个规则会影响所有条件判断和 ||短路求值所以要在词法解析阶段就明确下来写进文档不然迟早会为[]是真还是假吵架。顺便提一下短路求值。表达式解析器里实现和||时如果采用“先求值两侧再合并”的方式短路逻辑就丢了。正确做法是左侧求值后立即判断决定是否还要执行右侧。这个和if的分支逻辑是天然一致的——执行器对条件表达式的处理会直接复用到短路语义。4.3 while循环与break/continue小心死循环陷阱WhileStmt的实现是循环语句的基础execute() { let maxIterations 1000000; // 防止死循环的保险真实语言里一般不设 while (isTruthy(this.condExpr.evaluate())) { const flow this.body.execute(); if (flow.type Flow.BREAK) break; if (flow.type Flow.RETURN) return flow; // return 可以直接穿透循环 // CONTINUE 和 NORMAL 都继续循环 if (--maxIterations 0) { throw new Error(Max loop iterations exceeded: possible infinite loop); } } return { type: Flow.NORMAL }; }return从循环里穿透出来这个行为要特别留意如果循环体内部有return执行器要把这个信号原样向函数层传播而不是吞掉。continue和break的区别只是 continue 跳回条件判断break 跳出循环本质上都是让body.execute()提前返回。死循环对解释器来说是个很实际的问题。开发阶段建议加一个最大迭代次数保护比如10万次超出直接抛异常。真实语言当然不会这么干但在调试期这个保护能让你少费很多强行杀掉解释器进程的功夫。等所有功能稳定后可以把这个限制去掉。4.4 局部变量与赋值语句的执行语义前面几章有了符号表这一章讨论变量声明和赋值在执行器里的落地。执行VarDecl时需要在当前作用域注册变量。如果变量已存在就得看语义规则let重复声明应该报错var则一般允许覆盖。执行AssignStmt时先在作用域链上查找变量找到就更新值找不到就报“未定义变量”错误。有一个执行顺序的细节值得多说一句let a a 1;这种语句如果变量当前不存在那么右侧的a应该在哪一层查找我的习惯是先注册变量让它在当前作用域里可被查到然后求值右侧表达式。这样let a a 1会因为右侧a找到了刚注册但尚未初始化的变量而得到某种“未初始化”的错误而不是更隐蔽的行为。如果你把顺序反过来——先求值右侧再注册变量——那么右侧的a会往外层作用域查到一个不相关的同名变量产生非常难以排查的逻辑错误。5. 作用域与符号表语句块带来的一层“透明屏障”有了块语句变量作用域问题立刻浮现。之前只有全局一个作用域现在{ }可以嵌套里面的变量应该在外层不可见。维护作用域不是简单加一个栈就行关键在“进入块时压栈、退出块时弹栈”的时机以及变量查找的顺序。5.1 符号表栈的实现方式我采用的是环境链Environment Chain设计每个Env对象维护一个变量映射表同时持有parent指向外层环境。当前环境由执行器全局指针currentEnv表示。查找变量时从当前环境一路向上找class Env { constructor(parent) { this.vars new Map(); this.parent parent; } define(name, value) { if (this.vars.has(name)) { throw new Error(Variable ${name} already declared); } this.vars.set(name, value); } lookup(name) { let env this; while (env) { if (env.vars.has(name)) return env.vars.get(name); env env.parent; } throw new Error(Undefined variable ${name}); } }executeBlock()里在进入块语句数组时创建新Env退出后把currentEnv指回父环境。这样块内定义的变量不会漏到外部但块内能正常访问外层变量。这个机制对应着块级作用域的“透明屏障”从里面能看出去从外面看不到里面。5.2 声明提升和初始化时机的纠葛如果你的语言要支持var风格的“声明提升”语义那么和作用域的交互会更复杂。var的特点是无论声明写在块的哪里该变量都对整个函数体可见但赋值保持在原位置。实现声明提升有两个方案第一个方案是在进入作用域时扫描整个块的语句把所有VarDecl先注册为undefined再顺序执行。这个方案一次扫描、直接满足提升语义缺点是需要块语句的AST提前提供整个语句列表。第二个方案是靠执行时判断当赋值目标变量未声明时自动创建——这个方案方便但会让“打错变量名”变成“悄悄创建全局变量”是问题的高发区我个人不支持。5.3 为后续闭包环境预留的运行思路如果这个解释器后面打算实现函数和闭包那作用域设计在一开始就该多想一步。函数调用时函数体会创建一个新Env但函数内部引用的外层变量必须通过Env链追溯到函数定义时的环境而不是调用时的环境。这就是闭包的核心函数对象要携带“定义环境”的引用。class FunctionValue { constructor(params, body, closureEnv) { this.params params; this.body body; this.closureEnv closureEnv; } call(args) { const callEnv new Env(this.closureEnv); // 注意是以闭包环境为父环境 // 绑定参数... return executeBlock(this.body.statements, callEnv); } }如果环境链设计正确后面实现函数、闭包几乎不用改原有结构。如果环境链设计成“所有变量存放在全局对象里”或者“作用域是扁平数组”后面补闭包会非常痛苦。现在多花一点时间把环境链路数对是这笔账最划算的一次投资。6. 实操记录一套完整的最小语句测试集理论讲再多代码不跑起来都是白搭。我建议你准备一套覆盖所有语句类型的测试脚本每一类语句都有且只有一个典型案例。这套脚本会成为你后面改执行器时的回归测试基础。6.1 测试脚本示例我用的测试源文件大概长这样let a 10; let b 0; if (a 5) { b 1; } else { b -1; } let sum 0; for (let i 1; i 5; i i 1) { sum sum i; } let count 0; while (count 3) { count count 1; } print(b , b); print(sum , sum); print(count , count);注意这里print语句是作为一种预定义的全局函数或者内置语句实现的。如果你还没有实现函数调用直接在解释器里给print开个后门最简单——碰到print(...)表达式就输出值。这也是很多树解释器的常见做法先把输入输出通道打通其余功能再慢慢补。6.2 调试利器两步追踪法解释器做得越大调试越依赖输出的可视化。我最常用的两个手段第一AST打印。在Parser解析完成后把整个AST树以缩进文本形式dump出来。这样一旦执行结果不对先看AST是否和预期一致能立刻区分“解析错”还是“执行错”。function dumpAST(node, indent 0) { const pad .repeat(indent); if (node instanceof IfStmt) { console.log(${pad}IfStmt); dumpAST(node.condExpr, indent 1); dumpAST(node.thenStmt, indent 1); if (node.elseStmt) dumpAST(node.elseStmt, indent 1); } else if (/* 其它节点 */) { // ... } }第二执行轨迹追踪。在执行器里加一个全局开关traceMode打开后每执行一个语句或表达式就打印当前语句的行号、类型和关键值。这对排查循环不退出、作用域变量被意外覆盖这类问题几乎是决定性武器。实际做事时我一般在execute()的开头加一行if (traceMode) console.log([TRACE] execute ${this.constructor.name});6.3 我踩过的三个坑第一个坑表达式语句结束符判断不严。之前我实现时允许“换行即结束”结果一行里写a 1 b 2这种多语句时Parser报错信息完全看不懂。后来统一了consumeSemicolon()函数所有表达式语句都走这一个出口问题就没了。第二个坑for循环里变量作用域。最初我把for的init节点直接放进外层块执行导致循环里的变量在循环结束后还能访问。这在测试用例里不明显直到写了一个稍大一点的脚本才感觉到这个语义不正确。后来专门做了for的env包装初始化表达式在独立一层Env中执行循环结束后这层Env就被丢弃。第三个坑else匹配错乱。我曾经为了支持在then分支后面紧跟else在parseIfStmt里加了一个“回溯”逻辑导致多层嵌套if时匹配混乱。最后还是老老实实改成标准递归下降不加任何回溯猜测问题立刻消失。这里我的经验是对递归下降解析器来说保持无回溯是设计目标不要为了提高某个语法的兼容性去轻易打破它。7. 语句实现常见问题速查梳理一下这个阶段最容易碰到的问题按“症状-原因-解法”列成表方便卡壳时直接对号入座。症状常见原因解决建议表达式语句报“意外Token”错分号/换行结束逻辑不统一所有表达式语句统一走consumeSemicolon()或consumeNewline()一个出口多层if时else匹配到错误的if解析器做了不自然的回溯删除回溯逻辑按就近匹配的递归下降重写parseIfStmtfor循环初始化变量出循环后仍可见初始化环境绑定在错误作用域初始化单独建立一层Env循环结束即丢弃break或continue在循环外使用不报错控制流信号没有得到上层循环接收顶层执行时收到BREAK/CONTINUE信号直接抛“非法的break/continue”错误变量名拼写错误导致“未定义变量”变量查找逻辑只在当前作用域查找没有沿parent向上查检查lookup()是否实现了while (env)的链条遍历空字符串、0、空数组的条件判断结果和预期不符真值规则未统一定义明确真值规则并在evaluate条件处统一调用isTruthy()语句结束没消费}导致循环里丢失块边界块语句的右括号未在parseBlockStmt尾部消费在parseBlockStmt末尾用consume(})并确保错误信息提示行号这套表里的问题几乎每个都是我或身边同事在写解释器时真实撞过的。把它打印出来贴在显示器旁边比翻半天资料有效得多。8. 一个后续计划和建议按系列规划下一篇会进入函数定义与调用然后是闭包。这两块内容全都依赖本篇的作用域环境链设计。所以如果你在实现时发现环境链的结构不太得劲建议在本篇就调整好别拖到函数那一篇再返工。函数实现至少要做好两个准备一是FunctionValue可以把环境挂在内部二是参数绑定需要和变量声明走不同的创建路径避免重复声明的校验误伤函数参数。调试验证时的小技巧多准备几个小脚本作为“回归测试”比如一个覆盖所有语句类型的跑通用例一个只测循环边界的用例一个专门在嵌套块里反复声明同名变量的用例。每改一遍解释器就把这些用例全跑一遍。实现编译器/解释器的乐趣恰恰在于你越快地让程序“出问题”你越早地接近正确答案。