☰
编译原理语法分析实验:LL(1)预测分析表构建与避坑指南
2026/10/14 8:47:11 网站建设 项目流程

简介:南京邮电大学编译原理实验二(语法分析)完整实验报告,面向计算机专业学生与编译原理初学者,针对LL(1)语法分析器的构造给出全流程示范。报告首先演示左递归的检测与消除,随后逐步求解FIRST集和FOLLOW集,并据此构建LL(1)分析表;同时提供带详细注释的C++分析程序核心代码,覆盖符号串输入的判别与分析过程显示,算法时间复杂度也有说明。资源包仅包含1个doc文档,大小937KB,内容组织为实验目的、实验原理、步骤详解和源代码,包含了文法改写结果、集合求解表与分析表等关键材料,便于对照实验要求逐项核查。已有296人学习浏览该文档,适合正在完成编译原理实验、需要参考完整设计思路或备考语法分析知识点的读者。

1. 语法分析实验:先想清楚文法,再写第一行代码

南邮的编译原理实验二——语法分析,是这门课里第一个让你真正感受到“编译器不是黑匣子”的环节。很多同学一拿到题目就写代码,最后把大量时间耗在调栈上,回头看才发现根子上的问题其实是文法设计:左递归没消干净、FIRST 集少算了 ε,预测分析表里全是空白项。这些东西在动手前就能避开。围绕这个实验的资料通常会包含文法设计样板、FIRST/FOLLOW 集计算骨架、预测分析表驱动主循环和测试用例,把这四块拆开理解,语法分析就不再是玄学,而是按表施工。这篇笔记按我实际拆实验的顺序来写,适合正在做实验二、或者想对照验证自己实现的同学参考。

2. 实验二整体架构:输入产出与四个核心类怎么拆

2.1 词法结果与语法分析器的接口约定

语法分析器不直接读源码,它读的是词法分析器吐出来的 token 流。实验里最常见的交接格式是每行一条 token,三列:类型、值、行号。行号一定要带,因为语法错误的定位比词法错误难得多,没有行号,报错信息基本等于废话。词法分析阶段可能已经输出过一份这样的文件,语法分析器把它当作输入就行。常见做法是词法阶段产出token.txt,语法阶段读它,两个阶段用文件解耦,调试时可以手工编辑 token 文件来测试语法分析器,不用每次重跑词法。

接口约定样例大概是这样的格式:

INT i 1 PLUS + 1 INT i 1 STAR * 1 INT i 1 EOF # 1

最后一条用 EOF 表示输入结束,文法里的#和它是同一个东西。这里有个小设计点:type 用终结符名而不是直接用字符本身。INT i和PLUS +分开存,后面做预测分析表时,表头和栈顶比较都是拿终结符名去比。要是直接把+当 type,后面文法里写终结符也得写+,一旦词法输出里混进别的写法,栈顶和输入就永远对不上。推荐的做法是统一用规范名,词法输出和文法定义各维护一张映射表,后文避坑章节会专门讲这个。

Token 类的定义非常简单,但字段设计决定了后面调试的体验:

public class Token { public String type; // 终结符名:INT / PLUS / STAR / LPAREN / RPAREN public String value; // 词素原文:i / + / * / ( / ) public int line; // 行号,语法错误定位用 public Token(String type, String value, int line) { this.type = type; this.value = value; this.line = line; } @Override public String toString() { return "(" + type + ", " + value + ", line " + line + ")"; } }

type、value、line 三个字段各管一件事:type 参与语法分析核心逻辑,value 只在输出报错信息时给人类看,line 用来定位。toString 是故意写出来的,调试时任何一步都可以System.out.println(tokenList)直接看整条 token 流,能省下不少猜的时间。

从文件读 token 流的代码:

import java.io.*; import java.util.*; public class TokenReader { public static List<Token> read(String path) throws IOException { List<Token> tokens = new ArrayList<>(); BufferedReader br = new BufferedReader(new FileReader(path)); String line; while ((line = br.readLine()) != null) { line = line.trim(); if (line.isEmpty()) continue; // 跳过空行,别把空行当 token String[] parts = line.split("[,\\s]+"); // 逗号或空白都作分隔符 if (parts.length < 3) { // 格式不完整,打印出来但别中断整个分析 System.err.println("忽略格式错误的行: " + line); continue; } tokens.add(new Token(parts[0], parts[1], Integer.parseInt(parts[2]))); } br.close(); return tokens; } }

这个读取器的设计思路是“宽容输入”:遇到格式坏的行只告警不崩溃,保证实验环境下能继续往后排查。参数说明:split的正则[,\\s]+同时兼容INT i 1和INT,i,1两种分隔方式,但注意如果词法输出带括号,比如(INT, i, 1),需要先把首尾括号剥掉再喂进来,否则parts[0]会变成(INT这种脏数据。

2.2 核心类怎么拆

语法分析实验的代码量不大,但四个阶段的职责边界如果不拉开,后面一加错误处理就乱。我一般按计算依赖顺序拆成四个类:文法、FIRST/FOLLOW 集计算、预测分析表构建、表驱动主循环。顺序依赖是单向的,文法类产出产生式,FirstFollow 类消费产生式算出集合,TableBuilder 类同时消费文法和集合建表,Parser 类最后消费表和 token 流。这样的好处是每个阶段都能单独打印中间产物验证,出问题的时候不用对着几百行代码疯狂 debug。

类职责关键产出
Grammar保存产生式列表、非终结符集、终结符集消除左递归后的文法
FirstFollow迭代计算 FIRST 与 FOLLOW 集两个 Map
TableBuilder按规则填充预测分析表二维表或嵌套 Map
Parser执行表驱动分析匹配日志或错误列表
TokenReader读入词法结果文件List<Token>

为什么实验二这个环节绝大多数课程都会落到 LL(1) 而不是 LR?因为预测分析表是手工能填、能验证的。LR 的自动机构造更适合用工具生成,手工写一遍既难查错也难讲清楚;而 LL(1) 的表不过几十个格子,FIRST、FOLLOW 集算完,每个格子填什么一眼能看明白。这个选型是整个实验的底层逻辑:不是 LL(1) 最能打,而是它在教学尺度上最能暴露问题、最好批改。理解了这一点,后面所有步骤都围绕“表怎么填、填完怎么查”展开,思路就顺了。

3. 文法设计与 FIRST/FOLLOW 集:先消左递归,再迭代到不动点

3.1 消除左递归与提取公共左因子

实验里最经典、也是大多数参考资料默认使用的文法,是简单算术表达式文法:

E → E + T | T T → T * F | F F → ( E ) | i

这个文法一眼看过去挺正常,但它带直接左递归:E 的产生式右部首字符是自己。LL(1) 预测分析表不允许同一个非终结符的首符集合里出现自己,否则查表永远在展开同一个产生式,栈不会变短。消左递归的通用办法是把左递归改写成右递归,新增一个非终结符专管尾部。

步骤产生式
消除前E → E + T | T
消除后E → T E',E' → + T E' | ε

照这个模式把 T 也处理一遍,得到的完整文法就是:

E → T E' E' → + T E' | ε T → F T' T' → * F T' | ε F → ( E ) | i

注意这里 E' 和 T' 里的 ε 不是凭空加的,它的语义是“这一层推导到此为止,不再继续嵌套”。整个文法里只有 E' 和 T' 能推出 ε,后面算 FIRST 集时,ε 的传播路线会变得非常清晰。

比直接左递归更阴的是间接左递归。假如文法里同时存在A → B α和B → A β,A 和 B 互相引用形成环,扫一遍产生式根本看不到A → A这种显式形态,改写法也就无从下手。我一般处理这类问题先画一张依赖图:每个非终结符作节点,产生式右部首字符是非终结符就连一条边,然后检查这张图有没有环。有环就先做代入,把间接左递归转换成直接左递归,再套上面的改写公式。这个步骤不能省,实验里文法是自己写的还好,要是参考书上给的文法,通常已经改写过了,直接拿来用就行;自己编的文法十有八九在这里埋雷。

公共左因子则是另一类问题:同一个非终结符的多个产生式右部以相同前缀开头,比如S → i E t S和S → i E t S e S。两个产生式都以i E t S开头,LL(1) 分析无法在读到i的那一刻决定走哪条路。处理方式是延迟决策:

S → i E t S S' S' → e S | ε

提取公共左因子的目的不是消除某个集合,而是让前缀相同的产生式合并成一条,把分歧点往后推。这个改写做不做,直接决定预测分析表里会不会出现冲突,避坑章节会再展开。

3.2 FIRST 集迭代计算与验证

FIRST 集的定义三条规则,但实现时最容易漏的是“ε 传播”:

  • X 是终结符,FIRST(X) = {X}
  • 产生式右部能推出 ε,把 ε 加入 FIRST(左部)
  • 处理X → Y1 Y2 ... Yk,先把 FIRST(Y1) 去掉 ε 加入 FIRST(X),如果 Y1 可空,继续把 FIRST(Y2) 去掉 ε 加入,以此类推;如果全部可空,ε 也要加入 FIRST(X)

迭代实现比递归直观,而且每一轮集合的变化都可以打印出来当调试证据:

import java.util.*; public class FirstFollow { private Map<String, Set<String>> first; private Map<String, Set<String>> follow; private List<String[]> productions; // 每条产生式:prod[0] 是左部,其余是右部 private Set<String> nonTerminals; private Set<String> terminals; // 迭代计算所有非终结符的 FIRST 集,直到集合不再变化 public void computeFirst() { boolean changed = true; while (changed) { changed = false; for (String[] prod : productions) { String left = prod[0]; Set<String> firstLeft = first.computeIfAbsent(left, k -> new HashSet<>()); int sizeBefore = firstLeft.size(); for (int i = 1; i < prod.length; i++) { String sym = prod[i]; if (terminals.contains(sym)) { firstLeft.add(sym); // 终结符直接加入,然后停 break; } Set<String> fs = first.get(sym); if (fs != null) { for (String s : fs) { if (!s.equals("ε")) firstLeft.add(s); } if (!fs.contains("ε")) break; // 当前符号不可空,停止向后扫描 } } if (allNullable(prod, 1)) firstLeft.add("ε"); // 右部全可空,ε 也要加 if (firstLeft.size() != sizeBefore) changed = true; } } } private boolean allNullable(String[] prod, int start) { for (int i = start; i < prod.length; i++) { if (!first.getOrDefault(prod[i], new HashSet<>()).contains("ε")) { return false; } } return true; } }

这段实现的关键点是外层 while 循环驱动到不动点,每一轮都完整扫一遍所有产生式。为什么这个方法稳?因为 FIRST(Y) 可能在当前轮次还没算完,但下一轮它有了新元素,外层就再扫一遍,直到没有任何集合再变大。参数说明:productions 用数组存,prod[0]固定是左部非终结符,右部符号从下标 1 开始;ε在整个工程里用同一个字符串常量,不要混用"epsilon"或者空字符串,否则后面判断可空性时会反复踩坑。

FOLLOW 集的规则比 FIRST 集多一条,而且指向相反,是从左部向右侧传播:

  • 开始符号的 FOLLOW 集里必须先放#
  • A → α B β:把 FIRST(β) 去掉 ε 之后加入 FOLLOW(B)
  • A → α B或者A → α B β且 β 可空:把 FOLLOW(A) 加入 FOLLOW(B)
public void computeFollow() { follow.get("E").add("#"); // 规则 0:开始符号加 #,这里的 E 是开始符 boolean changed = true; while (changed) { changed = false; for (String[] prod : productions) { String left = prod[0]; for (int i = 1; i < prod.length; i++) { String B = prod[i]; if (!nonTerminals.contains(B)) continue; Set<String> followB = follow.computeIfAbsent(B, k -> new HashSet<>()); int sizeBefore = followB.size(); if (i == prod.length - 1) { // A → α B 形态:左部的 FOLLOW 全部给 B followB.addAll(follow.get(left)); } else { // A → α B β 形态,按 β 是否可能是空串分两种情况 String nextSym = prod[i + 1]; if (terminals.contains(nextSym)) { followB.add(nextSym); } else { Set<String> betaFirst = first.get(nextSym); if (betaFirst != null) { for (String s : betaFirst) { if (!s.equals("ε")) followB.add(s); } if (betaFirst.contains("ε")) { followB.addAll(follow.get(left)); // β 可空,并入 FOLLOW(A) } } } } if (followB.size() != sizeBefore) changed = true; } } } }

注意这个实现里规定 β 是单个符号,实验文法的右部基本都满足这个条件;如果文法是A → B C D E这种多位尾串,需要在nextSym之后继续向后扫描 FIRST 串,逻辑上是一个内层循环,原理和 FIRST 集的第三条规则一致。第 3.2 节算完,建议把结果打印出来,和手工推导的验证表逐行比对:

非终结符FIRSTFOLLOW
E{ (, i }{ ), # }
E'{ +, ε }{ ), # }
T{ (, i }{ +, ), # }
T'{ *, ε }{ +, ), # }
F{ (, i }{ *, +, ), # }

这张表是后面一切工作的基准。实验里最常见的错误是 FIRST 和 FOLLOW 混淆,把终结符+算进 FOLLOW 就错了——FOLLOW 集里只放终结符和#。每个集合都对了,预测分析表才有意义。

4. 预测分析表与驱动流程:把文法织成一张可查的表

4.1 预测分析表生成规则

LL(1) 预测分析表是一个二维结构:行是非终结符,列是终结符或#,格子里的内容是产生式。生成规则只有两条:对每条产生式A → α,先把 FIRST(α) 里的每个终结符填进M[A][a];如果 α 能推出 ε,再把 FOLLOW(A) 里的每个终结符填进M[A][b]。第二条规则是直觉上最容易漏的,它处理的是“这个非终结符在当前位置什么都不推导,直接消掉”的情形。

我用嵌套 Map 代替二维数组,写起来快,打印也方便:

public class TableBuilder { private Map<String, Map<String, String[]>> table = new HashMap<>(); public void build(Grammar g, FirstFollow ff) { for (String[] prod : g.productions) { String left = prod[0]; Set<String> firstAlpha = ff.firstOfRight(prod); for (String a : firstAlpha) { if (!a.equals("ε")) { putEntry(left, a, prod); } } if (firstAlpha.contains("ε")) { for (String b : ff.followOf(left)) { putEntry(left, b, prod); } } } } private void putEntry(String nonTerminal, String terminal, String[] prod) { Map<String, String[]> row = table.computeIfAbsent(nonTerminal, k -> new HashMap<>()); String[] prev = row.put(terminal, prod); if (prev != null) { // 冲突检测:同一个格子被填了两次,说明文法不是 LL(1) System.err.println("冲突: M[" + nonTerminal + "][" + terminal + "] 已被 " + String.join(" ", prev) + " 占用,又来一条 " + String.join(" ", prod)); } } }

嵌套结构与二维数组效果等价:外层 key 是非终结符,内层 key 是终结符,value 是产生式数组。空表项在 Map 里就是 null,驱动循环拿到 null 直接报语法错误。putEntry里加了冲突检测,这一步非常关键。Hash 表 put 的默认行为是覆盖旧值,两个产生式抢同一个格子时如果不检查,后面填的会把前面填的悄悄顶掉,表面看起来一切正常,实际行为已经错了。实验里如果看到这段输出冲突告警,第一反应应该是回文法做提取公共左因子,而不是在表结构层面硬调。

选 Map 还是二维数组,这个看实验要求。如果报告要画一张规规整整的表格,二维数组配终结符和非终结符编号更直观;如果只是要让驱动循环跑起来,Map 的代码量少一半。我一般先用 Map 跑通全流程,确认 FIRST/FOLLOW 集没问题之后,再按报告排版要求换成二维数组。换的时候注意列的顺序要和终结符集合的迭代顺序一致,否则表格打印出来对不上。

4.2 表驱动主循环

预测分析器的运行状态只有两个:一个栈,一个输入指针。栈初始压入#和开始符号,输入串末尾是#。每一步看栈顶符号:是终结符就拿它和当前输入比,一样就弹栈并推进输入;是非终结符就查表,查到产生式就把栈顶弹出、产生式右部逆序压栈;查不到就直接报语法错误。这个循环写出来不到四十行:

public class Parser { private Deque<String> stack = new ArrayDeque<>(); private TableBuilder tableBuilder; private List<Token> tokens; private int idx = 0; public void parse(String startSymbol) { stack.push("#"); stack.push(startSymbol); while (!stack.isEmpty()) { // 调试日志放在动作之前,栈和输入都还是“当前状态” System.out.println("栈: " + stack + " 剩余输入: " + tokens.subList(idx, tokens.size())); String top = stack.peek(); Token cur = tokens.get(idx); if (top.equals(cur.type) || (top.equals("#") && cur.type.equals("EOF"))) { stack.pop(); idx++; continue; } if (isTerminal(top)) { // 栈顶是终结符但和当前输入对不上 System.err.println("语法错误 行 " + cur.line + ": 期望 " + top + ",拿到 " + cur.type); return; } String[] prod = tableBuilder.get(top, cur.type); if (prod == null) { System.err.println("语法错误 行 " + cur.line + ": " + top + " 无法推导 " + cur.type); return; } stack.pop(); // 产生式右部逆序压栈,保证栈顶是右部第一个符号 for (int i = prod.length - 1; i >= 1; i--) { if (!prod[i].equals("ε")) stack.push(prod[i]); } } if (idx < tokens.size() - 1) { System.err.println("输入串未结束,分析失败"); } else { System.out.println("分析成功"); } } }

逆序压栈是整个自顶向下分析的核心动作:弹出左部非终结符之后,右部符号要按从左到右的顺序进栈,所以必须倒着压,这样一轮循环结束,栈顶正好是右部首符。遇到 ε 产生式则不压任何东西,相当于这个非终结符直接消失,这也是 ε 在 LL(1) 里的真实语义——不消费输入,只推进推导。

调试日志要放在动作之前,不要放在弹栈、压栈之后。放在后面,打印出来的栈和输入已经处于“下一步”的状态,人眼对着日志核对文法时永远慢半拍,看多了容易把自己绕晕。日志格式就打两个关键状态:完整栈和剩余输入。栈从底到顶打印,和教材里的表示法一致。

跑i + i * i #的分析过程,日志应该是这个形态:

栈: [#, E] 剩余输入: [INT i, PLUS +, INT i, STAR *, INT i, EOF #] 栈: [#, E', T] 剩余输入: [INT i, PLUS +, INT i, STAR *, INT i, EOF #] 栈: [#, E', T', F] 剩余输入: [INT i, PLUS +, INT i, STAR *, INT i, EOF #] 栈: [#, E', T', INT] 剩余输入: [INT i, PLUS +, INT i, STAR *, INT i, EOF #] 匹配 INT,弹栈并推进输入 栈: [#, E', T'] 剩余输入: [PLUS +, INT i, STAR *, INT i, EOF #] 栈: [#, E'] 剩余输入: [PLUS +, INT i, STAR *, INT i, EOF #] 匹配 PLUS 栈: [#, E', T] 剩余输入: [INT i, STAR *, INT i, EOF #] ...

对照E → T E'、T → F T'、F → INT这几条产生式看日志,每一步的栈变化都能和产生式对上。到这里,实验的主流程已经完整了,剩下的事情就是把边界情况补齐,然后花时间在测试用例上。正常实现跑通i + i * i可能只需要一小时,但把各种错误输入都处理干净,往往要再花两三小时。

5. 避坑指南:五个翻车点与现场解法

这一章按出现频率排,前两个翻车点出在 FIRST/FOLLOW 计算阶段,第三个出在驱动循环,第四个出在建表,第五个出在测试环节。它们的共同根源是同一个:中间产物没有验证就直接往下走。FIRST 集算错了,预测分析表必然错,但表面症状出现在驱动循环,于是很多人一头扎进 Parser 类里调半天,完全没意识到问题在源头。

5.1 间接左递归导致 FIRST 集永远算不完

现象:程序运行几秒后栈溢出,或者 FIRST 集合不断变大但永远不收敛。

原因:文法里存在A → B α、B → A β这种互相引用,直接消除左递归时只处理了A → A这种显式形式,没做代入展开,环还在,迭代计算一只绕着环打转。

解决:先画依赖图找环。对每个产生式,检查右部首字符是不是非终结符,是就连一条边,然后对依赖图做 DFS,能回到自身就说明有间接左递归:

boolean hasCycle(String node, Map<String, Set<String>> graph, Set<String> visiting) { if (visiting.contains(node)) return true; visiting.add(node); for (String next : graph.getOrDefault(node, Collections.emptySet())) { if (hasCycle(next, graph, visiting)) return true; } visiting.remove(node); return false; }

检测到环之后,把其中一个非终结符的产生式代入另一个,展开成直接左递归,再套改写公式。代入步骤最好写在一张草稿纸上手动完成,错误率比在代码里硬改低得多。

5.2 FIRST 集漏了 ε,预测表少了一半条目

现象:输入串里出现了E' → ε必须上场的情形,比如栈顶是E'、当前输入是右括号)或者#,查表查到 null,输出“无法推导”。

原因:某个产生式右部全由可空非终结符组成,但 ε 没有被加进 FIRST(左部)。常见实现是处理Y1之后拿到空集就直接 break,没继续往后扫Y2、Y3。

解决:给每个符号单独维护一个“是否可空”的布尔值,先扫一遍产生式算出可空性,再算 FIRST。或者用第 3.2 节里allNullable的方式,每轮循环检查整个右部是否全部可空。验证方法就一条:把FIRST(E')和FIRST(T')打印出来,必须包含 ε。这两个非终结符是例子里的可空点,其它非终结符的 FIRST 里不该出现 ε。

5.3 栈顶终结符和输入符号死活对不上

现象:栈里明明是+,输入的 type 也是+,代码却报“期望 PLUS,拿到 PLUS”,怎么想都不应该。

原因:终结符的命名不统一。词法输出里类型叫PLUS,文法里写的终结符是+,两边各说各话。另一种常见翻车是把 type 存成 value,i和变量名混成一个类型,LL(1) 的“一步一个终结符”直接失效。

解决:统一终结符规范名。词法输出和文法符号之间建一张映射表,建表、查表、报错全走同一套名字:

词法输出 type文法终结符含义
PLUS+加号
STAR*乘号
LPAREN(左括号
RPAREN)右括号
INTi整数或标识符

检查方法:把第一条 token 的 type 打印出来,和文法右部符号逐字比对,对着对着就能发现是哪边写岔了。

5.4 表项冲突被静默覆盖

现象:自己写的用例i + i通过了,换i + i * i结果错得离谱,而且全程没有任何报错。

原因:HashMap 的 put 覆盖。两个产生式同时想把同一个格子填进去,后写的一方悄悄把先写的顶掉了,分析器顺着错误的产生式一路推导,输出自然不对。

解决:建表时加冲突检查,put之前先看格子里有没有东西。第 4.1 节的putEntry里已经写好了这层检查,不要图省事删掉。一旦看到冲突,判断是直接左递归没消干净还是公共左因子没提取,回文法层面解决,不要试图在建表时“二选一”塞进去——那就是手动编造 LL(1) 假象。

5.5 边界用例成了漏网之鱼

现象:作业的测试用例全绿,交上去老师提供的示例一跑就崩。

原因:测试只覆盖了标准形态i + i * i,单个i、括号嵌套、错误输入、空输入全都没测。语法分析器的绝大部分分支都在错误处理上,只测正常路径等于只测了三分之一。

解决:备一组用例矩阵,分成必过、错误恢复、异常输入三组。每组都要有自己明确的预期结果:

用例预期结果
i分析成功
i + i * i分析成功
( i + i ) * i分析成功
i +报错且定位到行号和缺失位置
( i + i报错,括号不匹配
空输入报错且不抛异常

把这些用例和预期输出存成文件,后面接一个回归脚本,每次改动代码都全跑一遍,比肉眼扫输出靠谱得多。

6. 进阶用法:错误恢复与全量用例回归

基础版的分析器遇到第一个错误就停了,交实验够用,但离“能用的编译器”还差一步。实验要求再高一点,通常会希望分析器具备错误恢复能力——报完错之后继续往下分析,一次性报出多个错误,而不是改一个错重跑一次。最经典的恢复策略是恐慌模式:当分析器发现某个非终结符无法推导当前输入时,持续弹出栈顶、跳过输入,直到栈顶和当前输入重新进入一个“能继续”的状态。

同步符号集合一般用 FOLLOW 集构造,现场实现时常见做法是把 FIRST 集也并进去,加快恢复速度:

private Set<String> syncSet(String nonTerminal, FirstFollow ff) { Set<String> sync = new HashSet<>(ff.followOf(nonTerminal)); sync.addAll(ff.firstOf(nonTerminal)); return sync; }

出错恢复的循环要单独写,别塞进主循环里把正常逻辑搅乱:

while (!stack.isEmpty()) { String top = stack.peek(); if (tokens.get(idx).type.equals(top)) break; // 输入能和栈顶对上,直接恢复 if (syncSet(top, ff).contains(tokens.get(idx).type)) break; // 当前符号在同步集里,暂停弹栈 stack.pop(); // 其它情况继续弹栈 }

这段逻辑的边界是注意别让两个条件都不满足时死循环。弹栈永远不会让问题恶化,栈弹空之后主循环自然退出并报告失败,加上同步集合的break保证栈里有可恢复的非终结符时能接上,两个出口都不缺。

回归验证我一般用一个 bash 脚本把所有用例一次跑完,每个用例人工审计过一遍预期输出,存成.expected文件:

#!/bin/bash for case in test/cases/*.txt; do java -cp . Parser "$case" > /tmp/out 2>&1 if cmp -s /tmp/out "test/expected/$(basename $case).expected"; then echo "PASS $case" else echo "FAIL $case" diff "test/expected/$(basename $case).expected" /tmp/out fi done

这个习惯来自我改别人实验代码的血泪经验:日志打印位置改一格,整个预期输出就会变,没有 expected 文件做基线,你根本分不清是自己改坏了还是用例本身有问题。从那以后我每次交实验前,都强制自己把用例矩阵全跑一遍,再人工抽查两份“错误输入”的报错文本,确认行号真的指向问题 token。等你把这条流程固化成习惯,语法分析实验基本就只剩填表了。希望帮到你。

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

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

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

立即咨询