☰
递归下降语法分析实战:词法分析、消除左递归与FIRST/FOLLOW排错
2026/10/31 20:30:58 网站建设 项目流程

简介:这份面向编译原理课程实验的文档,围绕自上而下语法分析中的递归下降分析法展开,适合正在完成语法分析实验、需要理解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 N

2.3 手算 FIRST/FOLLOW 集,用冲突判定 LL(1)

文法改完不等于能写递归下降,还得证明它是 LL(1) 的——换句话说,任何一个非终结符,看到下一个输入符号时,只能有一条产生式可选。判定规则两条:

  1. 对X → α | β,要求FIRST(α) ∩ FIRST(β) = ∅;
  2. 如果α能推出Ɛ,还要额外要求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是语法分析器内部用的符号别名。中间隔了一层映射,我一般会这么记:

单词对外种别编码分析器内部符号说明
int1t数据类型,两套编码都要有
float26f本次实验新增,编码不能和已有种别撞
标识符10i不以数字开头、不在关键字表里的串
整数常量11d只认十进制,不处理小数和符号
界符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 变化
1A()看到{,吃掉0 → 1
2M()→P()→D()看到t,吃掉1 → 2
3P()看到i,吃掉2 → 3
4P()看到;,吃掉3 → 4
5M()递归调P(),D()看到i不是t/f,置flag=0仍是 4
6M()flag是 0,不再递归,按Ɛ返回仍是 4
7A()flag复位为 1仍是 4
8N()→Q()看到i,吃掉;看到=,吃掉4 → 6
9Q()→E()→T()→F()看到i,吃掉6 → 7
10T()→H()H()看到+不匹配*//,按Ɛ返回仍是 7
11G()看到+,吃掉7 → 8
12G()→T()→F()看到d,吃掉8 → 9
13H()→G()越界处当Ɛ返回仍是 9
14N()递归Q()越界,置flag=0,返回仍是 9
15A()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 在第二条用例上立刻暴露——原版只测单条声明,所以一直没被发现。

从那以后我每次写完一个递归下降函数,都会先问自己两句:它在什么情况下前移游标,在什么情况下原地返回,然后才去管结果对不对。顺序反了,后面全是玄学调试。希望帮到你。

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

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

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

立即咨询