多彩编程 多彩编程MZPH · CODE BLOG
ARTICLE DETAIL

文章详情

深耕前端与后端开发技术的一线实战笔记与踩坑复盘。

编译器前端实战:递归下降、AST构建与错误恢复指南

编译器前端实战:递归下降、AST构建与错误恢复指南 简介一份面向编译原理课程设计的实现报告适合计算机专业本科生或需要在课设中完成编译器前端模块的读者参考。内容围绕简单文法编译器前端展开涵盖词法分析、递归下降语法分析、语义分析、四元式中间代码生成并扩展了常量、数组、if-else 与 while 语句等文法同时简述后端目标代码生成流程可帮助理解从源码到中间代码的完整构造思路。文中包含设计任务、总体流程、数据结构与算法、程序流程图、实验结果及结论等模块并给出递归子程序调用栈快照与四元式生成实例能直观看到编译过程的关键处理。资源为 1 个 docx 文档压缩包约 381KB便于直接查阅或对照课程设计要求修改复用该报告已有 693 人学习适合作为课程设计选题、报告结构或代码实现方面的参考样例。1. 一个“简单”文法编译器前端难点到底在哪很早之前A同学给我看他自己写的编译器前端词法分析器用正则硬扫语法分析器选了经典的递归下降。代码写得很规矩一跑却出了怪事解析“12*3”这种三行表达式结果永远是把加法放在根节点乘法缩在右子树里优先级整个是反的。再试括号程序直接栈溢出评论区一句“这是传统的左递归问题”把他晾在原地。这类问题在所谓“简单”的文法编译器前端里特别常见。它看起来只有词法、语法、抽象语法树三段真动手时坑全在“文法”与代码的缝里左递归、公共前缀、优先级分层、回溯、错误定位哪一件不处理都跑不顺。这篇笔记就按我实际搭一个最小前端时的顺序来写先讲模块怎么切、文法怎么设计再给一个能运行的递归下降实现最后盘点那些不看会翻车的边角问题。它不打算做完整语言的编译器目标是把“简单文法”的前端从能跑变成扛得住用适合正在学编译原理的同学也适合要给自己的小语言快速搭前端的从业者。2. 先把前端流水线画清楚词法、语法与AST的职责与选型很多人一上来就写代码结果词法和语法的边界反复改今天觉得数字识别该归语法管明天又觉得括号匹配该在词法里做。其实前端顺序很固定源码先进词法分析器产出 token 流token 流进语法分析器按文法产生抽象语法树AST。中间不需要第二个接口AST 就是前端交付给后端的唯一产品。把这条线画清后面的大部分选择都是顺理成章的。2.1 词法分析器手写扫描、正则库还是自动生成器词法分析器的任务不是“看懂代码”而是把字符串切成有类型的 token。它一般只做四件事跳过空白和注释、识别一个字面量数字、标识符、识别一个符号、-、*、/、括号、分号以及记录这个 token 在源码中的行号和列号。对于一个 token 类型保持在 20 个以内、不带复杂字符串转义的文法我通常直接手写一个扫描循环。理由有三第一线性扫描一个字符一个字符地推进逻辑透明出错时用 debugger 跟一遍就能看出问题第二报错信息可以精确到“第几行第几列的某个字符无法识别”这对后面的语法报错非常关键第三不需要维护任何生成配置改一个 token 就是改几行代码的事。反过来如果哪天要支持多行字符串、嵌套注释、模板字符串这类状态敏感的语法手写扫描会迅速变成状态机地狱那时候用 flex 这类自动生成器或者成熟的词法库更划算它们在长输入和复杂状态切换上已经被大量验证过。也有一种中间做法在 Python、JS 这类语言里用正则库一条一条地匹配。它适合快速验证但有个硬伤——大部分正则库默认从当前位置开始匹配并返回第一个成功项顺序写得稍有偏差if和ifx就会被切错而且正则引擎的内部回溯在遇到超长输入时很难控制。所以我自己的规则很简单演示可以工程上不推荐。2.2 语法分析器递归下降、LL(1) 表驱动还是 LR 生成器语法分析器的选择比词法更影响开发节奏。常见路线四条手写递归下降、LL(1) 表驱动、LR(1)/LALR 生成器yacc/bison 这类、以及 ANTLR 这种基于自适应 LL(*) 的生成器。我手头这个“简单文法”项目默认选手写递归下降因为它和文法结构是一一对应的文法里的一条产生式在代码里就是一个函数产生式右侧的每个符号就是函数里的一个调用或匹配动作。这种对应关系让排查非常舒服——文法第 3 行有问题直接去第 3 个函数里找状态。表驱动虽然也适合 LL(1)但那张预测分析表是二维的每次想加一条产生式得先重新算 FIRST 和 FOLLOW再翻表格心智负担比递归下降大得多。LR 生成器能处理更大的文法集合可一旦出现冲突报错信息像天书对“简单”项目来说属于杀鸡用牛刀还磨刀。那什么时候该换工具我的判断标准是文法迭代速度。如果一周要改十几次文法手写函数跟着改十几次太累这时用 ANTLR 这类生成器改完文法重新生成代码反而效率最高。简单项目里更常见的情形是文法基本稳定只需要在函数里不断调整构建 AST 的动作递归下降依然是最舒服的姿势。2.3 token 与 AST 的数据契约先定死再动手前后端之间必须有一份稳定的数据契约否则今天给 parser 一个字符串数组明天改成带位置的字典接口每动一次两侧的代码全要跟着改。我一般把契约固定成两个结构。token 最小结构是四元组kindtoken 类型如NUM/PLUS/IDENT、value字面量原文或解析后的值、line、col。其中col我习惯记“该 token 起始字符的列号”而不是结束位置因为报错要指出的是“从哪里开始出错”。AST 节点最小结构是三元组type节点类型如binop/number/name、value可选保存运算符或字面量值、children有序子节点列表。注意区分语法树和 AST语法树会保留每一个产生式的展开痕迹包括很多冗余节点AST 则把括号、分隔符这类纯语法信息丢到结构里比如(12)*3的括号在 AST 中根本没有节点它的层级关系直接把“括号内先算”表达掉了。后端的语义分析和代码生成拿到的应当只是 AST而不是 token 流或语法树这个约定越早定下来越好。3. 文法设计先行分层、消左递归与提左因子决定天花板我写前端的顺序和大部分人相反先写文法再写代码。文法不是写代码之前的文档它是前端的地基分层分不好后面代码怎么写都别扭。上机验证过太多次文法设计阶段省下的半小时会在调试阶段用两小时还回去。3.1 按运算优先级把文法分成三层以最常用的四则运算表达式为例最简单的做法是把优先级直接分层嵌入文法。标准 EBNF 写法如下expr :: term (( | -) term)* ; term :: factor ((* | /) factor)* ; factor :: NUMBER | IDENT | ( expr ) ;这里的关键是“层”的顺序优先级最低的运算符放在最外层优先级最高的放在最底层解析factor作为原子单元要么是数字、变量要么是用括号包裹的整个表达式。expr处理加减时它的运算对象是term而term已经先把乘除算完了所以12*3解析出来一定是1 (2*3)的结构。这个分层还被另一个事实反推着文法中每多一层递归下降代码里就多一个函数。所以“简单”不是指层数少而是指每层只解决一件事。等你要往语言里加比较运算、逻辑运算时照这个模式继续往上叠层or_expr包and_exprand_expr包equality_expr一层一个职责优先级天然成立。3.2 左递归和公共前缀文法的两处必改点教科书上写表达式文法常写成expr :: expr term这叫做直接左递归因为产生式左侧的非终结符一开头又出现了自己。递归下降函数一旦照这个文法写解析第一个 token 就会无限调用自身直到栈溢出。所以实现前必须把它改成右递归或 EBNF 循环式expr :: term expr_tail ; expr_tail :: ( term) expr_tail | ε ;这个改法保持了“加减是左结合”的语义同时让递归下降在每一层只消耗一个运算符再递归下降到尾部不会死循环。实际写代码时expr_tail很少单独做成函数而是直接用 while 循环代替这点下一章会看到。另一个必改点是公共前缀。比如文法里同时有if ( expr ) stmt和if ( expr ) stmt else stmt两条产生式它们都以if ( expr ) stmt开头。如果照抄递归下降解析到if之后必须做出选择但当下根本没有足够信息判断后面有没有else只能往两条路都试——这是回溯的根源。解决办法是提取左因子stmt :: if ( expr ) stmt else_part ; else_part :: else stmt | ε ;把公共前缀提出来把“有没有 else”这个决策推迟到else_part处处理。这样解析器始终是单路径的不用试错也不会有指数级回溯的隐患。3.3 优先级靠文法分层结合性靠递归方向两者别混这是新手最容易混的地方。优先级解决的是“先算谁”结合性解决的是“同优先级时从左还是从右算”。expr :: term ((|-) term)*写成循环默认是左结合因为每读到一个运算符就把左边的结果和右边的term合成新节点天然形成左深树。如果需要一个右结合运算符比如赋值或乘方^做法不是在这个循环里做特殊判断而是单写一层右递归文法assign :: IDENT assign | expr ;这里assign右侧又出现assign递归下降解析时会一直向右展开形成右深树。把“结合性”放到文法层代码层就只管照着递归方向建节点两者一一对应调试时不至于为了一个运算符写一堆 if 特例。我见过有人为了省钱在平铺文法上用代码手动调整左右子树结果打印 AST 一看有的节点前序对有的后序对根节点位置还随输入长度变化最后整层返工。4. 手写递归下降落地可跑通的最小前端与关键参数到这一步文法已经定了表达式语言支持数字、变量、四则运算、括号。下面给一个能直接运行的最小实现按照上一章的文法分层来写。语言用 Python原因是结构表达清晰、跑起来零依赖核心逻辑可以照搬到任何语言。4.1 词法部分Token 定义与线性扫描器先定义 token 结构和词法分析器把行号、列号在扫描时记准。class Token: def __init__(self, kind, value, line, col): self.kind kind # token 类型NUM / IDENT / PLUS / MINUS / STAR / SLASH / LPAREN / RPAREN / EOF self.value value # 字面量原文例如 123、 self.line line # 起始行号从 1 开始 self.col col # 起始列号从 1 开始 class Lexer: def __init__(self, text): self.text text self.pos 0 # 当前扫描位置指向下一个待处理字符 self.line 1 self.col 1 def _advance(self): ch self.text[self.pos] self.pos 1 if ch \n: self.line 1 self.col 1 else: self.col 1 return ch def peek(self, offset0): idx self.pos offset if idx len(self.text): return return self.text[idx] def next_token(self): while self.peek() and self.peek().isspace(): self._advance() if self.pos len(self.text): return Token(EOF, , self.line, self.col) line, col self.line, self.col ch self.peek() if ch.isdigit(): raw while self.peek().isdigit(): raw self._advance() return Token(NUM, raw, line, col) if ch.isalpha() or ch _: raw while self.peek().isalnum() or self.peek() _: raw self._advance() return Token(IDENT, raw, line, col) if ch in -*/(): op self._advance() kind_map {: PLUS, -: MINUS, *: STAR, /: SLASH, (: LPAREN, ): RPAREN} return Token(kind_map[op], op, line, col) raise SyntaxError(f第 {line} 行第 {col} 列出现无法识别的字符 {ch!r})这个扫描器的核心参数有两个pos标记字符消费进度line/col随_advance维护。关键技巧是next_token里先把line, col存到局部变量再开始消费字符——因为_advance会改掉这两个值不提前保存所有 token 的位置都会变成结束位置。数字和标识符都采用“读到不再属于本类的字符为止”这就是最长匹配的朴素实现。注意peek只做预读不消费所以数字扫描时不会把下一个字符吞掉。4.2 语法部分递归下降与 match 模式parser 层只维护一个前瞻 tokencurrent指向当前正在处理的 tokenadvance消费它并更新。match是统一入口类型对得上就推进对不上就抛出带位置的语法错误。class Parser: def __init__(self, lexer): self.lexer lexer self.current None self.advance() def advance(self): self.current self.lexer.next_token() return self.current def match(self, kind): if self.current.kind ! kind: raise SyntaxError( f第 {self.current.line} 行第 {self.current.col} 列 f期望 {kind}实际 {self.current.kind} ) return self.advance() def parse_expr(self): node self.parse_term() while self.current.kind in (PLUS, MINUS): op self.advance() right self.parse_term() node Node(binop, op.value, [node, right]) return node def parse_term(self): node self.parse_factor() while self.current.kind in (STAR, SLASH): op self.advance() right self.parse_factor() node Node(binop, op.value, [node, right]) return node def parse_factor(self): if self.current.kind NUM: tok self.advance() return Node(number, tok.value, []) if self.current.kind IDENT: tok self.advance() return Node(name, tok.value, []) if self.current.kind LPAREN: self.advance() node self.parse_expr() self.match(RPAREN) return node raise SyntaxError( f第 {self.current.line} 行第 {self.current.col} 列 f意外的 token {self.current.kind} )注意parse_expr里 while 循环是如何实现旧文法expr_tail的每遇到一个加减号就把已经解析出的左节点和新的右节点合成binop。因为新节点总把旧节点放在children[0]所以左结合语义被完整保留。parse_term同理只是把优先级更低的运算对象换成factor。这样12*3解析时parse_expr第一次调parse_term后者先吃掉整个2*3加法节点自然挂在更外层。4.3 AST 节点与验证入口AST 节点仅需要type/value/children三个字段再加一个递归打印方法就够了。验证入口建议直接打印树形结构而不是只输出一个对象地址。class Node: def __init__(self, type, valueNone, childrenNone): self.type type self.value value self.children children if children is not None else [] def dump(self, indent0): line * indent f{self.type} if self.value is not None: line f {self.value} print(line) for child in self.children: child.dump(indent 1) def parse_source(text): lexer Lexer(text) parser Parser(lexer) ast parser.parse_expr() if parser.current.kind ! EOF: raise SyntaxError(f第 {parser.current.line} 行第 {parser.current.col} 列存在未消费的 token) return ast代码里最后一步检查EOF经常会被人漏掉。它的作用是保证整个输入都被消费完否则12 3这类多个表达式连写的输入会被静默接受只解析出前半段。parse_source是外部唯一入口下游拿到的只应是一个完整 AST。这套最小前端缺了语句层但如果要扩展只需仿照expr加一个parse_stmt函数文法改三行代码加三五行。5. 文法编译器前端排查五个翻车场景与补救办法前端跑不动、结果不对绝大多数不是代码写错而是文法与代码的隐式约定被破坏。下面五条都是我在各种“简单”文法项目里真实踩过的坑每条按现象、原因、解决的顺序拆开方便对照排查。5.1 递归深入后栈溢出或卡死现象解析12时直接RecursionError或者程序看起来卡住不动用调试器一看栈里全是同一个函数名。这个现象几乎可以断定是直接左递归漏网进了代码。原因文法写成expr :: expr term | term但递归下降的函数parse_expr第一行就调用了parse_exprtoken 还没有被消费每次调用都在原位置重进。解决改文法把左递归改成循环式expr :: term ((|-) term)*代码里用while而不是首行递归调用。快速排查方法检查每个 parse 函数的函数体确认第一个递归调用之前至少消费了一个 token如果一个函数在没有任何 token 消费的情况下调用自己基本就是左递归的代码化身。5.2 优先级错乱AST 结构不对现象解析12*3打印 AST 根节点是binop 没错但右子树居然也是binop 或者只有2乘号被挂在更下层解析(12)*3括号居然不起作用。原因绝大多数情况是文法分层没做term和expr共用同一个层级或者 parse 函数里把、*放在同一个 while 循环内处理。解决回到第 3.1 节的文法逐层确认expr - term、term - factor的调用链。AST 验证我用一个土办法把1*23、12*3、(12)*3三组输入并排打印肉眼检查根节点和左右子树的运算符任何一组的根节点运算符与预期优先级不符就沿着调用的层数往上查。5.3 语法报错的位置永远指向行首或文件末尾现象错误信息写“第 1 行第 1 列”或者明明在表达式中间出错位置却指向整个输入的末尾。原因Token 不带位置信息或者词法分析器在消费完字符后才记录line/col导致每个 token 的位置都是结束位置又或者 parser 报错时拿的是advance之后的current。解决词法层在next_token开头先把当前line/col存下来带着它构造 Tokenparser 层在做假设时用当前 token 的位置。注意一个隐蔽细节如果报错逻辑里先advance再取错误位置位置会顺移到下一个 token所以匹配失败要先取current.line/col再决定是否推进。5.4 输入稍长就慢得不像线性现象解析一个几百行的程序还能忍受到几千行时肉眼可见地越跑越慢甚至出现指数级膨胀。原因解析器不是单路径的它在某些决策点走了一条错路发现失败再回头如果公共前缀没有提干净比如if语句的两个变体共用了大量前缀每次遇到if都要把整棵子树尝试两遍复杂度直接炸开。解决先做第 3.2 节的提取左因子把所有“先试 A 再试 B”的分支改成“读完公共前缀再决策”。还有一个常见误用在factor里先试着按数字解析失败再按变量解析这种局部回溯其实无害因为消耗的是不同 token 序列真正危险的是两个分支共享同一段 token 消耗那才是指数级的来源。排查时看 parse 函数里有没有“尝试性调用”有的话重点检查两个分支是否在同一 token 位置开始。5.5 标识符与关键字互相吞噬数字边界切错现象把ifx识别成关键字或者123abc没有报错而是拆成123和abc两个 token1.2.3这种非法数字也可能被部分接受。原因词法分析器按顺序匹配规则如果关键字正则排在标识符前面任何以关键字开头的变量都会被吞数字扫描循环没有判断终止字符是否为字母或小数点。解决一类修复路径是“先识别完整标识符再查关键字表”因为ifx是完整标识符不在表里自然不会被误伤数字扫描则要求循环只在连续数字内推进遇到字母或第二个小数点立即结束并报错。边界处理还有个参数可调是否允许数字后紧跟字母报错。我的习惯是报错因为123abc几乎一定是用户漏写了运算符静默拆成两个 token 会把错误埋到语法层导致报错信息看不懂。6. 让前端更扛错错误恢复、同步 token 与 AST 验证前端的价值不只体现在能解析合法代码还体现在遇到非法代码时能继续报出更多错误。一个错误就停的解析器在长文件上体验极差用户修完第一个错还要再次编译才知道第二个错在哪。所以我会在 parser 里加上最小限度的错误恢复做法是经典的 panic mode。所谓 panic mode就是在语法错误发生后不停留在原地纠结而是跳过一批 token直到遇到一个“同步 token”再继续解析。同步 token 的选择要配合文法边界语句结束的分号、右括号、EOF都适合。下面这段是expect的恢复版本可以替换基础版match在语句层使用。SYNC_KINDS {SEMI, RPAREN, EOF} def expect(self, kind): if self.current.kind kind: return self.advance() self.errors.append( f第 {self.current.line} 行第 {self.current.col} 列 f期望 {kind}实际 {self.current.kind} ) while self.current.kind not in SYNC_KINDS: self.advance() return None注意这个版本只适合语句层的匹配不能用在表达式内部。表达式里误吃一个右括号后面整段结构都会乱而语句层用分号同步能保证错误后至少能继续识别下一条语句。错误恢复后的 AST 是不完整的下游做语义分析前必须检查errors列表非空就只报错不继续。我把错误收集和 AST 构建解耦就是为了避免“AST 缺了一截还拿去分析”的情况。验证方面除了第 4 章的 AST 打印我还会维护一组“用例对照表”每个用例保存预期根节点类型和错误数。正常用例检查优先级和结合性异常用例检查报错位置是否精确。比如12*3期待根节点binop 12 3期待错误且错误位置指向3这类表跑一遍胜过手动点十次。最值得留意的验证是“改动文法后回归全表”因为前端改一处文法往往牵连 parser 函数和错误位置不回归很难发现某个角落里优先级悄悄变了。这段路我走得不算顺。最早做前端时我也是先写代码再补文法结果一半时间花在追优先级错乱和栈溢出的玄学问题上后来改成“文法先行、代码对照文法”同样的功能只用一半时间落地报错还更准。每当有人拿着调试到崩溃的前端来找我我第一句问的永远是“你的文法文件在哪”。先让文法立住代码才有资格谈踩坑。希望这些场景能帮你在自己的前端里少绕几个弯路。本文还有配套的精品资源点击获取
返回列表