简介:这份面向编译原理课程实验的文档,围绕自上而下语法分析中的递归下降分析法展开,适合正在完成语法分析实验、需要理解LL(1)判定与程序实现的学生参考。内容先对给定文法消除左递归,整理产生式并求解各非终结符的FIRST集和FOLLOW集,再结合词法分析器补充float关键字识别(种别编码26),给出递归下降分析程序的代码与运行结果,便于对照检查解析流程和flag失败处理。资源包共1个doc文件,约80KB,以实验报告形式集中呈现题目、改造后文法、集合表和Java实现片段。已有534人学习,可用于复盘实验步骤、核对集合计算与代码结构,也可作为课程作业的排错参考。
1. 从一段文本到语法分析结果
一份写好的ex.txt丢进去,程序先输出一串{ t i ; i = i + d ; },再被七个递归函数逐层处理,最后打印「文法分析成功」。这条链路就是编译原理实验里最容易被跳过、也最容易出错的一段:词法分析器把字符流压缩成符号流,递归下降分析器按产生式逐个匹配。真正容易卡住的地方,通常在于消除左递归之后 FIRST/FOLLOW 算错、ε 分支让递归停不下来、分号忘了消费导致后面整条语句错位;写出函数本身反而没那么容易出问题。这套带完整代码和运行结果的实验工程,适合正在做自上而下语法分析、想让程序实际跑通而不是只做纸上推导的人;也能让已经跑通的读者看清标志位、游标回退这些工程细节。
2. 文法预处理:消除左递归并手算 FIRST/FOLLOW
2.1 原始文法与不能直接写代码的原因
实验给的原始产生式是这一套:
<函数体> → { <声明语句块> <可执行语句块> } <声明语句块> → Ɛ | <声明语句> <声明语句块> <声明语句> → <数据类型> <标志符> ; <数据类型> → int | float <可执行语句块> → Ɛ | <赋值语句> <可执行语句块> <赋值语句> → <标志符> = <表达式> ; <表达式> → <表达式> + <表达式2> | <表达式> - <表达式2> | <表达式2> <表达式2> → <表达式2> * <表达式3> | <表达式2> / <表达式3> | <表达式3> <表达式3> → ( <表达式> ) | <标志符> | <整数>符号太多,写代码时没法直接对应,先把终结符换成单字符:A是函数体,M是声明块,P是声明语句,D是数据类型,N是可执行块,Q是赋值语句,E/T/F是表达式的三层,i是标识符,d是整数常量,t/f是int/float。换完就是输入里那张简表。
问题出在<表达式> → <表达式> + <表达式2>这一条。它是直接左递归:如果照着写成void E(){ E(); ... },函数一进来就调自己,栈会在几十毫秒内被吃完,抛出StackOverflowError。这是递归下降的第一道坎,必须先做文法改造,不能让代码去硬扛。
真正危险的地方在另外一处:M → Ɛ | P M这种右递归。它在纸面上没问题,但落到代码里就是「调 P,如果成功就再调 M」。只要某一轮 P 没有消费掉任何 token 却把错误标志复位了,M()就会原地无限自我调用——不是栈溢出那种干脆的崩,而是转圈转到你以为是死循环。后面第 5 章会专门讲这个现场。
2.2 消除左递归的标准变换与等价性核对
直接左递归的消除模板是老生常谈,但很多人背了模板不会验证。模板是:
A → A α | β 改成 A → β A' A' → α A' | Ɛ照着套到表达式这一层,α是+ <表达式2>和- <表达式2>,β是<表达式2>,于是变成:
E → T G G → + T G | - T G | Ɛ T → F H H → * F H | / F H | Ɛ F → ( E ) | i | d改完之后E只会往T走,T只会往F走,递归深度由括号嵌套层数决定,跟表达式长度脱钩了。这是关键收益——一个一百项相加的表达式,改造前递归深度一百层,改造后只有三层加一条右递归的G链。
代价是+和-的结合性从「左结合」变成了「右结合」。对只判断文法是否合法的实验来说无所谓,对真要算值的人来说就是陷阱:1 - 2 - 3按右结合算是1 - (2 - 3) = 2,按左结合算是(1-2)-3 = -4。这也是为什么真正的编译器在递归下降里通常要额外做左结合修正,或者干脆换用算符优先、LR 那一套。这一点必须心里有数,别拿这个解析器去算数。
声明块和可执行块这两条本来就是右递归,形式上不用动:
M → Ɛ | P M N → Ɛ | Q N2.3 手算 FIRST/FOLLOW 集,用冲突判定 LL(1)
文法改完不等于能写递归下降,还得证明它是 LL(1) 的——换句话说,任何一个非终结符,看到下一个输入符号时,只能有一条产生式可选。判定规则两条:
- 对
X → α | β,要求FIRST(α) ∩ FIRST(β) = ∅; - 如果
α能推出Ɛ,还要额外要求FIRST(β) ∩ FOLLOW(X) = ∅。
把改造后的十一条产生式逐条算一遍:
| 产生式 | FIRST 集 | FOLLOW 集 |
|---|---|---|
| A → { M N } | {{} | {#} |
| M → Ɛ | P M | {Ɛ,t,f} | {i} |
| P → D i ; | {t,f} | {t,f} |
| D → t | f | {t,f} | {i} |
| N → Ɛ | Q N | {Ɛ,i} | {}} |
| Q → i = E ; | {i} | {i,}} |
| E → T G | {(,i,d} | {#,i,},)} |
| G → +TG | -TG | Ɛ | {+,-,Ɛ} | {#,i,},)} |
| T → F H | {(,i,d} | {+,-,#,i,},)} |
| H → *FH | /FH | Ɛ | {*,/,Ɛ} | {+,-,#,i,},)} |
| F → (E) | i | d | {(,i,d} | {*,/,+,-,#,i,},)} |
P的 FOLLOW 是{t, f}这一点当时卡了我一会儿:因为M → P M,P后面紧跟的必然是下一轮的M,而FIRST(M)去掉Ɛ就是{t, f}。想通这一层,后面全套 FOLLOW 都能顺着推。
带Ɛ的三条产生式逐条验冲突:
M → Ɛ | P M:FIRST(Ɛ) = {Ɛ},FIRST(PM) = {t, f},两者不相交;再看Ɛ分支,FIRST(PM) ∩ FOLLOW(M) = {t,f} ∩ {i} = ∅。通过。N → Ɛ | Q N:FIRST(QN) = {i}与FOLLOW(N) = {}不相交。通过。G → +TG | -TG | Ɛ:{+} ∩ {-} = ∅;Ɛ分支要求FIRST(+TG) ∩ FOLLOW(G) = {+,-} ∩ {#, i, }, )} = ∅。通过。
三条都无冲突,可以确认改造后的文法是 LL(1) 的。步到这里才能动手写函数——顺序不能反,先算后写,否则出错时你连是哪条产生式选错了都定位不到。
3. 词法分析器:把源程序压缩成 t/f/i/d 的 token 流
3.1 两个核心数据结构:list 表和 index 游标
整个解析器只靠两个静态成员撑着:
// 词法分析产出的符号线性表,元素就是 "t"、"f"、"i"、"d"、";" 这类字符串 private static final ArrayList<String> list = new ArrayList<>(); // 全局读指针,指向 list 中下一个待匹配的符号 private static int index = 0; // 错误标志:1 表示到目前为止一切正常,0 表示已经匹配失败 private static int flag = 1;这里的选择很朴素:不做Token类、不带行列号、不带原始文本,只存一个字符或一个字母。好处是语法分析函数里写list.get(index).equals("i")就够了,一眼能看出在匹配哪条产生式;坏处是出错时只能告诉你「失败」,说不出错在哪一行——这个问题留到最后一章解决。
index是全局游标,所有分析函数共享它。这意味着函数之间不需要传参、不需要返回值,靠副作用推进;也意味着任何一个函数多走或少走一格,后面的判断全错。第 5 章那几个难查的 bug,根子几乎都在这。
3.2 扫描主循环:字符分类与贪心切分
词法分析器就是一个字符一个字符往下扫,遇到字母就贪心往后吃,遇到数字同理,遇到界符直接入表:
public static void analyze(char[] chars) { char ch; String arr = ""; // 末尾补的那一位不参与扫描 for (int i = 0; i < chars.length - 1; i++) { ch = chars[i]; arr = ""; if (ch == ' ' || ch == '\t' || ch == '\n' || ch == '\r') { // 空白字符直接跳过,不产生 token } else if (isLetter(ch)) { // 字母开头:可能是关键字,也可能是标识符,先按最长匹配吃完 while (isLetter(ch) || isDigit(ch)) { arr += ch; ch = chars[++i]; // 注意这里是先自增再取,会多读一位 } i--; // 把多读的那一位还回去 if (isKey(arr) == 1) { list.add("t"); // int } else if (isKey(arr) == 2) { list.add("f"); // float } else { list.add("i"); // 其余一律当标识符 } } else if (isDigit(ch)) { // 数字开头:连续吃数字,统一记作 d,不保留具体值 while (isDigit(ch)) { arr = arr + ch; ch = chars[++i]; } i--; list.add("d"); } else { switch (ch) { case '(': case ')': case '{': case '}': case '=': case '+': case '-': case '*': case '/': case ';': case '!': list.add(String.valueOf(ch)); // 界符原样入表 break; default: System.out.println("存在无法识别的字符:" + ch); System.exit(0); // 直接终止,不做错误恢复 } } } }i--那两处是这段代码的核心。while里用chars[++i]取值,退出循环时i已经指向了第一个不属于这个单词的字符,所以必须回退一格,让外层for的i++正好落在那个字符上。少写一个i--,后面所有的字符都会集体错位一格,症状是「明明写对了却报语法错」。
参数说明:chars是整份源文件读成的字符数组,形参不做长度校验,靠调用方保证;函数无返回值,产出全部落在静态list里。这种「输入参数 + 全局输出」的写法在实验代码里很常见,真实项目里一般会返回List<Token>,避免隐式状态。
System.exit(0)那行也值得说一句:遇到无法识别的字符直接退出进程,退出码还是 0。这在批处理脚本里会让调用方误以为成功。改成System.exit(1)或者抛异常更合适,我在最后一章的改造里就换掉了。
3.3 关键字表与 float 的种别编码 26
实验要求里明确写了「在词法分析器中加入识别关键字 float 的部分,设置其单词种别编码为 26」。落到代码就是一个顺序查找:
public static int isKey(String str) { // 数组下标 + 1 就是关键字的种别编码,1 对应 int,2 对应 float String[] keyWord = {"int", "float", "if", "void", "main"}; for (int i = 0; i < keyWord.length; i++) { if (keyWord[i].equals(str)) { return i + 1; } } return -1; // 不是关键字,调用方据此判定为标识符 }返回值是 1 基序号,int拿到 1,float拿到 2。看似和题目要求的 26 对不上,其实是两套编码:26 是词法分析器对外输出的单词种别编码,t/f/i/d是语法分析器内部用的符号别名。中间隔了一层映射,我一般会这么记:
| 单词 | 对外种别编码 | 分析器内部符号 | 说明 |
|---|---|---|---|
int | 1 | t | 数据类型,两套编码都要有 |
float | 26 | f | 本次实验新增,编码不能和已有种别撞 |
| 标识符 | 10 | i | 不以数字开头、不在关键字表里的串 |
| 整数常量 | 11 | d | 只认十进制,不处理小数和符号 |
| 界符 | ASCII | 原样入表 | ;=(这些直接当符号用 |
提示:如果实验报告里要求同时给出两套编码,最好在代码里显式定义一个
FLOAT_CODE = 26常量,而不是在注释里写一句了事。后面写实验一和实验二的对接说明时,这个常量就是唯一的依据。
还有个细节:if和void在关键字表里,但文法里没有对应的产生式。也就是说if (x) { }这种输入,词法层能识别(返回 3),但语法层会因为isKey(arr)只判断了 1 和 2,把if落到else分支当成标识符i处理,最后在语法阶段报错。这种写法在实验里只是不够严谨,真到了生产环境就是词法和语法对不上的经典事故。
3.4 从磁盘文件到 token 表的完整链路
入口函数做三件事:读文件、调词法分析、打印中间结果。
public static void main(String[] args) throws Exception { // 原版是硬编码的绝对路径,换台机器就跑不了 File file = new File("D:\\JAVA EE 源代码\\Recursive descent analyzer\\ex.txt"); FileReader reader = new FileReader(file); int length = (int) file.length(); // 多开一位,留给文件末尾 char buf[] = new char[length + 1]; reader.read(buf); reader.close(); analyze(buf); // 词法分析,结果写进静态 list System.out.println("词法分析后,程序转化为:"); for (int i = 0; i < list.size(); i++) { System.out.print(list.get(i)); } System.out.println(); A(); // 从开始符号 A 进入递归下降 if (index == list.size()) { System.out.println("文法分析成功!"); } else { System.out.println("文法分析失败!"); } }判定逻辑只有一句:index == list.size()。意思是所有 token 都被吃干净了才算成功。这条判据非常关键——光靠flag不够,因为flag只在某条产生式明确失败时才置 0;如果文法匹配提前结束、剩下的 token 没人处理,flag依然是 1,但index没走到头。两个条件配合才能兜住。
new char[length + 1]这个+1是为了给analyze里那个chars.length - 1留位置。文件末尾的换行符在这套写法里被当成填充位,扫描时直接跳过。这个约定很隐晦,换个人接手大概率会踩——第 5 章会展开。
4. 递归下降分析器:一个非终结符一个函数的骨架
4.1 函数签名约定:无参、共享游标、靠标志位报错
十一条产生式对应十一个函数,全是static void,全都不带参数:
static void A() { ... } // A → { M N } static void M() { ... } // M → Ɛ | P M static void P() { ... } // P → D i ; static void D() { ... } // D → t | f static void N() { ... } // N → Ɛ | Q N static void Q() { ... } // Q → i = E ; static void E() { ... } // E → T G static void G() { ... } // G → + T G | - T G | Ɛ static void T() { ... } // T → F H static void H() { ... } // H → * F H | / F H | Ɛ static void F() { ... } // F → ( E ) | i | d不带参数是因为index是全局的,调一次函数就相当于「尝试按这条产生式匹配一段输入」,匹配成功index前移,失败flag置 0。不带返回值是因为结果只有两种,用flag表达就够了。
这套约定的好处是代码和文法一一对应,写P()的时候脑子里就是P → D i ;这一行。坏处是错误信息全丢了:你只知道失败了,不知道是哪个 token 出的问题。实验够用,工程不够用。
4.2 文法到函数的逐条映射:A / M / P / D / N / Q
先看函数体这一层,它负责匹配最外层的花括号:
static void A() { // A → { M N } if (list.get(index).equals("{")) { index++; // 吃掉左花括号 M(); // 声明语句块,可以是空 flag = 1; // 关键:进可执行块之前把标志位复位 N(); // 可执行语句块,可以是空 if (list.get(index).equals("}")) { index++; // 吃掉右花括号 } } }flag = 1这一行是原作者打的补丁,位置很讲究。因为M()在匹配失败时会把自己置 0,而M允许为空、N也允许为空,如果M空过一轮把flag留着 0,N()就再也不会执行了——明明合法的{ }也会被判失败。复位放在M()和N()中间,正好隔开两段的失败状态。
声明语句块和它下面两层:
static void M() { // M → Ɛ | P M P(); // 先试着匹配一条声明 if (flag == 1) { M(); // 成功才递归下一条,失败就当作 Ɛ 分支收工 } } static void P() { // P → D i ; D(); // 吃数据类型 t 或 f if (index < list.size() && list.get(index).equals("i")) { index++; // 吃标识符 } else { flag = 0; return; } if (index < list.size() && list.get(index).equals(";")) { index++; // 分号属于 P 的一部分,必须在这里吃干净 } 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 } }P()里的两处index < list.size()判断是防御性的,原版没有,在文件末尾遇到声明语句时会抛IndexOutOfBoundsException。D()的else分支原版还写了一行index--,理由是「回退」;但它在失败路径上根本没前移过,这一减会让游标倒退一格,把前一个已经匹配好的 token 又变成待匹配状态。这个问题在第 5 章细说。
赋值语句和可执行块:
static void N() { // N → Ɛ | Q N Q(); if (flag == 1) { N(); } } static void Q() { // Q → i = E ; if (list.get(index).equals("i")) { index++; // 吃赋值号左边的标识符 if (list.get(index).equals("=")) { index++; // 吃等号 E(); // 递归进表达式 // 这里的 ';' 原版没处理,靠后面的匹配兜底 } } else { flag = 0; // 不是标识符,说明这一轮不是赋值语句 } }Q()结尾漏了分号,和P()是同一类问题。单个赋值语句时看不出来——因为A()最后检查}时,index恰好在;上,flag还是 1,但index != list.size(),判失败。多个语句连写时就彻底乱了。
4.3 表达式三层递归:E / G / T / H / F
表达式这四层改造后是标准的「主体 + 尾巴」结构,每一层负责一个优先级:
static void E() { // E → T G T(); G(); } static void G() { // G → + T G | - T G | Ɛ if (list.get(index).equals("+") || list.get(index).equals("-")) { index++; // 吃掉加号或减号 T(); // 右边还得是一个项 G(); // 继续看后面还有没有加减 } // 什么都不做就是 Ɛ 分支,直接返回 } static void T() { // T → F H F(); H(); } static void H() { // H → * F H | / F H | Ɛ if (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 (list.get(index).equals(")")) { index++; // 吃右括号 } else { flag = 0; } } else { flag = 0; // 三种都不是,失败 } }G()和H()没有else分支,因为Ɛ就是「什么都不匹配」。这是递归下降里表达空产生式最自然的方式——不写代码就是空。但要注意,G()返回之后调用方不会再检查它到底匹配没匹配,所以E → T G里的G无论如何都会「成功」。这在 LL(1) 下是安全的,因为G的 FIRST 集和 FOLLOW 集不相交,看到+/-就进+/-分支,看到别的就该退出了。
F()里那一层括号递归是整个解析器里唯一会嵌套调回E()的地方。深度由括号层数决定,((((i))))会走四次,i只走一次。这也是为什么左递归必须消掉:留着左递归,递归深度就跟表达式长度成正比,一个长表达式直接把栈打穿。
4.4 手工分析一次:{ int i; i = i + d; }
拿一个最小但完整的输入,把index的变化逐格记下来,比看十遍代码都管用。词法分析后的list是九元组:
下标: 0 1 2 3 4 5 6 7 8 内容: { t i ; i = i + d按分析顺序走:
| 步骤 | 调用 | 匹配动作 | index 变化 |
|---|---|---|---|
| 1 | A() | 看到{,吃掉 | 0 → 1 |
| 2 | M()→P()→D() | 看到t,吃掉 | 1 → 2 |
| 3 | P() | 看到i,吃掉 | 2 → 3 |
| 4 | P() | 看到;,吃掉 | 3 → 4 |
| 5 | M()递归 | 调P(),D()看到i不是t/f,置flag=0 | 仍是 4 |
| 6 | M() | flag是 0,不再递归,按Ɛ返回 | 仍是 4 |
| 7 | A() | flag复位为 1 | 仍是 4 |
| 8 | N()→Q() | 看到i,吃掉;看到=,吃掉 | 4 → 6 |
| 9 | Q()→E()→T()→F() | 看到i,吃掉 | 6 → 7 |
| 10 | T()→H() | H()看到+不匹配*//,按Ɛ返回 | 仍是 7 |
| 11 | G() | 看到+,吃掉 | 7 → 8 |
| 12 | G()→T()→F() | 看到d,吃掉 | 8 → 9 |
| 13 | H()→G() | 越界处当Ɛ返回 | 仍是 9 |
| 14 | N()递归 | Q()越界,置flag=0,返回 | 仍是 9 |
| 15 | A() | index == 9 == list.size(),成功 | — |
第 5 步是这套实现最容易让人误判的地方:P()失败了一次,但M()把它当成Ɛ分支吞掉了,整条链依然继续。这不是 bug,是右递归 + 空产生式的正常行为——「尝试匹配一条声明,失败了就当没有」。真正需要警惕的是失败之后index有没有乱动。
第 13、14 步也值得注意:index已经等于list.size(),任何list.get(index)都会越界。原版在这里没有边界检查,靠的是flag置 0 之后不再递归下去;但只要某条路径漏判,程序立刻抛IndexOutOfBoundsException。
注意:走查的时候一定要把
index每一步的值写出来。只看「成功/失败」两个字,上面第 5 步的假失败和第 13 步的越界都看不出来。
5. 排查:递归下降跑不通时的五类现场
5.1 递归层的两处问题:M/N 停不下来、D 回退越界
现象一:程序卡死,CPU 占满,没有任何输出,也不抛异常。打上断点会发现P()和M()无限交替调用,调用栈一路疯长,几十秒后可能才栈溢出。
原因是M → Ɛ | P M这条产生式在代码里写成了「无条件递归」:P()之后直接M()。当P()因为当前 token 不是t/f而失败时,它只把flag置 0,没有做别的;如果这时flag又被别处复位成 1,M()就会再调一次P(),P()再失败一次,如此往复,index一格都没动。
解决方式是加一个「是否真的前进了」的判据,而不是只看flag:
static void M() { int before = index; // 记录进入本层时的游标位置 P(); // 只有匹配成功并且确实吃掉了 token,才继续递归 if (flag == 1 && index > before) { M(); } }index > before这一条是根本性的:递归下降的每一步都必须推动输入前进,原地不动就意味着这一轮什么也没匹配到,该收手了。原版的flag只能捕捉「明确失败」,捕捉不到「表面成功但零消费」。
现象二:报IndexOutOfBoundsException,或者明明写对了却提示失败。定位到D()的else分支,里面有一行index--。
原因是这条路上的语义搞混了。D()在失败时根本没有前移过index,那一行index--是照着「匹配失败要回退」的口诀加的,实际效果是把游标退到前一个已经匹配好的 token 上。比如list是[t, i, ;],D()成功后index=1;下一次D()在i上失败,index--变成 0,又指回了t,后面的P()看到t以为还能再匹配一条声明,整个序列就乱了。
解决方式是让失败路径保持游标不动:
static void D() { if (index < list.size() && (list.get(index).equals("t") || list.get(index).equals("f"))) { index++; // 成功才前移 } else { flag = 0; // 失败只置标志,index 原地不动 } }同时把所有list.get(index)前面补上index < list.size()。递归下降最容易在文件末尾踩空,因为最后一层递归一定会多试探一次。
5.2 词法层的两处问题:float 落成标识符、末字符被吞
现象三:输入里明明有float,分析结果里却出现i,然后语法报错。打印中间的list一看,{ float i; }变成了{ i i ; }。
原因是isKey()的关键字表里没有float,或者加进去了但分支没接上。isKey返回-1之后,analyze里的else分支一律list.add("i"),float就这样被当成普通标识符。题目要求「设置其单词种别编码为 26」,很多实现只在报告里写了 26,代码里还是走了标识符分支。
解决方式是把关键字表和种别编码绑在一起维护,别用两处硬编码:
// 关键字 → 内部符号的映射,加一个关键字只改这里 private static final Map<String, String> KEYWORDS = new LinkedHashMap<>(); static { KEYWORDS.put("int", "t"); // 种别编码 1 KEYWORDS.put("float", "f"); // 种别编码 26,实验新增 } // 词法分析里改成一次查表 String kind = KEYWORDS.get(arr); list.add(kind != null ? kind : "i");顺手还能解决if、void这些「在表里但文法里没有」的词被误判成标识符的问题——查表拿不到就直接报「文法不支持的关键字」,比静默降级成i强得多。
现象四:源文件最后一行如果没换行,最后一个 token 会消失。比如文件内容就是{ int i; }(结尾无换行),分析结果变成{ t i ;,右花括号没了。
原因在analyze的循环条件i < chars.length - 1。这个-1是为了跳过main里new char[length + 1]多开的那一位,但多开的那一位只保证在最后,前面的字符一个都不能少。文件末尾没有换行时,chars[length]是\0,chars[length-1]才是真正的},循环条件把它排除了。
解决方式是统一在main里补一个填充字符,或者干脆把analyze改成接收有效长度:
// 方案一:main 里显式补位,analyze 按完整长度扫 buf[length] = '\n'; // 填充一个空白,不影响 token 产出 analyze(buf, length); // 传有效长度进去,不再靠 -1 猜 // 方案二:analyze 内部跳过 '\0' for (int i = 0; i < chars.length; i++) { ch = chars[i]; if (ch == '\0') continue; // 空字符直接略过 // ... 其余逻辑 }我一般选方案二,少一个「调用方必须配合」的隐式约定。这种约定写的时候记得住,隔两周再看就是黑匣子。
5.3 运行层的一处问题:硬编码的绝对路径
现象五:换一台机器,或者把工程挪个目录,main一跑就抛FileNotFoundException,路径指向一个根本不存在的盘符。
原因是这一行:
File file = new File("D:\\JAVA EE 源代码\\Recursive descent analyzer\\ex.txt");绝对路径绑死了作者本机的目录结构,连盘符都写进去了。这类问题在实验报告里最常被忽略,因为作者自己的机器上永远能跑。
解决方式是把路径挪到启动参数,或者用相对路径:
public static void main(String[] args) throws Exception { // 默认读工程根目录下的 ex.txt,启动时可以用参数覆盖 String path = args.length > 0 ? args[0] : "ex.txt"; File file = new File(path); if (!file.exists()) { System.err.println("输入文件不存在:" + file.getAbsolutePath()); System.exit(1); } // ... 后续读取 }顺手把System.exit(0)改成1。批处理脚本、CI 流水线判断成功失败看的就是退出码,恒返回 0 等于把错误信息埋了。
6. 二次开发:把实验代码改成能报错定位的小工具
这套代码跑通之后,最值得做的改造不是加功能,而是让它在出错时说清楚错在哪。三个改动,按收益排序。
第一个是错误定位。现在flag只有 0/1 两个值,失败时你只知道「挂了」。加一个errorAt记录首次失败时的index,再把 token 表反查回原字符位置,输出就能变成第 1 行第 9 列:期望 ';',实际读到 'i':
private static int errorAt = -1; // 首次失败的位置,-1 表示还没出错 static void fail(String expected) { if (flag == 1) { // 只记第一处,后面的都是连锁反应 errorAt = index; System.err.println("期望 " + expected + ",实际读到 " + (index < list.size() ? list.get(index) : "<文件结束>")); } flag = 0; }fail()里if (flag == 1)那个判断是血泪经验。递归下降出错之后会一层层往上退出,每一层都会发现自己「没匹配上」,如果不加这个判断,你会看到二三十行错误输出,真正有用的只有第一行。
第二个是把文法数据化。现在十一条产生式写死在十一个函数里,改文法就得改代码,加个for循环就得再写一个函数。可以抽一张产生式表,用Map<String, List<String[]>>描述,再配一个通用匹配器。这对教学实验来说有点过度设计,但如果你想拿它去解析自定义的配置格式或者 DSL,这一步是分水岭。
第三个是补单元测试。递归下降的坑集中在边界上:空块{ }、嵌套括号((i))、连续声明{int i; float j;}、缺分号、缺右括号。把这五个写成断言,改造一次跑一次,比手工输用例靠谱得多:
// 期望成功:两个声明连写,这是原版最容易挂的用例 assertEquals(true, parse("{ int i; float j; i = i + d; }")); // 期望失败:漏了右花括号 assertEquals(false, parse("{ int i; i = i + d; ")); // 期望失败:表达式里出现未定义符号 assertEquals(false, parse("{ int i; i = i & d; }"));写完测试你会发现,P()漏吃分号这个 bug 在第二条用例上立刻暴露——原版只测单条声明,所以一直没被发现。
从那以后我每次写完一个递归下降函数,都会先问自己两句:它在什么情况下前移游标,在什么情况下原地返回,然后才去管结果对不对。顺序反了,后面全是玄学调试。希望帮到你。
本文还有配套的精品资源,点击获取