简介:这份资源面向计算机专业学习编译原理的学生,聚焦自上而下语法分析中的递归下降分析法,帮助理解语法分析在编译器设计中的作用与实现路径。内容围绕一个简单语言文法展开:先消除左递归并改造文法,再求各非终结符的FIRST集与FOLLOW集以验证LL(1)性质,随后在词法分析器中加入float关键字识别(种别编码26),最终构造递归下降分析程序并给出运行结果。资源包共1个doc文件,约80KB,完整收录实验目的、文法规则、改造过程、集合计算表、Java实现代码及解析结果,便于对照实验要求逐步复现。已有533人学习下载,适合需要完成同类实验或巩固递归下降分析流程的读者参考,可从中获取文法改造思路、集合推导方法与程序结构范例。
1. 从一份“跑不通”的文法说起:递归下降分析实验到底要交什么
很多人做编译原理实验,卡住的地方不是写不出递归函数,而是文法一改就死循环、词法分析器一加关键字就串种别、跑正确用例能过、跑错误用例却直接抛异常崩掉。这份自上而下的语法分析实验,核心就三件事:把原始文法消除左递归改造成 LL(1)、算出 FIRST 集和 FOLLOW 集自证合法性、再结合词法分析器写出递归下降分析程序并用正反用例验证。它适合正在做编译原理课设、毕设前期语法分析模块,或者要给组里新人做一份可复现参考实现的人。下面按“需求立住—文法改造—词法衔接—递归下降落地—避坑—验收答辩”的顺序,把这份方案拆到能直接照着改的程度。
2. 需求与文法改造:从原始产生式到 LL(1) 的完整推导
2.1 原始文法长什么样,为什么必须改
实验给的是一个简化语言的函数体文法,用符号化写法表示就是:
A → { M N } M → ε | P M P → D i ; D → t | f N → ε | Q N Q → i = E ; E → E + T | E - T | T T → T * F | T / F | F F → ( E ) | i | d其中t代表int,f代表float,i代表标识符,d代表整数常量。这套产生式直接拿来做递归下降是行不通的:E → E + T和T → T * F是直接左递归,递归下降函数一进去就会无限调用自己,栈直接爆掉。M → ε | P M和N → ε | Q N属于右递归形式,本身不致命,但和ε产生式混在一起时,如果 FIRST 集没算清楚,程序就不知道该不该继续往下走。所以第一步不是写代码,是把文法改造成 LL(1) 可分析的形式。
改造的目标很明确:消除左递归、提取公共左因子(本例中公共左因子不明显,主要是左递归),让每个非终结符在只看一个向前看符号时就能唯一确定用哪条产生式。这一步做不对,后面代码写得再漂亮也是白搭。
2.2 消除左递归的具体步骤与改造后文法
对E和T做标准左递归消除。以E为例,原始形式是E → E + T | E - T | T,按A → Aα | β转成A → βA'、A' → αA' | ε的套路:
E → T E' E' → + T E' | - T E' | ε T → F T' T' → * F T' | / F T' | ε F → ( E ) | i | dM和N保持右递归形式即可,因为它们不是左递归,递归下降可以直接处理:
A → { M N } M → ε | P M P → D i ; D → t | f N → ε | Q N Q → i = E ;这里有个容易忽略的点:P → D i ;里那个分号在原始正文的代码里其实没有显式匹配,P()函数只判断了i就index++,分号被跳过了。这在“能跑通”层面没问题,但严格来说是不完整的。我一般会在P()里补上对;的匹配,或者在词法阶段把分号也作为一个 token 保留,验收时更经得起追问。改造后的文法要能通过 FIRST/FOLLOW 集验证是 LL(1) 的,这是实验报告里必须写清楚的一环。
2.3 FIRST 集与 FOLLOW 集的计算与 LL(1) 验证
FIRST 集和 FOLLOW 集不是摆设,它们是判断“当前非终结符遇到某个输入符号时该选哪条产生式”的依据。按改造后的文法逐条算,结果如下表(#表示输入结束符):
| 非终结符 | FIRST 集 | FOLLOW 集 |
|---|---|---|
| A | { | # |
| M | ε, t, f, i | } |
| P | t, f | t, f, i |
| D | t, f | i |
| N | ε, i | } |
| Q | i | i, } |
| E | (, i, d | #, i, }, ) |
| E' | +, -, ε | #, i, }, ) |
| T | (, i, d | +, -, #, i, }, ) |
| T' | *, /, ε | +, -, #, i, }, ) |
| F | (, i, d | +, -, *, /, #, i, }, ) |
验证 LL(1) 的关键看两点:同一非终结符的各条产生式 FIRST 集两两不相交;含ε的产生式,其 FIRST 集与 FOLLOW 集不相交。以E'为例,+ T E'的 FIRST 是{+},- T E'的 FIRST 是{-},ε的 FIRST 是{ε},三者互不相交,且ε对应的 FOLLOW 是{#, i, }, )},与{+}、{-}也不相交,所以E'满足 LL(1) 条件。M和N同理,ε分支的 FOLLOW 是},与P、Q的 FIRST 不冲突。整套文法通过验证,可以放心构造递归下降程序。
提示:实验报告里 FIRST/FOLLOW 表一定要和改造后的文法一一对应,别拿改造前的产生式去算,否则答辩时老师一眼就能看出前后不一致。
3. 词法分析器衔接:关键字 float 的种别编码与 token 流设计
3.1 词法分析器要输出什么形式的 token
递归下降分析程序吃的不是原始字符,是词法分析器吐出来的 token 序列。这份方案里 token 用字符串表示,非终结符对应的终结符做了简化映射:int映射为t,float映射为f,标识符映射为i,整数常量映射为d,运算符和界符原样保留({、}、(、)、=、+、-、*、/、;)。这样语法分析函数里只需要做字符串比较,逻辑非常直白。
词法分析器的核心是一个字符扫描循环:跳过空白,遇到字母就连续读字母数字组成单词,再查关键字表决定是t、f还是i;遇到数字就连续读数字组成常量,记为d;遇到单字符运算符和界符直接入表;遇到无法识别的字符就报错退出。这个结构在实验一里通常已经写过,本实验的增量需求是加入float关键字的识别,并把它的种别编码设为 26。
3.2 关键字表与种别编码的落地写法
关键字判断用一个简单的字符串数组加线性查找就够了,不需要上哈希表。下面这段是词法分析器里关键字识别的核心逻辑,语言用 Java,和实验环境保持一致:
// 关键字表:下标+1 即为种别编码,float 对应编码 2,实验要求单独标记为 26 static String[] keyWord = {"int", "float", "if", "void", "main"}; public static int isKey(String str) { for (int i = 0; i < keyWord.length; i++) { if (keyWord[i].equals(str)) { return i + 1; // int->1, float->2, if->3 ... } } return -1; // 不是关键字,按标识符处理 }这里有个设计取舍要说清楚:实验要求“设置 float 的单词种别编码为 26”,但代码里isKey返回的是数组下标加一,float拿到的是 2。两种做法都常见——一种是在词法分析器内部维护一张真正的种别编码表,float显式写 26;另一种是像上面这样先用简化映射,在实验报告里说明“语法分析阶段统一用f代表 float,种别编码 26 在词法符号表中登记”。我一般倾向后者,因为递归下降程序只关心 token 的类别符号,不关心数字编码,硬把 26 塞进语法分析反而增加耦合。但如果老师明确要求输出种别编码,那就老老实实建一张Map<String, Integer>,把float映射到 26,int映射到 1,其余按序排。
3.3 从字符流到 token 列表的完整扫描流程
扫描函数接收字符数组,逐字符判断类型,把识别出的 token 追加到全局ArrayList<String>里。标识符和数字的读取用内层while连续消费字符,读完回退一格(i--),避免外层循环多跳一个字符。这个“回退一格”是词法分析里最容易写错的地方,少写一次i--就会吞掉下一个 token 的首字符,导致后面语法分析莫名其妙地失败。
public static void analyze(char[] chars) { String arr = ""; for (int i = 0; i < chars.length - 1; i++) { char ch = chars[i]; arr = ""; if (ch == ' ' || ch == '\t' || ch == '\n' || ch == '\r') { continue; // 跳过空白 } 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 += ch; ch = chars[++i]; } i--; list.add("d"); } else { list.add(String.valueOf(ch)); // 运算符、界符直接入表 } } }参数说明:chars是读入文件的字符数组,长度取文件长度加一以容纳末尾;list是静态 token 列表,供语法分析函数按index顺序消费。这段代码跑完后,list里就是类似{ t i ; i = d + d ; }的序列,语法分析器从下标 0 开始逐个匹配。词法分析器写完后,建议先单独打印 token 流核对一遍,别急着接语法分析——token 流错了,后面所有调试都是白费功夫。
4. 递归下降分析程序:非终结符函数映射与 flag 回退机制
4.1 每个非终结符对应一个函数的结构设计
递归下降的核心思想很朴素:文法里每个非终结符写一个函数,函数体按产生式右部的顺序依次匹配终结符、调用其他非终结符函数。终结符匹配成功就index++前进,非终结符就递归调用。整个程序用一个全局index指向当前待匹配的 token,用一个全局flag标记当前分支是否匹配成功。
按改造后的文法,函数映射关系是:A()对应函数体,M()对应声明语句块,P()对应声明语句,D()对应数据类型,N()对应可执行语句块,Q()对应赋值语句,E()对应表达式,E'用G()表示,T()对应项,T'用H()表示,F()对应因子。函数名和文法符号一一对应,答辩时对着文法讲代码,逻辑链非常清楚。
4.2 带 flag 的 ε 产生式处理与回退逻辑
这份方案里最值得讲的设计点,是M()和N()这两个含ε产生式的函数怎么处理。原始写法是:
static void M() { // M → ε | P M P(); if (flag == 1) M(); }问题在于:如果P()匹配失败,flag被置 0,但M()里没有对flag做判断就继续递归,或者P()内部失败后index已经回退,外层却还在往下走,就会出现三条语句反复循环调用、后续 token 永远分析不到的情况。实验正文里记录的就是这个坑——M → ε | P M、P → D i、D → t | f三个函数互相调用形成死循环。
解决思路是引入flag作为“本分支是否还有效”的开关:flag初始为 1,某个终结符匹配失败时置 0 并回退index,上层函数看到flag == 0就不再继续递归,直接返回。改造后的写法:
static int flag = 1; static void M() { // M → ε | P M P(); if (flag == 1) M(); // 只有 P 匹配成功才继续递归 } static void P() { // P → D i ; D(); if (flag == 1 && list.get(index).equals("i")) { index++; } else { flag = 0; // 匹配失败,标记无效 } } static void D() { // D → t | f if (list.get(index).equals("t") || list.get(index).equals("f")) { index++; } else { flag = 0; index--; // 回退,把消费掉的 token 还回去 } }这里D()失败时的index--是回退的关键:D()进来时并没有消费 token,失败后回退一格是为了让上层P()或M()在判断ε分支时还能看到当前 token。这个“失败回退”的约定必须在所有函数里保持一致,否则index会越跑越偏。我一般会在每个可能失败的函数末尾统一处理flag和index,而不是散落在各个分支里,调试时好定位。
4.3 表达式部分的递归函数与整体匹配入口
表达式部分按消除左递归后的形式写,E()调T()再调G(),G()处理加减和ε,T()调F()再调H(),H()处理乘除和ε,F()处理括号、标识符和整数:
static void E() { // E → T E' T(); G(); } static void G() { // E' → + T E' | - T E' | ε if (flag == 1 && (list.get(index).equals("+") || list.get(index).equals("-"))) { index++; T(); G(); } } static void T() { // T → F T' F(); H(); } static void H() { // T' → * F T' | / F T' | ε if (flag == 1 && (list.get(index).equals("*") || list.get(index).equals("/"))) { index++; F(); H(); } } 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 (flag == 1 && list.get(index).equals(")")) index++; } else { flag = 0; } }整体入口在main里:先读文件、调analyze()生成 token 流,再调A()从函数体开始分析,最后判断index == list.size()且flag == 1才算成功。这个双重判断很重要——只看index到没到末尾不够,因为flag为 0 时index可能恰好停在末尾,但中间某个分支其实已经失败了。两个条件都满足才打印“文法分析成功”,否则打印失败。
注意:
list.get(index)在index越界时会抛IndexOutOfBoundsException。正确用例不会触发,但错误用例(比如输入提前结束)很容易踩到。稳妥做法是在每个get前加index < list.size()判断,或者用try-catch包住入口,把异常转成“分析失败”输出,别让程序直接崩掉。
5. 避坑与排查:递归下降实验里最容易翻车的五个点
5.1 现象:程序卡死或栈溢出,控制台不停刷同一批调用
原因:左递归没消干净,或者ε产生式函数缺少flag判断,M()、N()在匹配失败后仍然无条件递归。解决:回头检查改造后文法是否还有A → Aα形式;所有含ε分支的函数,递归调用前必须判断flag == 1;在M()、N()里加打印语句观察调用深度,确认递归在失败时能退出。
5.2 现象:正确用例也报“分析失败”,但 token 流打印出来看着没问题
原因:index回退不一致。某个函数失败时回退了,另一个函数失败时没回退,导致后续匹配错位。解决:统一约定“失败即回退一格”,在所有可能失败的终结符匹配分支里成对写flag = 0; index--;;在main里打印最终index和list.size(),看差了几个 token,差的位置往往就是回退没对齐的地方。
5.3 现象:错误用例直接抛数组越界异常,而不是输出“分析失败”
原因:list.get(index)没有边界保护,输入在文法期望还有 token 时提前结束。解决:封装一个peek()方法,内部判断index < list.size(),越界返回特殊符号(如"#");所有list.get(index)替换成peek();入口处用try-catch兜底,把异常转成失败提示。
5.4 现象:float被识别成标识符i,声明语句分析失败
原因:关键字表里没有float,或者isKey的返回值判断写错(比如把float的编码 26 和数组下标 2 混用)。解决:确认keyWord数组包含float;如果按种别编码 26 实现,就在词法分析器里单独判断arr.equals("float")并list.add("f"),不要依赖数组下标;打印 token 流核对float是否输出为f。
5.5 现象:分号、括号等界符在 token 流里“消失”,语法分析却碰巧能过
原因:词法分析器把某些界符漏加进list,或者语法分析函数里跳过了对界符的匹配(如P()没匹配;)。解决:对照文法逐条检查每个终结符是否都有对应的list.add和匹配逻辑;用一份包含完整分号、括号的测试用例跑一遍,打印 token 流和文法符号序列做人工比对;验收前把“界符完整性”作为单独一项检查。
6. 验收与答辩:用正反用例和 FIRST/FOLLOW 表把方案讲圆
验收这件事,我一般按“三份材料 + 两个动作”准备。三份材料是:改造后文法与 FIRST/FOLLOW 表、词法分析器 token 流样例、递归下降程序源码;两个动作是跑正确用例、跑错误用例。正确用例选一个覆盖声明语句、赋值语句、加减乘除和括号的完整函数体,比如{ int i; float f; i = (1 + 2) * 3; },预期输出“文法分析成功”,token 流形如{ t i ; f i ; i = ( d + d ) * d ; }。错误用例故意漏掉分号或括号不配对,预期输出“文法分析失败”而不是崩溃。
答辩时老师最爱追问的三个问题,提前准备好答案:第一,“你怎么证明改造后的文法是 LL(1) 的?”——指着 FIRST/FOLLOW 表,说明同一非终结符各产生式 FIRST 集不相交、含ε的与 FOLLOW 集不相交。第二,“flag和index回退是怎么配合的?”——用D()失败回退、M()判断flag停止递归这条链讲清楚,最好现场加一行打印演示。第三,“错误用例为什么不会崩?”——说明peek()边界保护和入口try-catch的设计。
下面这张验收对照表可以直接放进实验报告:
| 验收项 | 检查方法 | 合格标准 |
|---|---|---|
| 文法改造 | 对照原始产生式逐条检查 | 无左递归,ε产生式位置正确 |
| FIRST/FOLLOW | 手工推导与程序输出比对 | 表项完整,LL(1) 条件成立 |
| 词法分析 | 打印 token 流 | float输出为f,界符无遗漏 |
| 正确用例 | 运行完整函数体 | 输出“文法分析成功”,index到末尾 |
| 错误用例 | 漏分号/括号不配对 | 输出“文法分析失败”,无异常抛出 |
| 代码结构 | 对照文法看函数映射 | 每个非终结符有对应函数,命名可追溯 |
血泪经验是:别等到答辩前一天才跑错误用例。我见过太多人正确用例跑得飞起,一换错误输入就数组越界,现场解释“这是正常报错”根本圆不回来。从那以后我每次做这类分析程序,都强制先把错误用例跑通、把异常兜住,再回头优化正确路径——顺序反了,后面全是补丁。希望这份拆解能帮到你,照着这套结构改,实验报告和代码都能立得住。
本文还有配套的精品资源,点击获取