ARTICLE DETAIL

资讯详情

深耕网站建设、视觉设计与SEO优化的一线实战洞察。

SLR(1)语法分析器Java实现:项目集、FOLLOW集与冲突定位

SLR(1)语法分析器Java实现:项目集、FOLLOW集与冲突定位

简介:这份压缩包面向编译原理课程实验,提供用Java实现的SLR(1)语法分析器,适合计算机专业学生和自学者对照课堂内容理解自底向上的语法分析过程,并完成实验参考与查漏补缺。代码围绕上下文无关文法处理展开,涵盖文法表示、消除左递归与左公因子、FOLLOW集计算、闭包与GOTO集构造、分析表生成以及移进归约解析等环节,可帮助读者把抽象概念落到具体实现,也能为课程设计中的文法预处理、状态机扩展和解析流程调试提供可借鉴思路。包内共85个文件,以76个Java源码为主体,另有6个XML配置、项目元数据文件和说明文本,压缩包约40KB,目录组织便于按模块查阅。已有524人学习,适合需要结合源码梳理SLR(1)分析器结构、理解分析表构造细节并排查实验问题的读者。

1. 别急着写代码,先弄清 SLR(1) 到底在算什么

很多人对 SLR(1) 语法分析器的第一印象是“表填对了就能跑”。真正做过编译原理实验的人都知道,卡人的不是敲不出那两三百行 Java,而是表填完了、demo 跑通了,换一个输入串立刻翻车,报错还指在一个八竿子打不着的位置。SLR(1) 的核心是一套严格的构造顺序:先有 LR(0) 项目集规范族,再有 FOLLOW 集,最后才轮得到 ACTION 和 GOTO 两张表。顺序错一步,后面全靠试。这份 Java 实现的 SLR(1) 语法分析器把整条链路摊开摆在那里,值得一层层拆开看。

2. 项目集规范族:LR(0) 自动机是算出来的,不是画出来的

教材上画自动机很轻松:画几个圈,标上I0、I1,箭头一连就完事。真写代码时你会发现,编译器并不知道该画几个圈,它必须从一条增广产生式出发,把所有可能的状态一个不漏地枚举出来,还要保证同一个状态不会被重复创建两次。这一节讲的就是这个枚举过程怎么落地。

2.1 产生式与项目怎么编码:字符串进不了哈希表做算术

文法一般从文本文件读入,形如E -> E + T | T。读进来第一件事是拆成一条条产生式,并在最前面补一条增广产生式S' -> E,编号为 0。这个编号 0 不是随便定的,后面判定 accept、以及闭包从哪个项目起步,都靠它做锚点。

拆完之后,一个“项目”就不是E -> E · + T这种带点的字符串了,而是一个二元组(产生式编号, 圆点位置)。用二元组的好处很实在:能算哈希、能比较、能排序、能当 Map 的 key,而字符串形态的项目在拼接和比较时又慢又容易因为空格不一致而对不上。归约动作也只需要存一个产生式编号,真正要还原右部长度时再回表去查。

// 一条产生式:左部非终结符 + 右部符号数组,编号即数组下标 static final class Production { final int id; final String lhs; final String[] rhs; // 长度可能为 0,表示 ε 产生式 Production(int id, String lhs, String[] rhs) { this.id = id; this.lhs = lhs; this.rhs = rhs; } int length() { return rhs.length; } } // 一个 LR(0) 项目:圆点落在右部的哪个位置 static final class Item { final int prod; // 产生式编号 final int dot; // 0 <= dot <= rhs.length Item(int prod, int dot) { this.prod = prod; this.dot = dot; } // 圆点右边的符号;dot 已到末尾时返回 null,这是"可归约"的判据 String nextSymbol(List<Production> prods) { String[] rhs = prods.get(prod).rhs; return dot < rhs.length ? rhs[dot] : null; } @Override public boolean equals(Object o) { if (!(o instanceof Item)) return false; Item x = (Item) o; return prod == x.prod && dot == x.dot; } @Override public int hashCode() { return prod * 131 + dot; } }

nextSymbol是整份代码里被调用次数最多的方法,它同时承担了三个判断:返回值是终结符就产生移进,是non终结符就触发闭包扩散,是null就产生归约。把这三个分支收敛到一个方法里,后面填表时的逻辑才不会散成一团。

equals和hashCode必须手写,而且必须只依赖prod和dot这两个字段。踩过一次坑:早期版本把Item定义成可变对象,圆点用++原地推进,结果塞进HashSet之后再修改,哈希值变了,contains永远返回 false,闭包直接不收敛。项目对象天生适合做成不可变的。

2.2 CLOSURE 的不动点:为什么要用工作表反复扩散

闭包的语义一句话能说完:如果项目里圆点右边是个非终结符B,那所有以B为左部的产生式,都应该以“圆点在开头”的形式加进来。麻烦在于这是会传染的——新加进来的项目圆点右边可能又是一个非终结符,得继续加,直到再也加不进去为止。

教材写法是“重复以下过程直到 I 不再增大”,实现里如果真写成每轮全量扫描一遍集合,复杂度会很难看。常规做法是用一个工作表(worklist):先把初始项目全部入队,每次弹出一个,看它需不需要扩散,需要就把新项目入队,同时用集合去重,保证同一个项目只处理一次。

// 求项目集闭包:从 kernel 出发,扩散到不动点 private Set<Item> closure(Collection<Item> kernel) { Deque<Item> work = new ArrayDeque<>(kernel); Set<Item> result = new LinkedHashSet<>(kernel); while (!work.isEmpty()) { Item it = work.poll(); String next = it.nextSymbol(prods); // 圆点已到末尾,或右边是终结符:不会引入新项目 if (next == null || isTerminal(next)) continue; for (int p : productionsOf(next)) { // 所有以 next 为左部的产生式编号 Item fresh = new Item(p, 0); // 圆点固定在右部最左端 if (result.add(fresh)) { // add 返回 true 才说明是全新的 work.push(fresh); // 只有新增的项目才需要继续扩散 } } } return result; }

这段代码能终止的根本原因是状态空间有限:全部可能的项目总数等于“所有产生式右部长度加一之和”,是个固定常数,集合只增不减,且每次增长都是往这个有限集合里塞一个没塞过的元素,所以最多扩散这么多轮。

result.add(fresh)的返回值是关键。很多人写成先if (!result.contains(fresh)) { result.add(fresh); work.push(fresh); },逻辑上没错但多了一次哈希查找。用LinkedHashSet而不是HashSet是为了让输出顺序稳定——调试打印项目集的时候,如果每次顺序都不一样,你根本没法用肉眼比对两次运行的结果差异。

productionsOf建议在解析文法时就建成Map<String, List<Integer>>,不要在闭包里线性扫描整个产生式表。状态数上百、每个状态闭包要扩散几十次的时候,这个索引的差别是肉眼可见的。

2.3 GOTO 与状态去重:key 不排序会造出一堆重复状态

有了闭包,GOTO(I, X)就简单了:把I里所有圆点右边正好是X的项目挑出来,圆点各自右移一格,组成核(kernel),再对这个核求闭包。真正容易翻车的是“状态去重”这一步——你需要判断一个新算出来的项目集是不是已经存在过。

判断相等不能靠Set.toString()。LinkedHashSet的toString依赖插入顺序,而插入顺序又依赖你遍历I的顺序、闭包扩散的顺序,两个内容完全相同、只是插入顺序不同的项目集,拼出来的字符串就是两个不同的 key。表现出来就是状态数莫名其妙翻倍,GOTO表里出现两行内容一模一样但编号不同的状态,还可能一个状态报了移进-归约冲突、另一个没报。

正确做法是先按(prod, dot)排序,再拼 key:

// 把一个项目集压成稳定字符串:先排序,再拼接 private String keyOf(Set<Item> state) { List<Item> sorted = new ArrayList<>(state); sorted.sort(Comparator.comparingInt((Item x) -> x.prod) .thenComparingInt(x -> x.dot)); StringBuilder sb = new StringBuilder(sorted.size() * 8); for (Item x : sorted) sb.append(x.prod).append('.').append(x.dot).append(';'); return sb.toString(); }

有了稳定 key,规范族的构造就是一个典型的“边遍历边增长”的循环:

List<Set<Item>> states = new ArrayList<>(); Map<String, Integer> index = new HashMap<>(); Set<Item> start = closure(List.of(new Item(0, 0))); // S' -> ·S states.add(start); index.put(keyOf(start), 0); // 注意 i < states.size():states 在循环体内会增长,这是刻意为之 for (int i = 0; i < states.size(); i++) { for (String sym : grammarSymbols) { // 固定顺序,保证状态编号可复现 Set<Item> target = goTo(states.get(i), sym); if (target.isEmpty()) continue; String k = keyOf(target); Integer id = index.get(k); if (id == null) { id = states.size(); states.add(target); index.put(k, id); } trans[i][symbolIndex(sym)] = id; // 终结符列 = 移进目标;非终结符列 = GOTO } }

循环条件写成i < states.size()而不是提前把size()存成局部变量,是这段代码的灵魂。新状态在循环体里被追加到尾部,索引i往后走的时候自然会碰到它们,整个算法就是一次广度优先遍历。如果把它写成int n = states.size(); for (int i = 0; i < n; i++),只会算出第一层状态,GOTO表大片空缺,然后你会以为是文法有问题。

grammarSymbols用固定顺序(比如先终结符后非终结符,各自按首次出现顺序)也很重要。状态编号是“首次被发现”的顺序,顺序一变编号全变,两张表就对不上了。调试时想复现上一次的状态编号,就得靠这个固定顺序。

如果文法规模大起来,keyOf里的字符串拼接会成为瓶颈,可以换成BitSet:项目总数是个常量,给每个项目分配一个全局下标,一个状态就是一个BitSet,相等比较和哈希都直接由BitSet提供,还能顺带省内存。实验规模的文法(十几个产生式)用不着上这套,但知道有这条升级路径,读别人代码时就不会觉得那些BitSet用得多此一举。

3. FOLLOW 集与归约决策:SLR(1) 的那“一瞥”到底看什么

LR(0) 自动机本身是“瞎”的:它只知道圆点走到了产生式末尾,该归约了,却不关心下一个输入符号是什么。SLR(1) 补上的就是这一眼——归约时看一眼向前看符号,只有这个符号落在左部非终结符的 FOLLOW 集里,才允许归约。整个 SLR(1) 的表达力上限,就压在这一条规则上。

3.1 FIRST 与 FOLLOW 的不动点:ε 是最容易漏的一环

FIRST 集相对直白:终结符的 FIRST 是它自己;非终结符的 FIRST 要沿着产生式右部从左往右看,遇到第一个不能推出 ε 的符号就停。FOLLOW 集则要绕一圈:$天生属于开始符号;对每条产生式A -> α B β,FIRST(β)里除 ε 外的符号都归B;如果β能推出 ε,那FOLLOW(A)也要归B。

第二条规则是重灾区。很多人写 FOLLOW 的时候只处理了“β 的第一个符号是终结符”的情况,β 为空或者 β 全是可空非终结符时就什么都不做,结果某些非终结符的 FOLLOW 集缺了元素,归约表里对应的格子是空的,一个完全合法的句子在运行时被判语法错误。

// 先算 nullable,再算 FOLLOW;这一步不能省 Set<String> nullable = new HashSet<>(); boolean changed = true; while (changed) { changed = false; for (Production p : prods) { boolean allNull = true; for (String s : p.rhs) { if (isTerminal(s) || !nullable.contains(s)) { allNull = false; break; } } if (allNull && nullable.add(p.lhs)) changed = true; // 左部也变成可空 } } // FOLLOW 传播,同样迭代到不动点 changed = true; while (changed) { changed = false; for (Production p : prods) { for (int i = 0; i < p.rhs.length; i++) { String b = p.rhs[i]; if (isTerminal(b)) continue; // 把 FIRST(β) - {ε} 并入 FOLLOW(b) int j = i + 1; while (j < p.rhs.length) { changed |= follow.get(b).addAll(firstOf(p.rhs[j], true)); if (!isNullable(p.rhs[j])) break; // 遇到不可空的符号就截断 j++; } // β 整体可空(或 β 为空),把 FOLLOW(左部) 并入 FOLLOW(b) if (j == p.rhs.length) changed |= follow.get(b).addAll(follow.get(p.lhs)); } } }

这段代码里firstOf(sym, includeEpsilon)用布尔参数区分“要不要把 ε 放进来”是必要的。FIRST 计算时需要 ε 来做终止判断,FOLLOW 传播时又必须把 ε 剔掉,混在一起写迟早出错。

还有一个隐蔽的初始化陷阱:follow这个 Map 必须在算 FOLLOW 之前,给每个非终结符都put一个空HashSet。如果只在用到时computeIfAbsent,某个从没出现在任何产生式右部的非终结符就永远没有条目,而addAll(follow.get(x))在x不存在时会直接抛 NPE,报错位置离真正的文法问题十万八千里。

3.2 ACTION 表的填充:冲突就是在某一格被写第二次时暴露的

填表的规则只有三条,但每条对应一种项目形态:

项目形态触发条件写入的动作
A -> α · a β圆点右边是终结符a在ACTION[state][a]写移进,目标状态是GOTO(state, a)
A -> α ·(A 不是 S')圆点已到末尾对FOLLOW(A)里每个符号,在ACTION[state][x]写归约,携带产生式编号
S' -> S ·增广产生式走到末尾在ACTION[state][$]写 accept

注意第二条是按 FOLLOW 集“扇出”的:一个可归约项目并不只对应一格,它对应|FOLLOW(A)|格。这也解释了为什么文法里多一个非终结符、多一条可空产生式,ACTION 表的非空格子会涨得很快。

冲突检测就发生在写入的时候:

// type: 1=移进, 2=归约, 3=accept;value: 目标状态 或 产生式编号 private void setAction(int state, String terminal, int type, int value, List<String> conflicts) { int col = termIndex(terminal); int old = actionType[state][col]; if (old == 0) { // 空格子,直接写 actionType[state][col] = type; actionValue[state][col] = value; return; } if (old == type && actionValue[state][col] == value) return; // 重复写入同一动作,正常 // 已经非空且动作不同:这就是冲突,记录下来而不是覆盖 conflicts.add(String.format("状态 %d 遇符号 %s:%s 与 %s 冲突", state, terminal, describe(old, actionValue[state][col]), describe(type, value))); }

这里最常见的误操作是“后写的覆盖先写的”。一旦允许覆盖,程序不会报任何错,表也是满的,只是行为变成任意的。文法本质上是二义的,或者只是不满足 SLR(1) 条件,但你看不到任何提示,只能靠输入串出错的诡异表现反推。把冲突收集起来而不是吞掉,是这份实现里最值得学的一个设计选择。

3.3 冲突定位:从一格冲突反推文法问题

冲突信息里至少要带三样东西:状态编号、终结符、双方的动作描述。缺一样都会让排查变成猜谜。

只知道“文法有移进-归约冲突”毫无意义,你手上有几十个状态。知道是状态 7、符号*、冲突双方是“移进到 12”和“按T -> T * F归约”,才有下手点:把状态 7 的项目集打印出来,一眼就能看到是哪个项目的圆点卡在*前面,哪个项目已经走到末尾。

常见的冲突成因有两个。一个是文法真有二义性,比如经典的悬空 else 问题,移进和归约都说得通,得靠优先级规则来裁决,而 SLR(1) 本身给不出答案。另一个是文法本身无二义,但 FOLLOW 集给得太宽——这正是 SLR(1) 比不上 LALR(1) 的地方,也是最后一章要展开的例子。

排除顺序上,先看是不是文法抄错了(多一行少一行、|分隔漏了一层),再看是不是 FOLLOW 算漏了,最后才怀疑文法本来就不是 SLR(1)。实战里前两种占大多数。

4. 表驱动执行循环:一个 while 加四个分支如何吃掉任意输入串

表和自动机都建好之后,剩下的部分反而最短:一个循环、一个栈、一个位置指针。短归短,理解不到位照样写出死循环和错位报错。

4.1 表结构选型:实验规模用二维数组,什么时候该换稀疏表

ACTION 表是“状态数 × 终结符数”,GOTO 表是“状态数 × 非终结符数”。表达式文法建出来大概十几个状态、七八个终结符,二维int[][]撑死一百多格,直接开数组最省事:查表是纯下标运算,不用装箱,不用算哈希。

用两个平行数组(actionType和actionValue)比用一个Action对象数组更实在。type用 0 表示错误格,既是“空”也是“出错”,省掉一次判空;value在移进时存目标状态,归约时存产生式编号,语义由type决定,不会混淆。

什么时候该换Map<Long, Action>?状态数上到几百、终结符几十个,乘法一算就是几万格,其中大多数是空的,这时候数组浪费的内存会超过 Map 的哈希开销。稀疏表还有额外好处:遍历非空格子做冲突统计更方便。实验作业不必上这套,但你在读任何真实编译器前端的表实现时,会看到大量“行压缩”“按列稀疏”的技巧,本质上就是在处理同一个权衡。

顺带一提value的正负号约定。有人喜欢用正数存移进、负数存归约、零存错误,省掉 type 数组;代价是 accept 和“归约到产生式 0”撞车,还得专门留个魔数。用两个数组,多四字节,少一堆特判。

4.2 双栈同步:状态栈、符号栈、位置指针必须三方一致

执行循环里维护三个东西:状态栈(存整数状态编号)、符号栈(存已经归约出来的语法树节点)、输入位置指针。三者必须同步推进,任何一方落后或超前,报出来的错误位置都是错的。

Deque<Integer> stateStack = new ArrayDeque<>(); Deque<Node> nodeStack = new ArrayDeque<>(); stateStack.push(0); // 初始状态 int pos = 0; while (true) { int s = stateStack.peek(); String look = (pos < tokens.size()) ? tokens.get(pos).text : "$"; int type = actionType[s][termIndex(look)]; int value = actionValue[s][termIndex(look)]; if (type == 1) { // 移进 stateStack.push(value); nodeStack.push(leaf(tokens.get(pos++))); // 位置指针只在这里前进 } else if (type == 2) { // 归约 Production p = prods.get(value); List<Node> kids = new ArrayList<>(p.length()); for (int k = 0; k < p.length(); k++) { stateStack.pop(); kids.add(nodeStack.pop()); // 弹出顺序是反的,后面要翻转 } Collections.reverse(kids); int top = stateStack.peek(); int next = gotoTable[top][nonTermIndex(p.lhs)]; if (next == 0) throw new IllegalStateException("GOTO 表缺格:" + p.lhs); stateStack.push(next); nodeStack.push(new Node(p.lhs, kids)); // 归约出新的非终结符节点 } else if (type == 3) { // accept return nodeStack.peek(); } else { throw new ParseException(pos, look, s); // 空表项 = 语法错误 } }

三个细节值得单独说。

第一,pos只在移进分支自增。归约是“把已经读进来的东西重新组织”,不消耗新输入。如果归约里也pos++,输入串会被吞掉一截,报错位置整体偏移。

第二,归约弹栈次数是右部长度p.length(),不是p.length() + 1。状态栈里除了右部各符号对应的状态,下面还压着一个“归约前”的状态,那个不能弹,归约完要用它去查 GOTO。

第三,归约完压入的是gotoTable[top][nonTermIndex(p.lhs)],用新的栈顶状态和左部非终结符查表。查出来是 0 说明 GOTO 表没填全,通常意味着规范族构造被提前截断了(回到 2.3 那个states.size()的坑),这时候抛异常比让程序带着错表继续跑要好得多。

4.3 语法树的构造时机:归约时建节点,子节点顺序要翻转

树是在归约分支里建的,不是在移进分支。原因很直接:移进的时候你还不知道这个 token 最终属于哪个父节点,圆点走完一个产生式,父子关系才确定下来。

kids的收集过程是“弹一个加一个”,先弹出来的是最右边的子节点,所以必须Collections.reverse。漏掉这一步的典型症状是:树能建出来,打印也没问题,但做后续语义分析时发现二元表达式的左右操作数反了,1 - 2算成2 - 1。这种错在小例子上看不出来,等做四则运算求值时才炸。

ε 产生式在这里有个特殊表现:p.length() == 0,循环一次都不进,状态栈不弹,kids为空,直接查gotoTable[top][p.lhs]并压栈。这本身是对的,但要小心它带来的一个副作用——如果 GOTO 查出来的状态和当前栈顶状态一样,而 ACTION 表里这个状态又允许同样的归约,循环就会一直做同一件事,永远不消耗输入。防这个的办法很土但有效:给while加一个步数上限,比如tokens.size() * 1000,超了就抛“疑似死循环,请检查 ε 产生式”。

树节点的构造建议用不可变对象,父节点引用在归约时一次性给定。想加回溯或者错误恢复的时候,不可变树能省掉一大堆“改坏了原始树”的调试噩梦。

5. 实现踩坑记录:SLR(1) 分析器最容易翻车的五处

下面这五条都是“程序在跑、没报异常、结果就是不对”的类型。它们的共同点是:错误不在出问题的那一行,而在更早的某个静默失败处。

5.1 文法读入阶段的两个静默失败

坑一:非终结符没有产生式,闭包静默跳过。

现象:输入串里出现一个文法中存在的符号,程序却在某个位置报“语法错误”,而且报错位置比真正的问题位置靠后很多。

原因:闭包方法里productionsOf(next)返回空列表,for循环一次都不进,不会报错也不会警告,只是这个非终结符永远扩散不出任何项目。于是它在任何状态下都没有可移进的边,遇到它必然落进 ACTION 的空格子。

解决:解析完文法后做一次完整性校验——收集所有出现在右部的非终结符,逐个检查是否有对应产生式,缺的立刻打印出来并终止。这是一行代码换半小时排查的买卖。

坑二:多字符终结符被拆成单字符。

现象:文法里写的是id,输入x被判错,或者输入id被识别成两个 token。

原因:分词阶段用charAt逐字符扫描,id自然被拆成i和d;或者反过来,输入串按空格切分而文法里的终结符是单字符,两边词法粒度不一致。

解决:词法先于语法,且必须和文法用同一张终结符表。识别时按“最长匹配”原则,先尝试长度最长的候选;实在要做简化,就在读文法时明确约定所有终结符都是单字符,并在文档和代码注释里写死这个前提,别让它变成隐性约定。

5.2 表构造阶段的状态爆炸与冲突误判

坑三:状态 key 不排序,同一个状态被建了两次。

现象:状态数比手工推导多出好几倍,GOTO 表里有多行内容完全一样;更诡异的是同一个文法两次运行,冲突数量不一样。

原因:用Set.toString()当 key,而LinkedHashSet的字符串形态依赖插入顺序,闭包扩散顺序稍有变化就得到不同 key。

解决:按(prod, dot)排序后再拼 key,或者用BitSet做全局项目下标的位图。排序这一步不能省,它是状态去重正确性的前提,不是优化。

坑四:FOLLOW 漏了“β 可空”分支,归约表大片空缺。

现象:大部分输入能正常解析,某些特定后缀的句子被拒;错误位置恰好落在某个非终结符该出现的地方。

原因:A -> α B β中 β 整体可空时,要把FOLLOW(A)并入FOLLOW(B)。代码只处理了 β 以终结符开头的情况,可空分支被跳过,FOLLOW(B)缺元素,B -> ... ·的归约格没被填上。

解决:先独立算 nullable 集合,再算 FOLLOW;把“β 可空”写成显式分支,别用if (i + 1 < rhs.length)这种看着省事其实漏一半的写法。

5.3 运行阶段的死循环与错位报错

坑五:ε 产生式归约导致死循环。

现象:输入x之后程序卡住不动,或者跑一会儿栈溢出;日志里同一个状态同一个产生式刷了几千遍。

原因:归约A -> ε时右部长度为 0,状态栈不弹,查 GOTO 得到的可能是当前同一个状态,而该状态的 ACTION 在同一个向前看符号上仍然是这条归约,于是无限循环。

解决:归约长度为 0 时只压栈不弹栈(这是正确行为),同时给主循环加步数上限,超限时报出当前状态、当前符号和最近若干次动作,让人一眼看出是在重复。加个上限远比事后对着卡死的进程抓栈要轻松。

附带一条观察:错误信息里必须带位置指针。只报“语法错误”而不报“第几个 token、这个 token 是什么、当前状态编号是多少”,等于把排查成本全部转嫁给读日志的人。ParseException(pos, look, s)这三个参数是最低配置,能带上一份期望符号集合更好。

6. SLR(1) 的边界:为什么它注定干不过 LALR(1),实验却还要做它

要真正理解 SLR(1),得看一个它能建出冲突、而 LALR(1) 不会的例子。文法长这样:

S -> L = R | R L -> * R | id R -> L

在 LR(0) 项目集里,GOTO(I0, L)得到的那个状态包含两个项目:S -> L · = R和R -> L ·。前者要求看到=就移进,后者要求看到跟随符号就归约。SLR(1) 判断“跟随符号”的依据是整个文法里R的 FOLLOW 集,而由于S -> L = R的存在,=确实在FOLLOW(R)里。两个动作撞在同一格,移进-归约冲突,SLR(1) 直接宣布这个文法不是 SLR(1) 文法。

问题出在哪?FOLLOW(R)是个全局量,它把文法中所有位置对R的期待混在了一起。而在这一格所代表的具体上下文里,R -> L归约之后紧接着要跟的符号,其实只可能是$——因为这条路径是从S -> R过来的,跟=一点关系都没有。LALR(1) 的做法是给每个项目带上只在“这个状态下”有效的向前看集合,把FOLLOW的全局信息收窄成局部信息,冲突自然消失。

6.1 用冲突计数当测试基准

理解了这一点,就有了一个不用肉眼比对状态图的验证手段:准备三个文法。

第一个是表达式文法,公认的 SLR(1) 文法,构造结果必须是 0 冲突,状态数应该和手工推导一致(十来个)。第二个是上面那个L = R文法,必须精确报出 1 个移进-归约冲突,且位置在GOTO(I0, L)对应的状态、符号是=。第三个是带悬空 else 的文法,必须报移进-归约冲突。

三个都对上,说明闭包、GOTO、FOLLOW、填表四段逻辑基本没错;只要有一个数量对不上,问题一定在表构造阶段而不在执行阶段,范围立刻缩小。这个思路比“多试几个输入串看看对不对”靠谱得多,因为输入串只能验证“接受的语言对不对”,验证不了“这个文法被判定成了什么类型”。

6.2 设计取舍:为什么教学实验选 SLR(1),工业实现选 LALR(1)

SLR(1) 的价值不在于它够强,而在于它把 LR 分析的骨架完整暴露了出来,且没有引入额外的复杂度。LALR(1) 要在 LR(0) 项目集上再叠一层向前看符号的传播算法,状态数一样,实现却要多出“传播”和“自发生成”两个概念;规范 LR(1) 更直接,状态数直接翻几倍,表的大小立刻不可控。SLR(1) 处在那个刚刚好的位置:概念完整、实现可控、失败可解释。

真到了工程场景,判断标准就反过来了。表的大小、构建时间、错误恢复能力,每一样都比“实现简单”重要,LALR(1) 是主流选择,某些场景下干脆放弃表驱动改用递归下降手写分析器。而 SLR(1) 留给我们的遗产是那条构造链路——闭包、GOTO、规范族、FOLLOW、表填充、栈式驱动。这条链路在 LALR(1) 里一步都没少,只是每步多了一点细节。

我自己的习惯是:拿到任何一份表驱动分析器的代码,先不看执行循环,直接去找“冲突是怎么被处理的”。如果冲突被静默覆盖,这份代码的正确性就没法保证,后面读多少都是在给一个不可靠的东西做注脚;如果冲突被收集、被报告、甚至被分类,那作者大概率是真的理解了自己在建什么。从那以后我每写一个分析器,都强制先把“冲突计数”打出来看一遍,0 也好、3 也好,得先知道自己在什么位置上。这份 Java 实现里那套把冲突记录成可读字符串的做法,值得当作模板留着。

希望帮到你。

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

返回列表