☰
递归下降分析器实战:从消除左递归到LL(1)文法实现
2026/10/27 2:11:30 网站建设 项目流程

简介:面向计算机专业编译原理课程学习者与实验备考者的语法分析实验资料,聚焦自上而下的递归下降分析方法,帮助理解编译器前端从词法分析到语法分析的关键衔接。压缩包内共1个doc文档,约80KB,完整记录实验目的、文法规则定义、消除左递归后的产生式改写、各非终结符FIRST集与FOLLOW集求解表,以及递归下降分析程序的Java实现代码与运行结果。内容围绕简单语言文法展开,涵盖声明语句块、表达式、赋值语句等产生式的递归函数设计,并说明在词法分析器中新增float关键字识别、种别编码设为26的处理方式,可对照验证LL(1)文法判定与解析流程。已有533人学习,适合需要完成同类实验、理解递归下降程序结构与排错思路的学生参考。

1. 从一份能跑通的递归下降分析器说起

很多人学编译原理,卡在语法分析这一关:书上把 FIRST 集、FOLLOW 集、LL(1) 讲得头头是道,真让你写一个能读文件、能报错、能跑出结果的程序,就不知道从哪下手了。这份实验资源正好补上这个缺口——它给了一套完整的 Java 实现,把「消除左递归 → 求 FIRST/FOLLOW 集 → 词法分析 → 递归下降分析」这条链路全部落地,输入一个.txt源文件,输出词法单元序列和文法分析成功/失败的结论。适合两类人:正在做编译原理课程实验、需要一份能对照着改的学生;以及想快速回顾自上而下分析实现细节的开发者。代码不长,但每个函数对应一条产生式,结构非常直白,拿来当模板改文法最省事。

2. 文法改造与 LL(1) 判定:先把左递归拆干净

2.1 原始文法长什么样,为什么要改

实验给的是一个简化语言的语法规则,核心产生式包括函数体、声明语句块、声明语句、可执行语句块、赋值语句和三层表达式。原始写法里<声明语句块> → Ɛ | <声明语句> <声明语句块>是右递归,本身不致命;真正麻烦的是表达式部分<表达式> → <表达式> + <表达式2> | ...,这是典型的直接左递归。递归下降分析器是「看到什么符号就调哪个函数」的自顶向下方法,如果文法左递归,函数会无限自我调用,栈直接爆掉。所以第一步必须做文法改造,把左递归消掉,同时保证改造后的文法是 LL(1) 的——也就是每个非终结符在只看一个向前看符号的情况下,能唯一确定选哪条产生式。

改造后的文法在资源里写得很清楚,用单字母非终结符表示:

A → { M N } M → ε | P M P → D i ; D → t | f N → ε | Q N Q → i = E ; E → T E' E' → + T E' | - T E' | ε T → F T' T' → * F T' | / F T' | ε F → ( E ) | i | d

这里t代表int,f代表float,i代表标识符,d代表整数常量。改造的关键动作是把E → E + T | T这类左递归转成E → T E'、E' → + T E' | ε的右递归形式,T和T'同理。这样每个函数在入口处只需要看当前 token,就能决定走哪条分支,不会出现「先递归再判断」的死循环。

2.2 FIRST 集和 FOLLOW 集的推导与验证

资源里给了一张完整的 FIRST/FOLLOW 表,这是判断 LL(1) 的依据。我把它整理成下面这张表,方便对照:

产生式FIRST 集FOLLOW 集
A → { M N }{M → ε | P M
P → D i ;t, ft, f
D → t | ft, fi
N → ε | Q Nε, i}
Q → i = E ;ii, }
E → T E'(, i, d#, i, }, )
E' → + T E' | - T E' | ε+, -, ε#, i, }, )
T → F T'(, i, d+, -, #, i, }, )
T' → * F T' | / F T' | ε*, /, ε+, -, #, i, }, )
F → ( E ) | i | d(, i, d+, -, *, /, #, i, }, )

验证 LL(1) 的规则是:对同一个非终结符的任意两条候选产生式 α 和 β,要求FIRST(α) ∩ FIRST(β) = ∅;如果某条产生式能推出 ε,还要额外要求FIRST(另一条) ∩ FOLLOW(该非终结符) = ∅。拿M → ε | P M举例,FIRST(P M) = {t, f},FOLLOW(M) = {i},两者不相交,所以 M 的选择是确定的。再看E' → + T E' | - T E' | ε,两个非 ε 分支的 FIRST 分别是{+}和{-},ε 分支靠 FOLLOW(E') 里的#、i、}、)来兜底,同样不冲突。整张表逐条过一遍都满足条件,说明改造后的文法是 LL(1) 的,递归下降程序可以放心写。

提示:FOLLOW 集里的#是输入结束符,实际代码里用「下标走到 list 末尾」来隐式表示,不需要真的往 token 列表里塞一个#。

3. 词法分析器:把源文件切成 token 序列

3.1 字符扫描与关键字识别

递归下降分析器吃的不是原始字符,而是词法分析器产出的 token 序列。资源里的analyze(char[] chars)方法干的就是这件事:逐字符扫描,跳过空白,识别标识符、整数、关键字和界符,统一塞进一个ArrayList<String> list。关键字识别靠isKey方法,把int、float、if、void、main放在数组里逐个比对,返回下标加一作为种别编码。实验要求里特别提到要加入float的识别并设置种别编码为 26,这份代码用isKey(arr) == 2把float映射成f,思路是一致的,只是编码方式换成了单字母符号,方便后续文法函数直接比对。

public static int isKey(String str){ String keyWord[] = {"int","float","if","void","main"}; for(int i=0;i<keyWord.length;i++){ if(keyWord[i].equals(str)) { return i+1; // int→1, float→2, if→3 ... } } return -1; // 不是关键字,按标识符处理 }

这段逻辑很朴素,但有个细节值得注意:isKey返回的是「数组下标 + 1」,所以int对应 1、float对应 2。调用方根据返回值决定往 list 里放t还是f,其余情况一律放i(标识符)。这种「关键字优先、否则标识符」的顺序不能反,否则int会被当成普通标识符吞掉,后面的声明语句就匹配不上了。

3.2 标识符、整数与界符的分支处理

主扫描循环按字符类型分派:字母开头就一路吃字母和数字,拼成完整单词再查关键字表;数字开头就连续吃数字,整体标记为d;剩下的界符用switch逐个匹配( ) { } = + - * / ; !,每个都原样放进 list。遇到无法识别的字符直接打印提示并System.exit(0)退出。

else if(isLetter(ch)){ while(isLetter(ch)||isDigit(ch)){ arr += ch; ch = chars[++i]; } i--; // 回退一格,抵消多读的那个字符 if(isKey(arr)==1) list.add("t"); else if(isKey(arr)==2) list.add("f"); else list.add("i"); } else if(isDigit(ch)){ while(isDigit(ch)){ arr = arr + ch; ch = chars[++i]; } i--; list.add("d"); }

i--这个回退是手写词法分析器的经典操作:while循环在读到第一个不属于当前单词的字符时才退出,此时i已经指向了下一个单词的首字符,必须退回去让外层for重新处理。漏掉这一行,相邻两个 token 之间就会丢字符,表现为「明明输入正确却分析失败」,属于高频翻车点。另外整数常量统一记成d,不保留具体数值,因为这份文法的表达式只关心「是不是一个操作数」,不关心值是多少,简化了后续处理。

4. 递归下降分析器:一个非终结符一个函数

4.1 函数与产生式的一一对应

整个分析器的骨架就是「每个非终结符写一个static void函数」,函数体严格按照产生式右部的顺序展开:遇到终结符就比对并前进下标,遇到非终结符就调用对应函数。以最外层的A()为例,产生式是A → { M N },代码就是先匹配{、再调M()、再调N()、最后匹配}。

static void A(){ // A → { M N } if (list.get(index).equals("{")) { index++; M(); flag = 1; N(); if (list.get(index).equals("}")) { index++; } } }

index是全局的 token 游标,所有函数共享,匹配成功就index++往后挪。flag是这份代码里最关键的一个状态位,下一节细说。M()和N()都是「可空」的非终结符,产生式带 ε 分支,所以它们的函数体里必须先尝试匹配实分支,匹配不上就当作走了 ε,直接返回,不能报错。

4.2 flag 标志位:解决可空产生式的重复调用

资源正文里专门记录了一个踩坑:M → ε | P M、P → D i ;、D → t | f这三条会产生反复循环调用,导致后续语句分析不下去。原因在于M()的实现是「先调P(),如果flag == 1就再调M()」,而P()内部调D(),D()在匹配失败时会把flag置 0 并回退index。如果flag不重置,M()就会在「P 失败 → 但 flag 还是旧值」的情况下继续递归,形成死循环。

static void M() { // M → ε | P M P(); if(flag == 1) M(); // 只有 P 真正匹配成功才继续递归 } static void D() { // D → t | f if(list.get(index).equals("t") || list.get(index).equals("f")) { index++; } else { flag = 0; // 匹配失败,标记并回退 index--; } }

解决思路是:flag初始为 1,D()匹配失败时置 0 并回退下标,M()看到flag == 0就不再递归,自然退出,相当于走了 ε 分支。A()在调用M()之前会把flag重置为 1,保证每一轮声明语句块的判断都是干净的。这套「标志位 + 下标回退」的组合是手写递归下降处理可空产生式时最常见的做法,比用异常做回溯轻量得多,代价是要小心每个入口都重置状态。

4.3 表达式三层结构与错误判定

表达式部分拆成E → T E'、T → F T'、F → ( E ) | i | d三层,对应代码里的E()/G()、T()/H()、F()。G()处理加减、H()处理乘除,都是「先看当前符号是不是运算符,是就吃掉并递归,不是就返回」,天然实现了运算符优先级和左结合。F()是递归的出口,负责匹配括号表达式、标识符或整数。

static void E(){ T(); G(); } // E → T E' static void G(){ // E' → + T E' | - T E' | ε if(list.get(index).equals("+") || list.get(index).equals("-")) { index++; T(); G(); } } static void F(){ // F → ( E ) | i | d if(list.get(index).equals("i") || list.get(index).equals("d")) { index++; } else if(list.get(index).equals("(")) { index++; E(); if(list.get(index).equals(")")) index++; } else { flag = 0; } }

最终判定在main里:A()跑完后,如果index == list.size(),说明所有 token 都被消费完,打印「文法分析成功」;否则打印「文法分析失败」。这个判定条件比只看flag更严格,能抓住「前缀匹配成功但后面还有多余 token」的情况,比如输入{int a;}后面多跟一个x,A()正常返回但index没走到末尾,照样判失败。

5. 跑起来与避坑:从改路径到看懂报错

5.1 环境准备与运行步骤

这份代码是纯 Java,没有第三方依赖,JDK 8 及以上都能编译。资源里main方法写死了文件路径D:\\JAVA EE 源代码\\Recursive descent analyzer\\ex.txt,直接跑肯定找不到文件,第一步就是改成自己机器上的路径,或者改成相对路径。

# 1. 准备源文件 ex.txt,内容示例: # { int a; a = 1 + 2 * 3; } # 2. 编译 javac RecurDesAnalyzer.java # 3. 运行(确保 ex.txt 在当前目录) java RecurDesAnalyzer

运行后会先打印「词法分析后,程序转化为:」和 token 序列,再打印分析结论。建议第一次先用一个极简的正确用例,比如{int a;},确认链路通了,再逐步加表达式和嵌套,这样出问题时容易定位是词法阶段还是语法阶段。

5.2 常见问题排查

现象一:程序直接抛IndexOutOfBoundsException。原因通常是输入末尾缺少闭合符号,或者list.get(index)在index已经等于list.size()时还被调用。递归下降代码里大量裸调list.get(index),没有边界保护。解决办法是在每个get之前加index < list.size()判断,或者干脆在 list 末尾补一个哨兵符号#,让越界变成「匹配不上」而不是崩溃。

现象二:明明输入正确却报「文法分析失败」。先看打印出来的 token 序列对不对。常见原因是词法阶段i--回退漏写,导致相邻 token 粘连;或者关键字表里没加float,float被当成标识符i,D()匹配f失败。对照 token 序列和文法产生式逐条核,比盯着分析器代码猜快得多。

现象三:带多条声明语句时只分析了第一条。这是flag状态没重置的典型表现。M()递归依赖flag == 1,如果上一轮D()失败把flag置 0 后没有在合适位置恢复,后续M()全部走 ε 分支,声明语句块被整体跳过。检查A()里M()调用前是否重置了flag,以及P()成功匹配后是否把flag恢复为 1。

现象四:表达式里1+2*3分析结果不符合预期。这份代码只做语法合法性判定,不建语法树、不算值,所以「结果」只有成功/失败两种。如果你期望它输出运算结果,那是把语法分析和语义分析混在一起了,需要自己在E()/T()/F()里加返回值或构建 AST,这是二次开发的事,不是原代码的 bug。

注意:main方法里reader.read(buf)读的是file.length()个字符,如果文件含中文或换行符编码不一致,长度可能对不上,建议统一用 UTF-8 保存源文件。

6. 二次开发:把「判对错」改成「建语法树」

原代码的定位是实验验证,只回答「这个输入合不合法」,不产出中间结构。真要做二次开发,最有价值的一步是让每个分析函数返回一个 AST 节点,把「匹配即前进」升级成「匹配即建节点」。改动量不大,但能把这份实验代码变成一个可复用的语法分析前端。

具体做法:给每个非终结符定义对应的节点类,比如ExprNode、TermNode、FactorNode,把static void改成返回节点对象。以F()为例,匹配到标识符时返回new FactorNode("id", token),匹配到括号时返回内部E()的结果,匹配失败返回null并置flag = 0。上层T()把F()的返回值和后续T'的结果组装成乘法/除法节点,E()同理组装加减节点。这样跑完一遍,根节点就是整棵语法树,后续做类型检查、常量折叠、甚至解释执行都有了抓手。

改造点原实现改造后
函数返回值void返回 AST 节点
错误处理flag = 0全局标志返回null+ 异常或错误列表
token 游标全局index封装进Parser对象,便于多文件复用
输入来源写死文件路径构造函数传入List<String>token 流

验证方法很直接:准备一组正确用例和一组错误用例,正确用例要求 AST 非空且 token 全部消费,错误用例要求返回null或抛出带位置信息的异常。再拿几个嵌套括号、多层乘除的表达式,人工核对 AST 结构是否符合运算符优先级。我一般会额外写一个printTree方法把树按缩进打出来,肉眼扫一遍比断点调试快。

血泪经验是:改返回值的时候,flag那套全局状态一定要同步清理,否则「返回了节点但 flag 还是 0」会让上层误判失败,这种 bug 藏得深、查起来费劲。从那以后我每次动这类带全局状态的分析器,都强制先把状态收进对象、跑一遍全量用例再往下加功能。希望这份拆解能帮你把实验代码真正用起来。

本文还有配套的精品资源,点击获取

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询