递归下降分析器手写实战:从文法改造到错误恢复的编译原理实验指南

发布时间:2026/10/14 17:07:56
递归下降分析器手写实战:从文法改造到错误恢复的编译原理实验指南 简介本资源为编译原理课程实验配套资料聚焦自上而下的语法分析重点讲解递归下降分析方法的完整实现过程适合正在学习编译原理、需要完成语法分析实验的高校学生及自学者参考。资源包内含1个doc文档压缩包约80KB文档中系统整理了实验目的、文法定义、消除左递归的改造步骤、FIRST集与FOLLOW集的求解过程并给出改造后文法的LL(1)验证结果。核心内容包括结合词法分析器构造递归下降分析程序其中词法分析器需增加对float关键字的识别并设置单词种别编码为26文档还附有Java实现的递归下降分析器代码涵盖A、M、P、D、N、Q、E、G、T等非终结符对应的递归函数及词法分析逻辑。目前已有531人学习下载读者可借此掌握消除左递归、计算FIRST与FOLLOW集、编写词法分析器及递归下降解析程序的完整流程深入理解编译器语法分析环节的关键技术。1. 递归下降分析为什么它是编译原理实验里最容易翻车也最值得手写的一环如果你正在做编译原理实验看到“自上而下的语法分析”这几个字大概率第一反应是这不就是给每个非终结符写一个函数吗但真动手写递归下降分析器的时候你会发现事情没那么简单——左递归一碰就死循环FIRST 集和 FOLLOW 集算错一个符号就全盘崩回溯写多了性能直接爆炸。我带过几届学生的课程设计也自己写过几个小型语言的解析器递归下降是那种“看起来最直观、写起来最考验基本功”的方案。它不需要你生成任何分析表也不需要引入额外的工具链纯手写代码就能把一个上下文无关文法变成可执行的解析程序。适合谁适合正在做编译原理实验、需要交一份能跑通的语法分析器代码和结果的同学也适合想真正理解“文法到程序”这一步映射关系的开发者。这篇笔记会把递归下降分析的完整落地路径拆开从文法预处理、FIRST/FOLLOW 集计算到每个非终结符对应的函数怎么写、怎么处理错误恢复再到实验报告里结果该怎么呈现。不绕弯子直接上能复现的操作。2. 递归下降分析的前置准备文法改造与集合计算2.1 为什么必须消除左递归和提取左公因子递归下降分析的核心限制是它要求文法不能有左递归否则对应的递归函数会无限调用自己。比如表达式文法E → E T | T直接写成parseE()里第一件事就是调用parseE()栈直接溢出。这不是代码写错了是文法本身不适合自上而下分析。消除左递归的标准做法是把A → Aα | β改写成A → βAA → αA | ε。对于E → E T | T改写后变成E → T E E → T E | ε提取左公因子则是为了解决另一个问题当多个产生式有相同前缀时递归下降分析器无法仅凭当前输入符号决定选哪条产生式。比如A → αβ | αγ需要改写成A → αAA → β | γ。这两步是递归下降分析的“入场券”不做的话后面写什么都是白费。我一般会在实验报告里单独列一节写文法改造过程因为这是评分点也能说明你理解了自己在干什么。2.2 FIRST 集和 FOLLOW 集的程序化计算FIRST 集和 FOLLOW 集是递归下降分析器里“选产生式”的依据。FIRST(A) 是从 A 出发能推导出的所有可能的开头终结符集合FOLLOW(A) 是在某个句型中紧跟在 A 后面的终结符集合。当 A 的产生式有 ε 时就需要看 FOLLOW(A) 来决定是否选用这条 ε 产生式。手工算几个符号还行但实验里文法一复杂手工算必错。我一般会写一个小的集合计算脚本用迭代法求不动点。下面是一个 Python 实现的骨架# 计算 FIRST 集和 FOLLOW 集 # grammar: dict, key 为非终结符, value 为产生式右部列表(每个右部是符号列表) # terminals: 终结符集合 # start_symbol: 开始符号 def compute_first(grammar, terminals, start_symbol): first {nt: set() for nt in grammar} # 终结符的 FIRST 集就是它自己 for t in terminals: first[t] {t} changed True while changed: changed False for nt, productions in grammar.items(): for prod in productions: if not prod: # 空产生式 if ε not in first[nt]: first[nt].add(ε) changed True continue all_nullable True for sym in prod: if sym not in first: first[sym] {sym} before len(first[nt]) first[nt] | (first[sym] - {ε}) if len(first[nt]) ! before: changed True if ε not in first[sym]: all_nullable False break if all_nullable and ε not in first[nt]: first[nt].add(ε) changed True return first def compute_follow(grammar, terminals, start_symbol, first): follow {nt: set() for nt in grammar} follow[start_symbol].add($) # 结束符 changed True while changed: changed False for nt, productions in grammar.items(): for prod in productions: for i, sym in enumerate(prod): if sym not in grammar: continue # 看 sym 后面的符号 rest prod[i1:] if rest: first_rest set() nullable True for s in rest: first_rest | (first.get(s, {s}) - {ε}) if ε not in first.get(s, {s}): nullable False break before len(follow[sym]) follow[sym] | first_rest if nullable: follow[sym] | follow[nt] if len(follow[sym]) ! before: changed True else: before len(follow[sym]) follow[sym] | follow[nt] if len(follow[sym]) ! before: changed True return follow这段代码的逻辑说明compute_first用迭代法不断合并每个产生式右部第一个符号的 FIRST 集直到不再变化。如果某个符号的 FIRST 集包含 ε就继续看下一个符号。compute_follow则扫描每个产生式中非终结符后面的部分把后面符号的 FIRST 集去掉 ε加入当前符号的 FOLLOW 集如果后面所有符号都可空就把左部非终结符的 FOLLOW 集也加进来。参数说明grammar字典的 key 是非终结符value 是产生式列表每个产生式用符号列表表示空产生式用空列表[]表示。terminals是终结符集合start_symbol是文法开始符号。运行后得到的first和follow字典可以直接用于后续的递归下降函数中做产生式选择。提示集合计算脚本建议单独跑一遍把结果打印出来和手工计算对照。我见过太多人因为 FOLLOW 集少算了一个$导致整个解析器在文件末尾报错。3. 递归下降分析器的代码实现从函数骨架到完整解析3.1 每个非终结符对应一个函数骨架与产生式选择递归下降分析器的结构非常直接每个非终结符写一个函数函数体内根据当前输入符号和 FIRST/FOLLOW 集选择产生式然后依次处理右部符号。遇到终结符就匹配并前进遇到非终结符就递归调用。下面以一个简单的算术表达式文法为例展示完整的解析器实现。文法已经做过左递归消除和左公因子提取E → T E E → T E | ε T → F T T → * F T | ε F → ( E ) | id对应的 Python 递归下降解析器# 递归下降解析器示例 # 输入: token 列表, 每个 token 是 (类型, 值) 元组 # 输出: 解析成功返回 True, 否则抛出异常 class Parser: def __init__(self, tokens): self.tokens tokens self.pos 0 self.current tokens[0] if tokens else ($, $) def advance(self): self.pos 1 if self.pos len(self.tokens): self.current self.tokens[self.pos] else: self.current ($, $) def match(self, expected_type): if self.current[0] expected_type: self.advance() else: raise SyntaxError(f期望 {expected_type}, 实际 {self.current[0]} 在位置 {self.pos}) def parse_E(self): # E → T E self.parse_T() self.parse_E_prime() def parse_E_prime(self): # E → T E | ε if self.current[0] : self.match() self.parse_T() self.parse_E_prime() # 否则用 ε 产生式什么都不做 def parse_T(self): # T → F T self.parse_F() self.parse_T_prime() def parse_T_prime(self): # T → * F T | ε if self.current[0] *: self.match(*) self.parse_F() self.parse_T_prime() def parse_F(self): # F → ( E ) | id if self.current[0] (: self.match(() self.parse_E() self.match()) elif self.current[0] id: self.match(id) else: raise SyntaxError(f期望 ( 或 id, 实际 {self.current[0]} 在位置 {self.pos}) def parse(self): self.parse_E() if self.current[0] ! $: raise SyntaxError(f解析结束后还有多余符号: {self.current}) return True逻辑说明每个parse_X函数对应一个非终结符。parse_E直接调用parse_T和parse_E_prime因为 E 只有一个产生式。parse_E_prime检查当前符号是不是是就匹配并继续递归不是就默认走 ε 产生式直接返回。parse_F根据当前符号是(还是id选择不同分支。match函数负责消耗一个终结符并前进。参数说明tokens是词法分析器输出的 token 列表每个 token 用(类型, 值)表示。pos是当前 token 索引。current是当前 token当输入耗尽时用($, $)表示结束。注意parse_E_prime和parse_T_prime里的 ε 分支不需要显式写return因为函数自然结束就代表选择了 ε 产生式。但如果你在实验报告里要展示产生式选择过程可以在 ε 分支加一行打印语句。3.2 带错误恢复的解析器同步符号与紧急模式上面的解析器遇到错误直接抛异常实验里通常要求能继续解析并报告多个错误。常见的错误恢复策略是“紧急模式”发现错误后跳过输入符号直到遇到一个同步符号通常是 FOLLOW 集中的符号或语句结束符然后继续解析。下面是在match和产生式选择中加入同步恢复的版本class RecoveringParser(Parser): def __init__(self, tokens, follow_sets): super().__init__(tokens) self.follow_sets follow_sets # dict: 非终结符 - FOLLOW 集 self.errors [] def synchronize(self, follow_set): # 跳过符号直到当前符号在 follow_set 中或到达输入末尾 while self.current[0] ! $ and self.current[0] not in follow_set: self.advance() if self.current[0] $ and $ not in follow_set: pass # 到达末尾无法恢复 def match(self, expected_type, follow_setNone): if self.current[0] expected_type: self.advance() else: self.errors.append(f位置 {self.pos}: 期望 {expected_type}, 实际 {self.current[0]}) if follow_set: self.synchronize(follow_set) else: self.advance() # 简单跳过 def parse_E_prime(self): if self.current[0] : self.match(, self.follow_sets[E_prime]) self.parse_T() self.parse_E_prime() # ε 分支 def parse_F(self): if self.current[0] (: self.match((, self.follow_sets[F]) self.parse_E() self.match(), self.follow_sets[F]) elif self.current[0] id: self.match(id, self.follow_sets[F]) else: self.errors.append(f位置 {self.pos}: 期望 ( 或 id, 实际 {self.current[0]}) self.synchronize(self.follow_sets[F])逻辑说明synchronize方法不断前进直到当前符号属于给定的 FOLLOW 集或到达输入末尾。match在失败时记录错误并调用同步。每个非终结符函数在调用match时传入对应的 FOLLOW 集作为同步依据。参数说明follow_sets是预先计算好的 FOLLOW 集字典。errors列表收集所有错误信息最后可以统一输出。提示错误恢复是实验的加分项但不要为了恢复而恢复。如果同步符号选得不好解析器可能跳过大量正确代码导致后续错误报告全是误报。我一般会先用简单跳过策略跑通再逐步加同步。4. 递归下降分析的避坑与排查那些实验报告里不会写的血泪经验4.1 左递归没消干净现象是栈溢出原因是间接左递归现象解析器一运行就报RecursionError: maximum recursion depth exceeded或者直接卡死。原因文法里存在间接左递归比如A → BαB → Aβ这种不会在单个产生式里直接看到左递归但展开后仍然是左递归。解决把所有非终结符按顺序排列检查是否存在A ⇒ Aα的推导。消除间接左递归需要先代入再消除或者用算法统一处理。我一般会写一个简单的检测脚本对每个非终结符做深度优先搜索看能否回到自己。4.2 FIRST 集算错导致选错产生式现象是解析到一半报“期望 X 实际 Y”现象解析器在某个位置报错但手工检查输入明明符合文法。原因FIRST 集计算时漏掉了某个符号或者 ε 的处理不对。比如A → Bc | dB → ε | b如果 FIRST(B) 没算上 ε那么当输入是c时解析器不知道可以选A → Bc再让 B 走 ε。解决打印每个非终结符的 FIRST 和 FOLLOW 集和手工计算对照。重点检查含 ε 的产生式。4.3 回溯写太多导致性能崩盘现象是长输入解析极慢现象输入稍微长一点解析时间指数级增长。原因在产生式选择时用了回溯比如A → α | β先试 α失败再试 β而 α 和 β 又有公共前缀导致反复扫描同一段输入。解决提取左公因子让每个非终结符在每一步都能根据当前符号唯一确定产生式。递归下降分析本来就不应该大量回溯如果需要回溯说明文法不适合递归下降。4.4 词法分析和语法分析边界不清现象是解析器里混入了字符判断现象parse_F里出现if self.current_char i and self.next_char d这种代码。原因词法分析没做干净把标识符识别的工作推到了语法分析阶段。解决词法分析器输出 token 流语法分析器只处理 token 类型不处理字符。id是一个 token 类型不是两个字符。这个边界不清会导致代码混乱实验报告也显得不专业。4.5 错误恢复时同步符号选错现象是报了一堆无关错误现象输入里有一个小错误但解析器报了十几条错误后面全是误报。原因同步符号选得太宽泛比如用;作为同步符号但错误位置后面没有;解析器一路跳到文件末尾。解决同步符号应该选 FOLLOW 集里的符号并且限制跳过的范围。我一般会在同步时加一个计数器最多跳过 10 个 token 就强制返回避免无限跳过。5. 实验结果的呈现与验证让评分老师一眼看到你跑通了5.1 测试用例设计覆盖正常、边界和错误三类输入实验报告里的结果部分不能只贴一个“Hello World”级别的输入。我一般会设计三组测试用例第一组是正常输入覆盖所有产生式分支。比如id id * id、( id id ) * id、id。每个输入都要有对应的解析树或推导过程输出。第二组是边界输入比如空输入、只有括号的输入、连续运算符。这些用来验证 ε 产生式和错误处理。第三组是错误输入比如id * id、( id id、id id。这些用来展示错误恢复和报错信息。下面是一个测试驱动脚本的示例# 测试递归下降解析器 test_cases [ (id id * id, True), (( id id ) * id, True), (id, True), (id * id, False), (( id id, False), (id id, False), ] for expr, should_pass in test_cases: tokens tokenize(expr) # 假设已有词法分析器 parser RecoveringParser(tokens, follow_sets) try: parser.parse() result True except SyntaxError: result False status 通过 if result should_pass else 失败 print(f输入: {expr:20s} 期望: {should_pass} 实际: {result} [{status}]) if parser.errors: for err in parser.errors: print(f 错误: {err})逻辑说明对每个测试用例先做词法分析得到 token 流然后创建解析器并调用parse。根据是否抛出异常判断解析成功与否和期望值对比。最后打印错误列表。参数说明test_cases列表里每个元素是(表达式字符串, 是否应该解析成功)。tokenize是假设已经实现的词法分析函数。5.2 解析树的可视化输出文本缩进比图形更实用实验报告里经常要求输出解析树。用图形库画树当然好看但调试阶段文本缩进更实用。我一般会在每个parse_X函数进入和退出时打印缩进信息class TreeParser(Parser): def __init__(self, tokens): super().__init__(tokens) self.depth 0 def enter(self, name): print( * self.depth f进入 {name}, 当前符号: {self.current[0]}) self.depth 1 def exit(self, name): self.depth - 1 print( * self.depth f退出 {name}) def parse_E(self): self.enter(E) self.parse_T() self.parse_E_prime() self.exit(E)这样运行后能得到一棵文本形式的解析树直接贴进实验报告即可。比图形更清晰也更容易看出递归调用层次。5.3 和 LL(1) 分析表法的对比什么时候递归下降更划算递归下降分析和 LL(1) 分析表法都是自上而下的语法分析但适用场景不同。LL(1) 需要构造分析表适合用工具自动生成递归下降需要手写代码适合教学和理解原理。如果文法规模小、变化频繁递归下降改起来更快如果文法很大、产生式上百条手写递归下降会累死人这时候用分析表法或自动生成工具更合适。我在实验里一般会建议先用递归下降把原理跑通再用 LL(1) 分析表法做对比实验。这样报告里既有手写代码的细节又有自动生成的效率对比内容更充实。提示如果实验要求只做递归下降那就把重点放在错误恢复和解析树输出上。这两块最能体现你对递归下降的理解深度。5.4 一个具体技巧用装饰器统一处理进入退出和错误最后分享一个我常用的技巧用装饰器把enter、exit和错误处理统一起来避免每个函数里重复写。def parse_rule(func): def wrapper(self, *args, **kwargs): name func.__name__.replace(parse_, ) self.enter(name) try: result func(self, *args, **kwargs) self.exit(name) return result except SyntaxError as e: self.errors.append(str(e)) self.synchronize(self.follow_sets.get(name, {$})) self.exit(name) return None return wrapper class DecoratedParser(RecoveringParser): parse_rule def parse_E(self): self.parse_T() self.parse_E_prime() parse_rule def parse_T(self): self.parse_F() self.parse_T_prime()这样每个解析函数只需要写核心逻辑进入退出和错误恢复都由装饰器处理。代码量减少一致性更好。我一开始也觉得装饰器是“花架子”后来在实验里被重复代码折磨够了才老老实实加上。希望帮到你。本文还有配套的精品资源点击获取

关于本文作者

来自尧图内容编辑团队

尧图内容编辑团队 内容团队

尧图内容编辑团队

本文由尧图网络内容编辑团队执笔。团队由资深项目经理、前端工程师与设计师组成,所有内容均来自亲手交付的真实项目,先讲清问题、再给出可落地的解法。尧图深耕北京网站建设十年,服务过京华建材集团、智造科技等各行业客户,把一线经验沉淀为可复用的行业观察。

  • 十年建站经验,覆盖建材、制造、服务、文创等
  • 项目经理把关选题与事实准确性
  • 工程师与设计师联合撰写专业细节
  • 统一编辑规范,保证文风与排版一致
  • 每月复盘转化数据,迭代选题方向

延伸阅读

相关资讯与近期热门内容

深度阅读推荐

建站决策前值得细读的三篇

网站改版的5个关键决策
2024-08-12

网站改版的5个关键决策

什么时候该改版、改到什么程度、如何避免流量掉光,京华建材集团改版复盘给出答案。

获取专属建站方案

看完文章,把您的行业与预算告诉我们,免费获取一份量身定制的官网建设方案与报价。

立即免费咨询