语法分析器Java实现:从文法解析到分析表生成)
简介本资源是一份面向高校编译原理课程学习者与实验实践者的SLR(1)语法分析器Java实现项目聚焦自底向上语法分析核心算法帮助读者深入理解文法处理、分析表构建与状态机驱动的解析流程。压缩包共85个文件主体为76个Java源码文件涵盖文法表示、左递归消除、FOLLOW集计算、闭包与GO TO集生成、分析表构造及主控解析逻辑辅以6个XML配置文件含Maven依赖管理pom.xml及IntelliJ IDEA工程配置、1个.iml项目文件及.gitignore等辅助文件整体仅40KB轻量紧凑且结构清晰。已有521人学习下载适合课程实验复现、算法原理验证与Java工程化实现参考。读者可直接导入IDE运行调试通过源码逐层掌握SLR(1)从文法预处理到最终归约判定的完整技术链路并结合注释与模块划分快速定位关键算法实现细节。1. 这不是个 ZIP 包是编译原理课设里最硬的那块骨头SLR(1) 语法分析器 Java 实现能跑通、能调试、能改文法、能看分析栈——别再用 Python 写玩具了真要交作业/过答辩/理解 LR 分析本质就得啃这个你肯定试过网上搜“SLR(1) java 实现”结果翻三页全是空壳工程、缺 parser 表生成器、没文法输入接口、main 方法一跑就 ArrayIndexOutOfBoundsException。更糟的是有些代码把ItemSet当 List 用Goto函数硬编码改个终结符就得重写半个类——这不是教学资源是埋雷现场。而这个byyl SLR(1).zip是我去年带三届本科生做编译原理实验时从 27 个学生提交包里反向扒出的唯一一个完整闭环实现它用 Java 写从文法字符串解析 → FIRST/FOLLOW 集计算 → SLR(1) 分析表自动构造 → 输入串驱动分析栈模拟 → 输出每一步动作shift/reduce/accept和状态栈/符号栈快照。它不炫技但每个类职责清晰GrammarParser负责文法合法性校验支持 ε、左递归检测告警SLRTableBuilder用标准子集构造法生成action和goto表不是查表是真算ParserDriver模拟 LR 分析机行为连State类都重写了hashCode()防止哈希冲突导致项目集合并失败。适合两类人一是被课设 deadline 追着跑的大三学生解压即 run二是想真正搞懂“为什么 SLR(1) 比 LL(1) 强”“冲突怎么来的”“表里数字到底代表什么”的人——它把黑匣子拆成了可单步调试的齿轮组。2. 从文法定义到分析表生成为什么这个 Java 实现能避开“手算表抄错就全崩”的玄学陷阱2.1 文法输入格式不是随便写是严格遵循编译原理教材的 BNF 变体支持 ε 和左递归检测这个包里的文法不是写在配置文件里而是通过src/main/resources/grammar.txt定义。格式必须严格每行一条产生式左侧非终结符大写右侧用|分隔多个候选式ε 用ε表示终结符小写或加引号如。例如E → E T | T T → T * F | F F → ( E ) | id提示GrammarParser会做三件事① 检查所有左侧是否为非终结符首字母大写且不在终结符列表中② 扫描右侧是否含未声明的符号报UnknownSymbolException③ 对每个非终结符调用hasLeftRecursion()方法若发现直接左递归如A → A α会抛出LeftRecursionException并提示“请先消除左递归”。这不是摆设——我见过太多学生因忽略这点在FIRST计算时陷入无限递归导致栈溢出。2.2 FIRST/FOLLOW 集计算递归下降迭代收敛比教材伪代码更贴近真实工程逻辑FirstFollowCalculator类没用教科书里那种“反复扫描直到不变”的朴素算法而是采用带标记的深度优先遍历 迭代收敛。核心逻辑在computeFirst()方法public MapString, SetString computeFirst() { MapString, SetString first new HashMap(); // 初始化终结符 first 就是自己ε 的 first 是 {ε} for (String terminal : terminals) { first.put(terminal, Collections.singleton(terminal)); } first.put(ε, Collections.singleton(ε)); boolean changed; do { changed false; for (String nonTerminal : nonTerminals) { SetString newFirst new HashSet(first.getOrDefault(nonTerminal, Collections.emptySet())); for (ListString rhs : grammar.get(nonTerminal)) { // 处理 rhs: X1 X2 ... Xk for (int i 0; i rhs.size(); i) { String symbol rhs.get(i); if (terminals.contains(symbol)) { // 遇到终结符加入并跳出 if (newFirst.add(symbol)) changed true; break; } else if (nonTerminals.contains(symbol)) { // 非终结符加入其 first不含 ε SetString symbolFirst first.getOrDefault(symbol, Collections.emptySet()); for (String s : symbolFirst) { if (!ε.equals(s) newFirst.add(s)) changed true; } // 若 symbol 的 first 含 ε继续下一个符号 if (!symbolFirst.contains(ε)) break; // 若是最后一个符号且含 ε则加入 ε if (i rhs.size() - 1 symbolFirst.contains(ε)) { if (newFirst.add(ε)) changed true; } } else { throw new RuntimeException(Unknown symbol: symbol); } } } first.put(nonTerminal, newFirst); } } while (changed); return first; }这段代码的关键参数说明rhs是产生式右部符号列表如[E, , T]symbolFirst是当前符号的 FIRST 集缓存。它比教材算法多两处工程优化①提前终止一旦遇到终结符或不含 ε 的非终结符立刻 break避免无谓循环②ε 传播显式控制只在i rhs.size()-1时才将 ε 加入防止错误传播比如A → B C,B → ε,C → d则FIRST(A)应含d但不含 ε。computeFollow()同理用followSetChanged标记每次迭代是否更新确保收敛。2.3 SLR(1) 分析表构造用标准子集构造法生成 LR(0) 项集规范族再映射到 action/goto 表SLRTableBuilder的核心是buildItemSets()方法它实现标准的LR(0) 项集规范族构造教材算法 4.3public MapInteger, ItemSet buildItemSets() { MapInteger, ItemSet itemSets new HashMap(); // 步骤1构造拓广文法 S → S ListListString augmentedGrammar new ArrayList(grammar.values()); augmentedGrammar.add(0, Arrays.asList(S, S)); // 假设原文法起始符是 S // 步骤2计算初始项集 I0 closure({S → • S}) ItemSet initial new ItemSet(); initial.addItem(new Item(S, Arrays.asList(S), 0)); initial closure(initial, augmentedGrammar); QueueItemSet queue new LinkedList(); queue.offer(initial); int stateId 0; itemSets.put(stateId, initial); while (!queue.isEmpty()) { ItemSet current queue.poll(); // 对每个符号 X ∈ (Vt ∪ Vn)计算 goto(current, X) for (String symbol : allSymbols) { ItemSet next gotoFunc(current, symbol, augmentedGrammar); if (!next.getItems().isEmpty()) { // 检查 next 是否已存在 boolean exists false; for (ItemSet existing : itemSets.values()) { if (existing.equals(next)) { // 已存在记录转移 current.addGoto(symbol, existing.getStateId()); exists true; break; } } if (!exists) { next.setStateId(stateId); itemSets.put(stateId, next); queue.offer(next); current.addGoto(symbol, stateId - 1); } } } } return itemSets; }关键点说明closure()方法递归添加所有•在前的产生式如A → • B C则添加B → • αgotoFunc()移动圆点并再次闭包。最终itemSets是状态 ID 到项集的映射。buildActionTable()和buildGotoTable()则遍历每个状态I_i对每个终结符a若goto(I_i, a) I_j则action[i][a] s j若I_i含A → α •且a ∈ FOLLOW(A)则action[i][a] r kk 是产生式编号若I_i含S → S •则action[i][$] acc。这里FOLLOW(A)直接复用上一步计算结果不是重新算——这是避免重复计算、保证一致性的重要设计。3. 真实输入驱动分析如何用ParserDriver模拟 LR 分析机看清每一步 shift/reduce 的决策依据3.1 输入串预处理与符号标准化为什么id必须映射为id而不能是identifierParserDriver不接受原始字符串如id id而是要求 token 流。TokenScanner类负责将输入按空格分割并做终结符标准化public ListString tokenize(String input) { ListString tokens new ArrayList(); for (String raw : input.trim().split(\\s)) { if (raw.isEmpty()) continue; // 关键映射 → , id → id, ( → ( if (raw.equals() || raw.equals(-) || raw.equals(*) || raw.equals(/)) { tokens.add(raw.substring(1, 2)); // 去掉引号 } else if (raw.equals(() || raw.equals()) || raw.equals()) { tokens.add(raw.substring(1, 2)); } else if (raw.equals(id) || raw.equals(num)) { tokens.add(raw); // 保留 id/num 作为终结符名 } else { // 其他情况假设是终结符名本身如 a, b tokens.add(raw); } } tokens.add($); // 添加结束符 return tokens; }参数说明input是空格分隔的 token 序列如id id $raw是每个 token。它处理三类情况① 带单引号的运算符→② 括号(→(③ 预定义终结符名id,num。为什么必须这样因为action表的列索引是终结符名grammar.txt里定义的终结符是,*,id所以输入 token 名必须完全一致。若传入identifieraction[0][identifier]为 null直接 NPE。3.2 分析栈模拟StackString存符号StackInteger存状态双栈同步是理解 LR 的核心parse()方法的核心是双栈管理public ParseResult parse(ListString tokens) { StackInteger stateStack new Stack(); StackString symbolStack new Stack(); stateStack.push(0); // 初始状态 symbolStack.push($); // 初始符号 int pos 0; ListParseStep steps new ArrayList(); while (pos tokens.size()) { String lookahead tokens.get(pos); int currentState stateStack.peek(); String action actionTable.get(currentState).get(lookahead); if (action null) { return new ParseResult(false, Error at token lookahead in state currentState); } if (action.startsWith(s )) { // shift int nextState Integer.parseInt(action.substring(2)); stateStack.push(nextState); symbolStack.push(lookahead); steps.add(new ParseStep(shift, lookahead, nextState, new ArrayList(symbolStack), new ArrayList(stateStack))); pos; } else if (action.startsWith(r )) { // reduce int prodIndex Integer.parseInt(action.substring(2)); ListString rhs getRhsByIndex(prodIndex); // 获取产生式右部 int popCount rhs.size(); // 弹出 2*popCount 个元素状态符号 for (int i 0; i popCount; i) { symbolStack.pop(); stateStack.pop(); } String lhs getLhsByIndex(prodIndex); // 产生式左部 symbolStack.push(lhs); int gotoState gotoTable.get(stateStack.peek()).get(lhs); stateStack.push(gotoState); steps.add(new ParseStep(reduce, lhs → String.join( , rhs), gotoState, new ArrayList(symbolStack), new ArrayList(stateStack))); } else if (acc.equals(action)) { steps.add(new ParseStep(accept, , -1, new ArrayList(symbolStack), new ArrayList(stateStack))); return new ParseResult(true, steps); } else { return new ParseResult(false, Invalid action: action); } } return new ParseResult(false, Unexpected end of input); }逻辑说明stateStack存状态 ID整数symbolStack存符号字符串两者长度始终相等。shift时同步 push 状态和符号reduce时同步 poprhs.size()次因为每个符号对应一个状态goto查表用新符号lhs和栈顶状态stateStack.peek()。ParseStep记录每一步的类型、动作内容、新状态、当前双栈快照——这是调试冲突根源的唯一途径。3.3 输出结果解析ParseResult不只是 true/false而是带完整执行轨迹的调试日志运行Main.java后控制台输出不是简单Accepted而是结构化步骤Step 1: shift id → state 2 Stack: [$, id] States: [0, 2] Step 2: reduce F → id → state 3 Stack: [$, F] States: [0, 3] Step 3: reduce T → F → state 4 Stack: [$, T] States: [0, 4] ... Final: accept每个ParseStep包含① 动作类型shift/reduce/accept② 动作对象id或F → id③ 新状态 ID④ 符号栈和状态栈的深拷贝。这才是能 debug 的输出——当你遇到reduce/reduce conflict直接看哪一步action[i][a]同时有r 2和r 5再回溯FOLLOW集是否重叠当shift/reduce conflict看action[i][a]是s j还是r k再查FOLLOW和FIRST是否交集非空。4. 避坑指南SLR(1) 实现里最常翻车的五个边界问题血泪经验总结4.1 现象java.lang.NullPointerException在actionTable.get(currentState).get(lookahead)原因lookahead是id但actionTable的列只包含,*,(,),$没id。根源是grammar.txt里终结符没声明id或tokenize()没正确映射如把id当成identifier。解决检查grammar.txt第一行是否含id如Vt * ( ) id $确认tokenize()对id的处理分支生效断点调试raw.equals(id)是否为 true。4.2 现象reduce/reduce conflict报错但文法明显无冲突原因FOLLOW集计算错误导致两个不同产生式的FOLLOW交集非空。常见于FOLLOW(E)错误包含应只含),$因E → E T中是终结符不应进入FOLLOW(E)。解决在computeFollow()中打印每个非终结符的followSet验证FOLLOW(E)是否含。修正逻辑只有当A → α B β且β可推导 ε 时FOLLOW(A)才加入FOLLOW(B)若β非空如 T则FOLLOW(B)只加FIRST(β)即不加FOLLOW(A)。4.3 现象分析过程卡死while (pos tokens.size())无限循环原因action表某状态对lookahead返回null但代码没抛异常而是跳过pos导致pos不变。解决在parse()方法中if (action null)分支必须return new ParseResult(false, ...)不能只 log。我曾见学生注释掉这行结果输入id 缺id时死循环。4.4 现象goto表查不到状态gotoTable.get(stateStack.peek()).get(lhs)为 null原因lhs是S但gotoTable只建了Vn非终结符的列S是拓广符号未加入nonTerminals集合。解决在GrammarParser初始化时nonTerminals必须包含S。buildItemSets()中allSymbols要包含SgotoTable列名需同步更新。4.5 现象closure()递归栈溢出java.lang.StackOverflowError原因文法含左递归如A → A a | bclosure()在计算FIRST(A)时无限调用自身。解决GrammarParser的hasLeftRecursion()必须启用。若检测到左递归强制抛异常并提示“请先消除左递归”不能静默忽略。消除后重试。5. 进阶技巧如何用这个 SLR(1) 实现快速验证文法二义性、定位冲突根源、甚至生成可视化分析树5.1 冲突根因定位三步法锁定reduce/reduce的FOLLOW交集点当buildActionTable()报reduce/reduce conflict不要猜。打开SLRTableBuilder.java在buildActionTable()方法末尾加调试代码// 在双重循环内action[i][a] 赋值前插入 if (action ! null action.startsWith(r ) actionTable.get(i).containsKey(a) !actionTable.get(i).get(a).equals(action)) { System.err.println(RR Conflict in state i on a : actionTable.get(i).get(a) vs action); // 打印涉及的两个产生式 int idx1 Integer.parseInt(actionTable.get(i).get(a).substring(2)); int idx2 Integer.parseInt(action.substring(2)); System.err.println( Prod idx1 : getProduction(idx1)); System.err.println( Prod idx2 : getProduction(idx2)); // 打印 FOLLOW 集 String lhs1 getLhsByIndex(idx1); String lhs2 getLhsByIndex(idx2); System.err.println( FOLLOW( lhs1 ) followSet.get(lhs1)); System.err.println( FOLLOW( lhs2 ) followSet.get(lhs2)); }运行后输出直接告诉你state 5 on 冲突F → id和T → T * F的FOLLOW(F)和FOLLOW(T)都含。这时你只需检查FOLLOW(F)计算——F → ( E )中)后无符号FOLLOW(F)应含FOLLOW(E)而E → E T导致FOLLOW(E)含这就是冲突根源。修改文法如加括号优先级或换 LALR(1) 即可。5.2 生成分析树用ParseStep回溯构建 AST无需改核心代码ParseResult的steps列表已记录所有reduce动作。写一个ASTBuilder类遍历steps逆序处理public ASTNode buildAST(ParseResult result) { DequeASTNode nodeStack new ArrayDeque(); for (int i result.steps.size() - 1; i 0; i--) { ParseStep step result.steps.get(i); if (step.type.equals(reduce)) { // 解析 F → id 得 lhsF, rhs[id] String[] parts step.action.split( → ); String lhs parts[0].trim(); ListString rhs Arrays.asList(parts[1].trim().split(\\s)); // 构建节点lhs 为父rhs 为子从 nodeStack 弹出 len(rhs) 个 ASTNode parent new ASTNode(lhs); for (int j 0; j rhs.size(); j) { if (terminals.contains(rhs.get(j))) { parent.addChild(new ASTNode(rhs.get(j))); // 终结符叶子 } else { parent.addChild(nodeStack.pop()); // 非终结符子树 } } nodeStack.push(parent); } } return nodeStack.pop(); }参数说明nodeStack存子树根节点reduce时弹出rhs.size()个节点作为子节点。ASTNode只需String value和ListASTNode children。这样输入id id的输出 AST 就是(E (E (T (F (id))) () (T (F (id)))))可直接转 JSON 或 dot 图。5.3 文法二义性快速检验批量测试多输入串统计accept率写个GrammarValidator读取testcases.txt每行一个输入串统计输入串是否接受步骤数最大栈深冲突状态idtrue53-id idtrue125-id id * idtrue186-id * idfalse--state 2 on *若同一输入串在不同实现如你手算 vs 此程序结果不一致必有计算错误。我一般会强制跑 10 个边界 case空串、单终结符、左结合链id id id、右结合链若文法支持、含括号嵌套(id id) * id、错误输入id 。从那以后我每次改FIRST或FOLLOW逻辑都强制走一遍这 10 个 case 的自动化测试再对比手算表——少一次就可能答辩时被问住。希望帮到你。本文还有配套的精品资源点击获取