简介:这份资源面向计算机专业学习编译原理的学生,聚焦自上而下语法分析中的递归下降分析法,帮助读者完成从文法改造到分析程序实现的完整实验流程。内容围绕消除左递归、计算各非终结符的FIRST集与FOLLOW集以验证LL(1)文法,并在词法分析器中加入float关键字识别(种别编码26),进而构造递归下降分析程序,涵盖A、M、P、D、N、Q、E、G、T等非终结符对应的递归函数实现与运行结果。资源包共1个doc文件,约80KB,以实验报告形式组织,包含实验目的、文法规则、改造后的产生式与FIRST/FOLLOW集表格、Java代码及分析结果,便于对照理解语法分析的完整环节。已有533人学习,适合需要完成同类实验或复习递归下降分析思路的读者参考。
1. 递归下降分析:为什么一个 flag 就能让死循环的解析器停下来
写递归下降解析器的人,几乎都会在同一个地方翻车:文法里明明写着M → ε | PM,代码照着抄成M(){ P(); M(); },一跑就栈溢出。这不是玄学,而是「空产生式 + 递归」组合出来的必然结果——P匹配失败时没有消费任何输入,M又无条件递归调用自己,输入指针原地不动,调用栈无限增长。这份编译原理实验资源的价值,恰恰在于它没有停在「消除左递归、求 FIRST/FOLLOW 集」的纸面推导上,而是把递归下降分析器真正跑起来,并且用一个flag标志位把上面这个死循环摁住了。搞懂这个 flag 为什么必须存在、它替代了 LL(1) 分析表里的哪一步判断,你就真正理解了自上而下语法分析的底层机制,而不是只会背「LL(1) 是自左向右、最左推导、向前看一个符号」。
2. 从文法到 LL(1):消除左递归和 FIRST/FOLLOW 集到底在算什么
递归下降分析器的代码结构,本质上是文法产生式的「直译」。但直译有两个前提:文法不能有左递归,否则函数第一句就调用自己;每个非终结符在面临多个候选产生式时,必须能靠向前看一个终结符唯一决定选哪条。这两件事分别对应「消除左递归」和「验证 LL(1)」,是写代码之前必须完成的准备工作,跳过任何一步,后面的解析器都会以某种方式崩掉。
2.1 左递归为什么会让递归函数直接爆栈
原始文法里<表达式> → <表达式> + <表达式2> | <表达式2>是典型的直接左递归。如果照抄成函数:
// 错误示范:直接照抄左递归文法 static void E() { E(); // 第一句就递归调用自己,输入指针没动 if (peek().equals("+")) { index++; T(); } }E()进入后立刻再进E(),index一次都没增加,调用栈在几毫秒内撑爆。消除左递归的标准手法是把A → Aα | β改写成A → βA'、A' → αA' | ε,把「递归在左边」变成「递归在右边」,这样函数体第一句变成消费输入或调用别的非终结符,递归调用发生在消费之后,栈深度被输入长度约束住。实验里E → TE'、E' → +TE' | -TE' | ε就是这个变换的结果,代码中对应E()调T()再调G()(即 E'),G()里先判断+/-再递归,顺序完全对得上。
2.2 FIRST 集和 FOLLOW 集:预测下一个产生式的依据
消除左递归只解决了「能不能递归」,没解决「该选哪条产生式」。递归下降靠的是每个非终结符函数里那一串if (peek().equals(...)),这些判断的合法性来自 FIRST 集:候选产生式右部能推导出的首个终结符集合必须两两不相交,否则向前看一个符号无法区分。当候选产生式能推导出 ε 时,还要看 FOLLOW 集——当前非终结符后面可能跟什么终结符,用这个集合来决定「是不是该走空产生式这条路」。
实验里给出的表就是这套推导的产物。以M → ε | PM为例,FIRST(PM) = {t, f}(因为P → Di,D → t | f),FOLLOW(M) = {i, },两者不相交,所以看到t/f就选PM,看到i/}就选ε。这个判断在代码里被简化成了flag机制,下一章会看到它具体怎么落地。E'的 FIRST 集是{+,-,ε},FOLLOW 是{#,i,},)},同样不相交,LL(1) 条件成立。把这些集合算清楚,不是为了交实验报告,而是为了确认「向前看一个符号」这个策略在这个文法上真的够用——如果 FIRST 集有交集,递归下降就得改成回溯或者换 LL(k)、LR,那是完全不同的实现路径。
2.3 词法分析器:语法分析的输入从哪来
递归下降函数里到处是list.get(index).equals("i")这种判断,list就是词法分析器的输出。实验要求在原词法分析器基础上增加float关键字识别,种别编码设为 26,代码里用isKey数组{"int","float","if","void","main"}实现,返回下标 +1 作为编码。这里有个容易忽略的设计点:语法分析器只认种别码,不认原始字符串。int被映射成t,float映射成f,标识符统一映射成i,整数映射成d,这样文法里的终结符和词法输出就对齐了,解析函数不需要关心「这个标识符叫什么名字」,只需要知道「它是个标识符」。这种分层是编译器前端的标准做法,词法层负责字符到 token 的映射,语法层负责 token 序列到语法结构的映射,两层之间用种别码解耦。
3. 递归下降分析器的实现:flag 标志位如何替代 LL(1) 预测表
把文法直译成函数,最直觉的写法是每个非终结符一个函数,函数里按产生式顺序尝试匹配。但实验代码里出现了一个不太「教科书」的东西:静态变量flag,初值 1,匹配失败时置 0,M()和N()里用if(flag==1)控制是否继续递归。这个设计不是随手加的,它解决的是「空产生式递归」和「失败传播」两个问题,理解它比背 LL(1) 定义更有价值。
3.1 空产生式 M → ε | PM 的递归陷阱
先看M()的代码:
static void M() { // M → ε | PM P(); if (flag == 1) M(); // 只有 P 匹配成功才继续递归 }如果写成无条件的P(); M();,当输入是}(即 M 应该走 ε 产生式)时,P()内部D()发现当前 token 既不是t也不是f,匹配失败,但index没有前进,M()又递归调用自己,无限循环。实验里的解法是:D()失败时把flag置 0 并回退index--,M()看到flag != 1就不再递归,自然退出,相当于选择了 ε 产生式。这其实就是 LL(1) 预测表里「FOLLOW(M) 包含当前 token 时选 ε」的运行时版本——只不过实验代码没有显式查表,而是用「子过程失败」这个信号来隐式判断。
3.2 flag 的传播语义:一次失败如何终止整条递归链
flag是静态变量,一旦某个深层函数把它置 0,整条调用链上所有if(flag==1)的判断都会失效,递归逐层返回,最终main里检查index == list.size()判断整体成败。这种「全局失败标志 + 逐层短路返回」的模式,是递归下降里处理错误传播最省事的写法,代价是错误定位能力弱——你只知道「失败了」,不知道「在第几个 token、期望什么、实际是什么」。工业级解析器通常会在失败点抛出带位置信息的异常,或者用错误恢复策略跳过 token 继续解析,实验代码为了简洁选了最粗暴的方案,但机制上是自洽的:flag承担了「预测失败」的信号,index--承担了「回退到失败前位置」的职责,两者配合让解析器在遇到不合法输入时能干净地停下来,而不是死循环或越界。
3.3 表达式部分的递归下降:E → TE' 的代码映射
表达式部分没有用 flag,因为E'、T'的产生式都是「看到运算符就消费并递归,否则走 ε」,天然不会死循环:
static void E() { // E → TE' T(); G(); // G 即 E' } static void G() { // E' → +TE' | -TE' | ε if (list.get(index).equals("+") || list.get(index).equals("-")) { index++; T(); G(); // 消费运算符后才递归,index 已前进 } // 否则什么都不做,等价于 ε }G()里递归调用发生在index++之后,输入指针一定前进了,所以不会无限递归。这正是消除左递归带来的直接好处:递归位置从「消费前」挪到了「消费后」。T()/H()的结构完全对称,F()处理括号和原子,遇到(就递归进E()再匹配),形成表达式的嵌套解析。整个表达式子文法是一棵「优先级分层」的递归结构:E管加减,T管乘除,F管原子,每层只处理自己那级运算符,低优先级运算符把操作数解析委托给高优先级层,这是递归下降处理表达式优先级最经典的写法,比查运算符优先级表再手动调度的方式直观得多。
3.4 词法分析与语法分析的衔接:list 和 index 的契约
整个解析器依赖两个静态状态:list存 token 序列,index是当前读位置。词法分析analyze()把源文件字符流转换成list,语法分析从index=0开始消费。这个契约有两个隐含要求:一是词法分析必须把整个文件读完再交给语法分析(实验代码确实是先analyze(buf)再A()),二是语法分析函数只能通过index++前进,不能随意跳读。P()里有个细节值得注意:
static void P() { // P → D i ; D(); if (list.get(index).equals("i")) { index++; } }产生式里P → Di后面其实还有个分号(原始文法<声明语句> → <数据类型> <标志符>;),但代码里只匹配了D和i,分号被词法分析器识别成;存进list后,语法分析没有消费它。这意味着index在P()结束后停在分号上,后续M()递归时P()再次进入,D()看到;匹配失败置flag=0,M()退出,N()开始处理可执行语句块。分号没被消费但也没导致失败,是因为flag机制让「匹配失败」变成了「走 ε 产生式」的信号。这种「文法写全、代码偷懒」的做法在实验里能跑通,但严格来说是不完整的——如果输入里声明语句后面缺分号,解析器不会报错,因为分号根本没被检查。这是实验代码的一个真实取舍:用 flag 换简洁,牺牲了部分错误检测能力。
4. 避坑与排查:递归下降分析器跑不通时先看这几处
递归下降的 bug 有个特点:现象往往是「栈溢出」「死循环」「明明输入合法却报失败」,但根因可能藏在文法改造、词法映射、flag 传播中的任何一环。下面几条是这类实验里高频出现的翻车点,按「现象 → 原因 → 解决」整理,照着排查能省不少时间。
4.1 现象:程序一跑就 StackOverflowError
原因几乎总是空产生式被写成了无条件递归,比如M(){ P(); M(); }或N(){ Q(); N(); }。当输入走到应该选 ε 的位置,子过程匹配失败但不消费 token,递归没有终止条件。解决方式是给递归加前置判断:要么像实验代码那样用flag标志位,子过程失败时置 0,递归前检查flag==1;要么显式查 FOLLOW 集,当前 token 在 FOLLOW 集里就直接返回不递归。前者实现简单,后者更贴近 LL(1) 理论,两种都能 work,关键是递归调用前必须有「输入已前进」或「明确该走 ε」的判断。
4.2 现象:合法输入被判失败,index 停在某个 token 上不动
先打印index和list.get(index),看解析器卡在哪个 token。常见原因是词法分析的种别映射和文法终结符对不上,比如文法里用t表示int、f表示float,但isKey数组顺序写错,float返回了 1 而不是 2,导致D()里equals("f")永远不成立。另一个高频原因是index--回退用错位置:D()失败时回退是为了让上层重新判断,但如果上层没有对应的「失败处理」逻辑,回退后又会进入同一个失败分支,形成「失败-回退-再失败」的循环。排查时在每次index变化处打日志,确认前进和回退成对出现。
4.3 现象:表达式解析结果不对,1+2*3被算成(1+2)*3
递归下降本身不负责求值,但如果实验要求顺带算表达式值,优先级错误通常来自函数调用层次写反:E()里直接调F()而不是T(),或者T()里没调F()而是直接处理了原子。正确的层次是E → T (('+'|'-') T)*、T → F (('*'|'/') F)*、F → 原子,每层只处理自己那级运算符,低优先级层把操作数委托给高优先级层。如果发现*和+优先级一样,检查是不是把T()和E()的调用关系搞混了。
4.4 现象:输入末尾多一个 token 就报失败,少一个反而成功
检查main里的成功条件index == list.size()。如果某个产生式末尾的终结符(比如声明语句的分号)在代码里没被消费,index会停在它前面,导致「输入合法但 index 没走到末尾」被判失败;反过来,如果代码多消费了一个 token,合法输入也会因为index越界或跳过关键 token 而失败。排查方法是把list完整打印出来,对照文法逐个 token 核对消费路径,确认每个终结符都有对应的index++。
5. 设计取舍:flag 机制的代价,以及什么时候该换一种写法
实验代码用flag静态变量解决空产生式递归,这个选择在「能跑通、代码短」这个目标下是合理的,但它暴露了递归下降分析器设计里一个绕不开的取舍:错误处理的粒度。flag是全局的,一次失败会让整条递归链短路返回,解析器只能告诉你「失败了」,给不出「第 5 个 token 处期望=实际是+」这种信息。工业级实现通常用异常携带位置和期望集合,或者在每个非终结符函数里维护局部错误状态,失败时记录上下文再向上抛,代价是代码量翻几倍。
另一个取舍是「查表」还是「硬编码」。实验代码把 LL(1) 预测逻辑直接写进了if判断和flag检查里,好处是直观、和文法产生式一一对应,坏处是文法一改就得改代码。更通用的做法是把 FIRST/FOLLOW 集和预测表做成数据结构,解析器驱动逻辑固定,换文法只换表。这两种路线在编译器前端里都有真实应用:手写递归下降(如很多语言的标准库解析器)偏硬编码,追求性能和可读性;表驱动 LL(1) 或 LR 偏数据驱动,追求文法可配置。实验代码属于前者,理解它的局限,比记住它的写法更重要。
我自己的习惯是,每次写完递归下降函数,先拿三个用例过一遍:一个合法输入、一个在空产生式位置结束的输入(比如只有{}的函数体)、一个在中间缺 token 的输入。第三个用例最能暴露flag传播和index回退的问题,因为合法输入往往掩盖了「失败路径没写对」的 bug。从那以后我每次手写解析器都强制走一遍这三类用例,比事后调试栈溢出省时间得多。这份实验资源把文法改造、FIRST/FOLLOW 推导、词法分析器扩展、递归下降实现和 flag 机制完整串了起来,代码和运行结果都在,适合拿来对照着自己跑一遍,把「为什么这么设计」落到每一行if和每一次index++上。希望帮到你。
本文还有配套的精品资源,点击获取