Java实现SLR(1)语法分析器:从LR(0)项目集到ACTION表完整解析
简介面向编译原理课程实验的SLR(1)语法分析器Java实现压缩包内为配套的完整IntelliJ IDEA工程。SLR(1)属于自底向上语法分析方法适合处理不含左递归和左公因子的上下文无关文法代码完整演示了文法表示、消除左递归与提取左公因子、构造FOLLOW集、闭包与GO TO集进而生成分析表并驱动移进/归约解析。整套工程可直接导入IDEA运行便于边调试边对照教材理解。资源共85个文件其中76个Java源文件另有6个XML配置、1个iml项目文件及说明文档包体仅40KB结构紧凑。Java源码按模块组织XML对应Maven依赖与IDEA工程设置适合编译原理学习者或需要完成类似实验的开发者参考。已有521人学习下载。通过阅读并运行该工程可以直观掌握SLR(1)分析表的构建原理以及自底向上解析过程中状态栈、符号栈与输入串之间的配合方式。1. SLR(1)语法分析器缺失的编译原理实验作业一份能跑的Java实现学编译原理的时候最让人头疼的不是词法分析器而是语法分析阶段那张层层递进的LR分析表。教材上把SLR(1)的构造过程写得清清楚楚——先写文法、再求FIRST和FOLLOW、然后构造LR(0)项目集族、最后填表——但真到了需要上机实现面对“如何用Java写一个SLR(1)语法分析器”的实验题很多人直接卡在第一步项目集族的闭包计算到底怎么写这个压缩包里装的就是一个把教材算法完整落地成Java代码的实验项目。它实现了从文法输入到ACTION表自动构造、再到驱动分析栈完成句子归约的全过程附带冲突检测和错误提示。适合正在做编译原理实验的学生也适合想回顾LR分析器内部机制的开发者——拿到手能直接跑通也能照着源码理解整个分析流程的骨架。2. 项目结构速览文法输入、核心类划分与运行方式拿到压缩包后第一件事是把它解压成标准的Java工程推荐直接用IDEA打开。整个项目的包结构围绕“一个分析器该有的零件”来组织——文法表示、集合计算、分析表、驱动循环每块都对应一个独立类文件。先别急着看代码把项目结构搞清楚后面调试会省很多时间。2.1 目录结构七个类文件如何分工解压后你会看到典型的src目录核心类大概分为以下几层Grammar类负责读入文法产生式、做拓广处理FollowSet类计算每个非终结符的FOLLOW集合Closure类处理LR(0)项目集的闭包运算和GOTO函数ActionTable和GotoTable分别存两类分析表SLRAnalyzer是主控类把前面所有零件串起来执行分析。这种划分方式非常贴近教材推理链路——闭包计算依赖文法规则的表示分析表驱动依赖闭包得到的项目集族。工程里名字可能略有出入但按这个逻辑去找十分钟就能对上号。关键的文件里Grammar承担了最底层的职责它把“S-EE | E*E | (E) | id | n”这种产生式拆成对象——左部是一个非终结符字符串右部是一个符号列表终结符和非终结符分开存放。这里有一个经常被忽略的设计项目集闭包需要反复查询“某个非终结符的所有产生式”所以Grammar里一般会维护一个MapString, List 结构key是左部符号value是对应的全部产生式。这种预索引设计避免了每次闭包运算都遍历整个文法属于实现时必要的性能优化。2.2 文法配置与启动流程项目的运行入口通常在SLRAnalyzer类的main方法里。它会先构造一个Grammar对象然后调用一个类似buildLR0Automaton的方法。这里得说明白SLR(1)本质上是在LR(0)自动机上加一张按FOLLOW集合决定归约动作的分析表所以流程必然是先求FOLLOW再求LR(0)项目集族最后填表。main方法里一般会把这些步骤全部跑完然后读取输入串比如“idid*id”输出分析过程——包括每一步的状态栈变化、符号栈内容、剩余输入以及执行的是移进还是归约。启动方式按最常规的习惯来把项目用IDEA直接Open等Maven或Gradle同步完成这个项目很可能是原生Java工程不需要依赖管理器找到SLRAnalyzer类右键Run即可。如果你习惯命令行也可以用javac编译整个src目录再运行主类。运行成功后会在控制台看到一张打印出来的ACTION表每一列对应一个终结符每一行对应一个状态编号表格里的“s4”表示移进并压入状态4“r2”表示按编号为2的产生式归约“acc”表示接受。这张表是判断你整个实现是否正确的直接依据——如果输入串最终以acc结束说明你的分析表构造代码至少是自洽的。2.3 输出结果解读跑完一个正确输入后控制台的输出一般分两段前半段是构造期的信息展示FOLLOW集合和ACTION/GOTO表后半段是分析过程的现场回放。理解现场回放是确认自己是否真的弄懂分析器行为的关键——每一行输出都会显示当前栈顶状态查表得到动作然后更新三样东西状态栈、符号栈、输入指针。如果你发现分析输出的最后不是acc而是打印了“error at position 5”之类的话那问题大概率出在FOLLOW集合计算或者闭包计算上而不是驱动逻辑本身。3. 核心实现拆解从CLOSURE运算到LR(0)项目集族整份代码最值得细读的是LR(0)自动机的构建过程这也是面试和实验答辩中最高频的考点。它的本质是不动点迭代——从一个初始项目“S - .S”出发反复施加闭包运算和GOTO转换直到项目集的数量不再增长。这个过程用代码写出来其实比教材上的形式化描述直观得多。3.1 产生式与项目的数据结构在Java里定义文法产生式常见做法是写一个Production类内部持有左部字符串、右部符号列表和产生式编号。注意右部符号列表必须保留顺序因为归约时要从栈顶弹出与右部等长的符号数。接着定义Item类代表一个项目也就是“圆点位置”不同的产生式。Item里至少要有两个字段所属的Production引用、圆点在右部符号中的偏移量。特别要注意Item必须重写equals和hashCode判断标准就是“同一个产生式且圆点位置相同”否则后面用Set做去重时会直接失效项目集会爆炸式增长。public class Item { private Production production; private int dotIndex; public Item(Production production, int dotIndex) { this.production production; this.dotIndex dotIndex; } public Symbol getCurrentSymbol() { // 圆点不在右部末尾时返回圆点后的那个符号 if (dotIndex production.getRhs().size()) { return production.getRhs().get(dotIndex); } return null; } Override public boolean equals(Object obj) { if (!(obj instanceof Item)) return false; Item other (Item) obj; return this.production other.production this.dotIndex other.dotIndex; } Override public int hashCode() { return production.getId() * 31 dotIndex; } }这里面的getCurrentSymbol方法是闭包运算的触发器只有当它返回一个非终结符时闭包才需要把这个非终结符的所有产生式以“圆点在最左边”的形式加入集合。equals方法用的是引用相等加整数比较因为Production对象的唯一性由文法读入时的编号保证——同一个产生式不会重复构造所以引用比较是安全的。dotIndex从0开始计数这符合教材里“S - .S”表示圆点在第一个符号之前的规定。3.2 CLOSURE运算一个队列驱动的不动点算法闭包运算是整个项目集族构建的地基。教材上的定义是一个项目集中所有项目的闭包包含所有圆点后面是某个非终结符B时B的每个产生式对应的“圆点在开头”的项目。翻译成代码最常见的写法是维护一个待处理队列——先把初始项目放进结果集然后循环弹出项目检查圆点后的符号发现非终结符就把它的全部产生式加入直到队列为空。public SetItem closure(SetItem items, Grammar grammar) { SetItem result new HashSet(items); DequeItem workList new ArrayDeque(items); while (!workList.isEmpty()) { Item item workList.poll(); Symbol symbol item.getCurrentSymbol(); // 只有圆点后面紧跟着非终结符时才触发闭包扩展 if (symbol ! null grammar.isNonTerminal(symbol)) { for (Production prod : grammar.getProductions(symbol)) { Item newItem new Item(prod, 0); // add返回true说明是全新的项目才需要继续展开 if (result.add(newItem)) { workList.add(newItem); } } } } return result; }逻辑说明这段代码的退出条件是队列为空但因为闭包具有单调性——每次新增项目都会再触发一轮扫描——所以它必然收敛。这里有个容易出错的细节getProductions(symbol)拿到的必须是非终结符的全部产生式不能遗漏否则会导致后续项目集缺失最终分析表怎么也拼不完整。另外一个技巧是依赖HashSet的add返回值判断是否真正新增避免重复项目被无限压入队列。这个工作列表模式在编译原理的集合计算里非常通用——FIRST集、FOLLOW集也都能用同样思路实现。3.3 GOTO函数与自动机构建闭包算完之后下一步是从每个项目集出发看圆点后面的符号把圆点往后推一格得到新的项目集。这就是GOTO函数。再对整个GOTO结果求一次闭包就得到一个完整的新状态。整个LR(0)自动机的构建实际上是两层循环外层遍历已知状态内层遍历文法符号集合终结符加非终结符计算GOTO后如果得到的是新状态就加入状态列表。这里必须用Map或者二维状态编号记录“项目集 - 状态编号”的映射否则无法判断新状态是否已经在自动机里存在。public ListSetItem buildAutomaton(Grammar grammar) { ListSetItem states new ArrayList(); MapSetItem, Integer stateIndex new HashMap(); SetItem initItems new HashSet(); initItems.add(new Item(grammar.getAugmentedStartProduction(), 0)); SetItem initClosure closure(initItems, grammar); states.add(initClosure); stateIndex.put(initClosure, 0); for (int i 0; i states.size(); i) { SetItem state states.get(i); // 收集当前状态中所有圆点后面的符号避免重复计算GOTO SetSymbol symbols new HashSet(); for (Item item : state) { Symbol s item.getCurrentSymbol(); if (s ! null) symbols.add(s); } for (Symbol symbol : symbols) { SetItem gotoSet new HashSet(); for (Item item : state) { Symbol s item.getCurrentSymbol(); if (s ! null s.equals(symbol)) { gotoSet.add(new Item(item.getProduction(), item.getDotIndex() 1)); } } SetItem gotoClosure closure(gotoSet, grammar); if (gotoClosure.isEmpty()) continue; if (stateIndex.containsKey(gotoClosure)) { // 已存在的状态记录转移边即可 transitionTable[i].put(symbol, stateIndex.get(gotoClosure)); } else { int newIndex states.size(); states.add(gotoClosure); stateIndex.put(gotoClosure, newIndex); transitionTable[i].put(symbol, newIndex); } } } return states; }这段代码里最关键的数据结构是transitionTable它是一个数组每个元素是一个MapSymbol, Integer记录从当前状态遇到某个符号后跳转的目标状态编号。注意我在收集符号时用了Set去重这避免了对同一符号的重复GOTO计算——如果项目集里有两条项目同时指向同一个符号AGOTO结果是一样的没有必要算两遍。这种细节优化在状态数量上了几百之后效果非常明显。构建完自动机就拿到了两张中间产物一是完整状态列表二是每个状态的转移表。接下来就能进入分析表填充环节。这里有个值得记住的设计判断LR(0)自动机的构建和分析表的填充解耦让代码更容易调试——如果你发现某个输入串分析失败可以先观察自动机的转移是否正确再判断是填表逻辑错了还是FOLLOW集合错了不需要一次性排查所有代码。3.4 从自动机到ACTION表自动机建好后填充ACTION表的逻辑就相对机械了。遍历每个状态中的每个项目按项目圆点位置分三种情况处理圆点后面是终结符时查转移表得到目标状态填入“移进”动作圆点已经在右部末尾时对该状态的所有FOLLOW集合元素填入“归约”动作归约编号就是产生式编号圆点是拓广产生式右部末尾时对结束符填写“接受”。这个过程写出来大约二十行但有一个必须处理的棘手问题——冲突检测。public void buildActionTable(ListSetItem states, MapInteger, SetString followSets) { for (int i 0; i states.size(); i) { for (Item item : states.get(i)) { Symbol s item.getCurrentSymbol(); if (s ! null s.isTerminal()) { // 移进动作action[i][s] shift 目标状态 int target transitionTable[i].get(s); setAction(i, s.getName(), SHIFT, target); } else if (s null) { // 归约项目按FOLLOW集合决定在哪些输入符号上归约 String left item.getProduction().getLeft(); SetString follow followSets.get(left); for (String terminal : follow) { setAction(i, terminal, REDUCE, item.getProduction().getId()); } } if (item.getProduction().isAugmented() s null) { setAction(i, $, ACCEPT, -1); } } } }这里的潜在风险是“覆盖”。如果同一个格子先被填了移进又被填了归约直接覆盖会导致分析器行为不确定——有时候碰巧能跑对但本质上是错的。正确的做法是在setAction里检测冲突如果目标格子已经有值且和当前要填的动作不一致就应该立刻报告“该文法不是SLR(1)文法”。教材上用术语叫“SLR(1)冲突”这个报错信息对使用者来说比静默覆盖要友好得多能帮助定位到具体的产生式和FOLLOW集合元素。4. 分析驱动循环栈顶状态、输入符号与ACTION表的每一步博弈分析表构建完成之后整个分析器就进入了“运行时”。这一部分的结构非常清晰读代码时建议把重点放在状态栈的维护方式上——它决定了归约时从哪里弹出、移进时压入什么。4.1 状态栈与符号栈的双栈设计分析器内部使用两个栈一个状态栈存储LR(0)自动机的状态编号一个符号栈存储已移进或已归约出的文法符号。两个栈是同步增长的——每执行一次移进状态栈压入新状态、符号栈压入当前输入终结符每执行一次归约状态栈弹出右部长度个数个元素、符号栈弹出同样数量个元素然后压入左部非终结符再根据GOTO表压入新状态。这个“弹出右部长度个元素”的操作是整个分析过程中最容易出边界bug的地方——如果栈里元素不足或者GOTO表查不到转移目标就要立刻终止并报错。public boolean parse(String input, ListString tokens) { DequeInteger stateStack new ArrayDeque(); DequeString symbolStack new ArrayDeque(); stateStack.push(0); symbolStack.push($); int index 0; while (true) { int currentState stateStack.peek(); String lookahead index tokens.size() ? tokens.get(index) : $; Action action getAction(currentState, lookahead); if (action.isShift()) { // 移进压入目标状态和当前输入符号读下一个输入 stateStack.push(action.getTarget()); symbolStack.push(lookahead); index; } else if (action.isReduce()) { Production prod getProductionById(action.getReduceNumber()); // 弹出右部长度个符号压入左部符号用GOTO表转移状态 for (int i 0; i prod.getRhs().size(); i) { stateStack.pop(); symbolStack.pop(); } Integer nextState getGoto(stateStack.peek(), prod.getLeft()); if (nextState null) { return false; } stateStack.push(nextState); symbolStack.push(prod.getLeft()); } else if (action.isAccept()) { return true; } else { return false; } } }驱动循环的判断逻辑说明每次迭代先取状态栈顶和当前输入符号查ACTION表得到三类动作之一。移进动作的关键是“跳过一个输入位置”——lookahead会被压入符号栈不用额外保存归约动作的关键是“用产生式右部长度驱动弹出次数”——这个长度决定了状态栈要回退几步然后在回退后的栈顶状态查GOTO表得到下一个状态。值得注意的是归约时可能连续多次归约比如归约出E之后又归约出S此时下一次循环会自动基于栈顶新状态重新计算不需要特殊处理。4.2 三步调试法从失败回放到表格定位如果分析中途返回false最直接的排查办法是在每个分支打印日志输出当前状态、输入符号和动作类型。这个习惯能帮你避免一个常见的认知误区——很多人以为分析失败一定是分析表构造错了但实际多半是错误恢复逻辑或输入切分的问题。调试时推荐按三步走第一步看终结符切分确认“id”是否被切成“i”和“d”第二步看ACTION表内容手动模拟几步移进确认状态转移是否符合预期第三步看GOTO表重点检查归约后的状态是否真的存在。这种从外部输入到内部查表、从驱动到构造的排查顺序比直接在几百行代码里打日志有效率得多。4.3 性能表现与适用范围这套分析器的核心复杂度在构造期——CLOSURE和GOTO调用是O(n²)级别但分析期每个输入符号只查询一次哈希表是严格的线性时间。对于一个实验项目来说处理几百行的表达式求值输入毫无压力。它的限制在于文法本身是“非得是SLR(1)”——即ACTION表不允许出现冲突。换句话说它能分析的语言类别严格小于LR(1)文法能覆盖的语言类别遇到悬空else这类经典二义性问题会直接报错。这个项目里如果你输入的文法是左递归或二义性的构建分析表时就会立刻抛出冲突异常而不是等到分析期才出错——从工程上讲这是好事因为错误被尽可能前置了。5. 避坑指南从构造到调试的四条血泪经验写这个实验时最容易翻车的节点不是“看不懂算法”而是“代码看起来每一步都对合在一起就见鬼了”。以下四条是我在实际调试中反复遇到的现象和解决路径每条都按“现象 → 原因 → 解决”来写希望能帮你绕过同样的坑。5.1 现象分析表构建报“第0行第1列已有归约动作又试图写入归约动作”原因拓广产生式被错误地当作普通产生式放进了产生式列表导致闭包计算时产生了额外的归约项目。拓广产生式S - S只用于识别接受动作不应该参与FOLLOW集合计算和GOTO函数的符号收集。解决在Grammar类里单独保存拓广产生式普通产生式列表完全排除它。具体检查方法是打出一个项目集的完整内容如果看到S - S . 出现在最初几个状态里且不是由初始状态唯一产生说明拓广处理有污染。5.2 现象程序死循环CPU飙满最后栈溢出原因FOLLOW集合的计算是一个典型的不动点迭代如果退出条件写错——比如只在某一轮没有任何变化时退出而实际实现里每次集合更新后没有记录“是否发生过变化”——就会永远迭代下去。解决用一个boolean变量记录本轮是否有任何集合变化如果一轮扫描后没有任何变化才退出。我习惯用一个maxIteration兜底值比如文法符号数的三倍做保护超限直接报错。对比FIRST集合的求解可以很快收敛FOLLOW集合的依赖链更长这个保护尤其重要。5.3 现象项目集数量异常大达到几百上千而预期只有几十个原因闭包运算中的去重失效。Item对象没有重写equals和hashCode导致Set认为两个本质上相同的项目同一个产生式、同一个圆点位置是不同的对象不断重复加入。解决重写Item的equals和hashCode且注意hashCode的计算必须包含生产式编号和圆点位置两个字段——只包含一个字段的话碰撞率会非常高虽然最终结果大概率还是对的但性能难以接受。这个坑非常隐蔽因为代码不会报错只是状态数爆炸分析器也能跑但分析表大得离谱。5.4 现象输入串明显符合文法分析器却报语法错误原因GOTO表在归约后的状态转移处出现了空值。常见场景是非终结符的GOTO表条目漏填或者初始状态没有被正确设置为0号状态——如果状态编号从1开始而压入栈的初始状态恰好是0查表时永远查不到正确动作。解决在自动机构建完成后立刻打印GOTO表的完整内容逐个检查每个非终结符的每个状态是否都有目标值。一个合格的表应该是没有空格的任何空格都意味着转移缺失必须回到自动机构建阶段检查GOTO计算逻辑。5.5 现象接受的句子里混入了不合法的符号位置比如“id id”被接受原因输入串在送入分析器之前没有做词法切分和语法前置校验。SLR(1)分析器只负责检查“符号序列是否符合文法”它不会验证相邻符号是否有意义。比如文法允许E - id但输入“id id”如果在词法层被切成了两个终结符id分析器可能按“E - .id, E - .id”的方式归约出两个E反而可能被接受。解决在驱动循环之前增加一步简单的终结符合法性检查确认每个token都在终结符集合内再进入分析。这一步不属于编译原理课程通常要求的范围但属于工程上手就必须做的防御。5.6 现象缓冲输入串最后缺少结束符分析器把整条输入分析完却报错原因驱动循环的终止条件依赖“$”作为结束标志而如果输入处理时忘记在末尾附加“$”分析器会尝试读取不存在的token。解决在解析入口界定输入格式——要么在tokens列表末尾强制添加“$”要么在getAction查询时把越界情况统一视为“$”。推荐后者因为前者容易在后续处理里把“$”误当成真正的终结符参与归约。6. 二次开发给分析器加一个可视化分析表导出功能当分析器能稳定跑通教材里的经典文法之后下一步值得做的是把分析表以更直观的方式输出。我在改造这个项目时写了个扩展把ACTION表和GOTO表合并后导出成CSV文件再用Excel或表格阅读器打开查看。这个功能有三个实际用途一是验证自己手工推演的表是否和程序计算的一致二是实验报告里可以直接放表格截图三是在分析冲突时可以快速按下标定位到具体格子。public void exportTableToCsv(String filePath) { try (FileWriter writer new FileWriter(filePath)) { StringBuilder header new StringBuilder(State); for (String terminal : terminals) { header.append(,).append(terminal); } for (String nonTerminal : nonTerminals) { header.append(,).append(nonTerminal); } writer.write(header \n); for (int i 0; i stateCount; i) { StringBuilder line new StringBuilder(String.valueOf(i)); for (String terminal : terminals) { line.append(,).append(formatAction(i, terminal)); } for (String nonTerminal : nonTerminals) { line.append(,).append(gotoTable[i].get(nonTerminal) null ? : gotoTable[i].get(nonTerminal)); } writer.write(line \n); } } catch (IOException e) { e.printStackTrace(); } } private String formatAction(int state, String terminal) { Action action getAction(state, terminal); if (action null) return ; if (action.isShift()) return s action.getTarget(); if (action.isReduce()) return r action.getReduceNumber(); if (action.isAccept()) return acc; return error; }改造思路说明这里的核心是把二维数组的表示映射成行文本终结符写在前面几列、非终结符写在后面几列状态编号放最左边。formatAction方法把Action对象压缩成常见的“s3/r4/acc”三位字符串空字符串代表“没有动作”——也就是语法错误位。这种输出格式和编译原理教材里的分析表可视化写法完全一致放到实验报告里不需要额外说明。如果你嫌CSV不够直观把输出目标换成HTML表格也是十几行的事——生成一个table标签每个格子背景色按动作类型加红绿标记冲突位置直接标黄这样检查冲突时不用盯着一堆数字脑补一眼就能看到哪一行哪一列的格子内容彼此矛盾。改写这个功能时我踩了个坑GOTO表的列头只能放非终结符不能把终结符混进去否则输出的表格会出现“E在id列也有数字”这种对不上教材的怪象。从那以后我每次改这类表格导出都强制先确认终结符集合和非终结符集合的交集为空再动手拼接字符串——这个习惯救了我好几次。另外提醒一点导出功能不要改原有分析逻辑的内部结构用独立方法持有自己的文件写入逻辑就好这样万一导出格式出了问题不会影响到分析器的正确性。希望这份解析能帮你在实验答辩前把项目的每个细节都摸熟动手改的时候少走弯路。本文还有配套的精品资源点击获取