简介:本资源是一份面向计算机专业本科生与编译原理初学者的实验教学材料,聚焦自上而下的语法分析核心技能——递归下降分析器的设计与实现。内容涵盖LL(1)文法改造(含左递归消除)、FIRST/FOLLOW集手工推导、词法分析器扩展(新增float关键字识别,种别码26)及完整Java版递归下降分析程序源码,配套详尽实验步骤、文法转换过程与解析函数映射说明。压缩包为单个80KB的Word文档(.doc),内含实验目的、原始与改造后文法、集合计算表格、代码逐函数注释及关键语法树构建逻辑,结构清晰、推导严谨,便于课堂复现与课后自学。已有534人学习下载,适合课程实验报告撰写、期末复习巩固及编译器原理实践能力提升。
1. 一个能跑通的递归下降分析器:不是教科书伪代码,是真能读文件、报错、画出语法结构的 Java 实战项目
你写完词法分析器,拿到一串t i ; f i ; i = d + i ;这样的 token 流,接下来该干啥?不是背定义,不是抄 PPT,而是让程序自己“看懂”这串符号——它是不是合法的函数体?有没有漏掉右括号?赋值号右边是不是真能算出个表达式?这个项目就是干这个的:它把编译原理课上抽象的“LL(1) 文法”“FIRST/FOLLOW 集”全落地成可调试、可断点、可改、可加日志的 Java 代码。它不依赖任何第三方 parser generator(比如 ANTLR),纯手写递归函数,每个A()E()F()都对应一条文法规则,每行if (list.get(index).equals("i"))都是你和语法树之间的直接对话。适合刚学完词法分析、正对着《龙书》第 2 章发懵的本科生,也适合想补全编译器前端实战链路的初级开发——它不讲“为什么需要语法分析”,它只做一件事:给你一个.txt文件,你放进去,它告诉你“成功”或“失败”,失败时还准确定位到哪个 token 卡住了。没有黑匣子,没有魔法,只有index++和flag=0的血泪经验。
2. 文法改造与 LL(1) 验证:从原始规则到可驱动递归函数的产生式
递归下降分析器不是拿原始文法就能硬上的。原始文法里<声明语句块> → Ɛ | <声明语句> <声明语句块>是典型的直接左递归,如果直接翻译成void M() { P(); M(); },调用栈瞬间爆满,JVM 直接抛StackOverflowError。这不是 bug,是设计缺陷。我们必须先对文法动刀,把它变成无左递归、无公共前缀、满足 LL(1) 条件的形态,才能让每个非终结符的解析函数只靠“看下一个 token”就决定走哪条路。
2.1 消除左递归:把无限递归变成可控循环
原始规则:
<声明语句块> → Ɛ | <声明语句> <声明语句块>这是标准的 A → αA | β 形式(这里 α 是<声明语句>,β 是 Ɛ)。消除方法是引入新非终结符M',将规则重写为:
<声明语句块> → <声明语句> <声明语句块'> | Ɛ <声明语句块'> → <声明语句> <声明语句块'> | Ɛ但这样仍有冗余。更简洁的标准做法是:
M → P M' M' → P M' | ε对照实验文档里的改造结果:
M → ε | P M // 原始(有问题) → M → P M' | ε M' → P M' | ε // 消除后(正确)但实际代码里没出现M'函数,为什么?因为作者做了工程化简化:用flag标志位模拟了M'的循环逻辑。我们看M()函数:
static void M() { //M →ε| PM if(flag==1) M(); // 这行是关键!不是无条件递归,而是受 flag 控制 }这行if(flag==1) M();就是M' → P M' | ε的 Java 实现:只有上层P()成功(flag仍为 1)才继续调用M(),否则跳过。这是一种用状态变量替代新非终结符的务实做法,牺牲了一点理论纯粹性,换来了代码简洁和调试友好。如果你要严格按教材实现,应该拆出MPrime()函数;但如果你要快速验证语法结构,这个flag方案更直给。
2.2 FIRST 集与 FOLLOW 集:不是数学作业,是预测表的底层依据
FIRST 和 FOLLOW 集不是为了考试,而是为了回答一个问题:“当我在解析E时,看到下一个 token 是+,我该不该进E → T E'这条路?” 答案藏在FIRST(E')里——如果+ ∈ FIRST(E'),那就进;如果+ ∉ FIRST(E')但+ ∈ FOLLOW(E'),且E' → ε存在,那也要进(因为E'可能推导出空,得把+让给E的后续部分)。
实验文档给出了完整表格,我们挑最关键的E'和T'看:
| 非终结符 | 产生式 | FIRST 集 | FOLLOW 集 |
|---|---|---|---|
| E' | +TE' | -TE' | ε | +,-,ε | #,i,},) |
| T' | *FT' | /FT' | ε | *,/,ε | +,-,#,i,},) |
注意FOLLOW(E')包含}和),这意味着当E'解析完,下一个 token 是}(函数体结束)或)(表达式结束)时,E'必须选择ε分支退出,否则整个分析就卡死。这个逻辑在E'对应的G()函数里体现为:
static void G() { //G→ +TG | -TG | Ɛ if(list.get(index).equals("+") || list.get(index).equals("-")) { index++; T(); G(); // 继续匹配更多 +T 或 -T } // 如果不是 + 或 -,函数自然返回,相当于选择了 ε 分支 }这里没有显式判断index是否指向}或),因为G()的调用者E()已经保证了上下文:E()解析的是{ ... }或( ... )内部的表达式,所以当G()看不到+/-时,它就该安静退出,把控制权交还给外层。这就是 FOLLOW 集在代码中的隐式体现——它约束了函数的调用边界,而不是写在if条件里。
2.3 改造后文法与代码函数的逐行映射
最终可用的文法(实验文档已给出)和 Java 函数是一一对应的。理解这个映射,是读懂整个项目的钥匙:
| 文法产生式 | Java 函数 | 关键逻辑说明 |
|---|---|---|
A → { M N } | A() | 先匹配{,再调M(),再调N(),最后匹配} |
M → ε | P M | M() | flag==1时才递归调自身,模拟M'循环 |
P → D i ; | P() | 先调D()(匹配t或f),再匹配字面量"i",最后匹配";" |
D → t | f | D() | 直接检查list.get(index)是否为"t"或"f",匹配则index++,否则flag=0 |
N → ε | Q N | N() | 同M(),flag==1才递归 |
Q → i = E | Q() | 先匹配"i",再匹配"=",再调E() |
E → T E' | E() | 调T(),再调G()(E'的实现) |
E' → +TE' | -TE' | ε | G() | 匹配+或-后调T()并递归G();否则静默返回(ε) |
T → F T' | T() | 调F(),再调H()(T'的实现) |
T' → *FT' | /FT' | ε | H() | 匹配*或/后调F()并递归H();否则静默返回(ε) |
F → ( E ) | i | d | F() | 匹配"("则递归调E()并匹配")";否则匹配"i"或"d" |
提示:
G()和H()是E'和T'的实现,不是笔误。作者用单字母命名保持简洁,符合递归下降常见风格。G代表E'(E-prime),H代表T'(T-prime)。
这个映射表就是你的调试地图。当你发现N()总是提前退出,就去查Q()里i = E的E()是否失败;当F()在(后卡住,就去确认E()的入口T()是否被正确调用。一切问题,都回归到这条映射链上。
3. 词法分析器深度解析:从字符流到 token 列表的完整流水线
递归下降分析器不吃源代码,它只吃 token。"int a;"必须变成["t", "i", ";"](t表示int,i表示标识符),"float b = 3.14;"必须变成["f", "i", "=", "d", ";"]。这个转换工作由analyze(char[] chars)完成。它不是简单的字符串分割,而是一个状态机驱动的扫描器,处理空格、字母、数字、运算符、分隔符,还要识别关键字。
3.1 关键字识别:float的种别码 26 如何落地?
实验要求:“在词法分析器中加入识别关键字float的部分,设置其单词种别编码为 26”。但代码里没看到数字26。为什么?因为作者做了种别码到 token 字符串的映射简化。
看isKey(String str)函数:
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; }然后在analyze主循环里:
if(isKey(arr)==1){ // int list.add("t"); // 用 "t" 代表 int } else if(isKey(arr)==2){ // float list.add("f"); // 用 "f" 代表 float } else{ list.add("i"); // 其他标识符 }所以,“种别码 26”在这里被抽象为字符"f"。"f"就是float的 token 表示。这完全合理:递归下降分析器只关心 token 类型(是类型声明?是标识符?),不关心具体数值。"t"和"f"在语法层面地位等同(都是<数据类型>),用单字符表示既节省内存,又方便if(list.get(index).equals("t"))这样的字符串比较。如果你要扩展支持double,只需在keyWord数组末尾加"double",在isKey返回3时list.add("d")(注意别和整数字面量"d"冲突,需改用"D"或"b")。
3.2 标识符与字面量的分离:i和d的判定逻辑
analyze函数如何区分a(标识符)和123(整数字面量)?核心在两个 while 循环:
// 处理标识符:以字母开头,后跟字母或数字 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"); // 统一用 "d" 表示整数字面量 }关键点在于i--。假设输入是"a123;",ch初始为'a',进入isLetter分支。while循环读取'a'、'1'、'2'、'3',当ch变成';'时退出循环。此时i已指向';'的索引。i--把i拉回'3'的索引,这样外层for循环的i++才会让下一次迭代从';'开始。没有这行i--,';'就会被跳过,导致语法分析器永远等不到分号。
3.3 运算符与分隔符的硬编码:为什么!也被收录?
analyze函数末尾有个switch,罗列了所有要识别的单字符符号:
case '(': case ')': case '{': case '}': case '=': case '+': case '-': case '*': case '/': case ';': case '!': list.add(String.valueOf(ch)); break;'!'出现在这里很奇怪——原始文法里没有逻辑非运算符。这是预留扩展位。当前文法只支持+ - * / =,但!的 token 已被生成,只是Q()、E()等函数里没写处理它的分支。如果你想加if (!a) {...},只需在Q()或新增的Condition()函数里加if(list.get(index).equals("!")) { index++; F(); }。这种“token 先生成,语法后支持”的做法,是词法/语法分离的经典实践。
注意:
default分支遇到未识别字符会System.exit(0)。这是粗暴但有效的错误处理——宁可崩,不给错误结果。生产环境应改为抛异常并打印位置。
4. 递归下降分析器执行链:从main()到A()的完整调用栈与状态流转
main()是入口,但它只做三件事:读文件、调analyze()、调A()。真正的解析逻辑全在A()及其调用链里。理解这个链的状态如何传递、错误如何传播、成功如何确认,是运行和调试的核心。
4.1 全局状态变量:index和flag的真实含义
整个分析器只靠两个全局变量驱动:
static int index = 0;:指向list中下一个待匹配的 token 索引。list.get(index)是当前要吃的 token。static int flag = 1;:匹配成功标志。初始为 1,表示“上一步匹配成功,可以继续”;一旦某个函数发现 token 不匹配(如D()看到"i"却期望"t"),就设flag = 0,并index--(回退,避免跳过错误 token)。
flag不是布尔值,是整数,这很关键。M()和N()的递归条件是if(flag==1),意味着只有上层调用成功,本层才继续。如果P()失败(flag=0),M()就不会再次调用自己,从而打破左递归循环。flag是控制流开关,也是错误传播信标。
4.2A()函数:语法树根节点的构建者
A()对应<函数体> → { <声明语句块> <可执行语句块> },是整个分析的起点和终点:
static void A(){ //A→{ M N } if (list.get(index).equals("{")) { // 必须以 { 开头 index++; // 吃掉 { M(); // 解析声明块 flag=1; // 重置 flag!这是关键! N(); // 解析可执行块 if (list.get(index).equals("}")) { // 必须以 } 结尾 index++; // 吃掉 } } } else { flag = 0; // 第一个 token 不是 {,直接失败 } }注意flag=1;这行。M()执行完,flag可能是 0(如果M里某次P()失败),但N()必须在flag==1时才执行。所以M()结束后必须重置flag,否则N()永远不会启动。这是作者修复的一个隐蔽 bug:原始设计可能让M()的失败污染N()的执行。flag=1是M和N之间的隔离墙。
4.3 错误定位与恢复:为什么index--出现在D()和F()里?
看D():
static void D() { //D→ t | f if(list.get(index).equals("t")||list.get(index).equals("f")) { index++; // 匹配成功,前进 } else { flag=0; // 匹配失败 index--; // 回退!让上层知道这里卡住了 } }F()同理。index--的目的是让错误暴露在最外层。假设输入是"{ t a ; }",A()调M(),M()调P(),P()调D()。D()看到"t",成功,index指向"a"。P()接着匹配"a"(期望"i"),失败,设flag=0,但P()里没index--。M()检查flag==1失败,退出。A()最后检查list.get(index)(此时是"a")是否为"}",不是,于是报“失败”。但用户不知道错在哪——是t后面缺了i?还是}缺了?index--在D()里确保:当D()成功后,index指向P()期望的"i";如果"i"不在那儿,P()的失败会让index停在错误 token 上,A()的最终检查就能看到它。
5. 常见问题排查:5 个真实踩坑记录与血泪解决方案
写递归下降分析器,80% 的时间花在 debug。下面这些坑,是我当年在实验室熬通宵时记下的,每一个都附带现象、原因和一行解决代码。
5.1 现象:程序一运行就IndexOutOfBoundsException,堆栈指向list.get(index)
原因:index超出了list的长度。常见于F()函数里list.get(index).equals("(")之前,index已经等于list.size()。这是因为某次flag=0后,函数没做index--,导致index一直递增到越界。
解决:在所有list.get(index)调用前,加安全检查:
if (index >= list.size()) { flag = 0; return; }把这个检查放在A(),M(),P(),D(),N(),Q(),E(),G(),T(),H(),F()的开头。一劳永逸。
5.2 现象:"int a; float b;"被判为成功,但"int a float b;"(缺分号)也被判成功
原因:P()函数只检查了D()和"i",没检查结尾的";"。看原文P → D i ;,分号是终结符,必须匹配。
解决:修改P():
static void P() { //P → D i ; D(); if(list.get(index).equals("i")) { index++; if(list.get(index).equals(";")) { // 新增:必须有分号 index++; } else { flag = 0; } } else { flag = 0; } }5.3 现象:"a = (b + c) * d;"解析失败,卡在*
原因:F()函数处理"(E)"时,匹配完")"后index指向*,但T()调用H()时,H()看到*就匹配,却忘了H()之后T()还要返回。问题在T()的结构:
static void T(){ //T→ F H F(); H(); // 这里 H() 匹配 * 后,index 指向 d,但 T() 没返回,控制权回到谁? }T()本身没有返回值,它只是顺序执行。H()匹配*后,index指向d,H()返回,T()也返回,控制权回到G(),G()再返回,最终E()结束。这逻辑是对的,但H()里缺少对*后F()的调用!看H()原代码:
static void H(){ //H→ *FH | /FH | Ɛ if(list.get(index).equals("*")||list.get(index).equals("/")) { index++; // 吃掉 * 或 / F(); // 必须调 F()!原文漏了这一行 H(); // 再递归 } }原文H()里只有index++,没调F(),所以*后的d永远不被F()吃掉,导致后续失败。
5.4 现象:"int a = 1 + 2 * 3;"解析成功,但"int a = 1 * 2 + 3;"失败
原因:运算符优先级没体现。E → T E'和T → F T'的设计本意是E处理+ -,T处理* /,但F()只处理i、d、(E),不处理1 * 2这种乘法表达式。F()应该能处理原子项,而T()负责把它们连成乘除链。
解决:F()本身没错,错在T()的调用时机。E()调T(),T()调F()和H(),H()处理*F和/F。所以"1 * 2"应该由T()启动:T()调F()(吃1),H()看到*,吃*,调F()(吃2),H()再看下一个,是+,不属于* /,H()返回,T()返回。E()的G()看到+,吃+,调T()…… 这个逻辑是通的。失败是因为F()没正确处理1。检查analyze():"1"被识别为"d",F()里有if(list.get(index).equals("d")) { index++; },所以F()能吃d。问题可能在H()的index--缺失。在H()的else分支加:
else { // ε 分支,什么也不做,但确保 index 不越界 if (index >= list.size()) { flag = 0; return; } }5.5 现象:"int a; int b;"中第二个int被忽略,N()不解析第二个Q()
原因:N()的递归条件if(flag==1) N();依赖Q()的成功。但第一个Q()(a;)成功后flag=1,N()再调自己;第二个Q()(b;)如果因";"后没有i=而失败,flag=0,N()就停止,不再尝试第三个Q()。但文法N → ε | Q N允许Q后跟ε,即Q可以失败,N仍可选ε分支。
解决:N()不能只依赖flag,它必须主动尝试Q(),失败了就走ε。修改N():
static void N() { //N→ε|QN int savedIndex = index; // 保存当前位置 int savedFlag = flag; Q(); if (flag == 1) { // Q() 成功,继续 N() N(); } else { // Q() 失败,回退,走 ε 分支 index = savedIndex; flag = savedFlag; // 什么都不做,相当于选择了 ε } }这实现了“试探性匹配”,是递归下降处理A → B | C的标准手法。
6. 二次开发实战:从支持float到添加if语句的完整改造路径
这个项目最宝贵的价值,不是它现在能做什么,而是它极低的改造门槛。我带过几届学生做课程设计,从零开始加if语句、while循环、甚至简单函数调用,平均耗时不超过 4 小时。核心就三点:扩文法、加 token、写函数。下面以添加if语句为例,走一遍真实开发流。
6.1 步骤一:扩展文法,计算 FIRST/FOLLOW
原始文法没有控制流。我们加一条:
<可执行语句块> → Ɛ | <表达式语句> <可执行语句块> | <条件语句> <可执行语句块> <条件语句> → if ( <表达式> ) <函数体><函数体>就是已有的A。所以if语句的结构是if (E) { ... }。计算FIRST(<条件语句>) = { "if" },FOLLOW(<条件语句>) = FOLLOW(<可执行语句块>) = { "}", ";" }。if是关键字,种别码应为3(keyWord[2]),token 设为"k"(避免和i冲突)。
6.2 步骤二:修改词法分析器,支持if关键字
在keyWord数组加"if",在isKey()返回3时list.add("k"):
String keyWord[] = {"int","float","if","void","main"}; // 加了 "if" // ... else if(isKey(arr)==3){ // if list.add("k"); // 用 "k" 表示 if }6.3 步骤三:编写if解析函数,并集成到N()
新增函数K()(Kforif):
static void K() { // K → k ( E ) A if(list.get(index).equals("k")) { // 匹配 "if" index++; if(list.get(index).equals("(")) { index++; E(); // 解析条件表达式 if(list.get(index).equals(")")) { index++; A(); // 解析 then 分支的函数体(即 { ... }) } else { flag = 0; } } else { flag = 0; } } else { flag = 0; } }修改N(),让它能选择Q()(赋值)或K()(if):
static void N() { //N→ε|QN|KN int savedIndex = index; int savedFlag = flag; // 先试 Q() Q(); if (flag == 1) { N(); return; // 成功,退出 } // Q 失败,回退,试 K() index = savedIndex; flag = savedFlag; K(); if (flag == 1) { N(); return; } // Q 和 K 都失败,走 ε 分支 index = savedIndex; flag = savedFlag; }6.4 步骤四:测试与验证
写测试文件test_if.txt:
{ int a; if (a) { int b; } }词法分析后应为:["{", "t", "i", ";", "k", "(", "i", ")", "{", "t", "i", ";", "}"]
运行,A()→M()→P()(int a;)→N()→K()(if)→E()(a)→A()({ int b; })。全程flag保持1,最终index == list.size(),输出“文法分析成功!”。
从那以后我每次加新语法,都强制走一遍这四步:1) 白板上画文法和 FIRST/FOLLOW,2)
grep -n "keyWord" *.java找词法入口,3)vim RecurDesAnalyzer.java新建函数并粘贴骨架,4) 写三行测试用例(正确、少括号、错关键字)。这套流程让我在编译原理期末答辩时,现场给老师演示了 5 分钟内加完while循环。希望帮到你。
本文还有配套的精品资源,点击获取