news 2026/10/22 15:12:30

SLR(1)语法分析器Java实现:从LR(0)项目集到ACTION/GOTO表

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
SLR(1)语法分析器Java实现:从LR(0)项目集到ACTION/GOTO表

1. 项目整体思路与设计拆解

1.1 SLR(1)到底是什么,能解决什么问题

大部分人第一次接触编译原理时,最先被劝退的往往不是词法分析,而是语法分析里那一堆状态机、项目集、ACTION表和GOTO表。SLR(1)恰好就站在这个让人头疼的位置上——它比LL(1)强大,又比LR(1)简单,是理解自底向上分析的最佳切入点。

简单说,SLR(1)是一种自底向上的语法分析方法,核心思路是“移进-归约”:从左到右扫描输入的单词串,一边把符号压入栈中,一边根据文法规则判断当前栈顶的内容能不能归约成一个非终结符。整个过程由一个有限状态自动机驱动,这个自动机的每个状态对应一组LR(0)项目,而ACTION表和GOTO表则告诉分析器在某个状态下遇到某个终结符或非终结符时该做什么动作。

这个项目解决的问题很具体:给你一个文法,程序自动计算出First集和Follow集,构建出LR(0)项目集规范族,生成ACTION/GOTO分析表,最后用这张表去驱动一次完整的语法分析。做完这个实验,你基本就理解了编译原理课本中“自底向上分析”那一章的所有核心概念。

适合谁来学?两类人。一类是被编译原理课程逼到墙角的计算机专业学生,这个项目能帮你把抽象概念落地成可运行的代码;另一类是想深入理解编译器前端工作的开发者,哪怕你不做编译器,文法分析、状态机的思想在很多领域——比如配置文件解析、SQL语句解析——都有直接的应用价值。

1.2 为什么选择Java来实现SLR(1)

这个实验我们用的是Java,当时某高校的编译原理课程要求就是Java。其实后来我自己复盘,发现Java写这个项目有几个实实在在的好处:

第一个好处是集合框架足够强。SLR(1)的实现绕不开Set、Map、List这些数据结构,项目集规范族本质上就是一套“集合的集合”,状态转移又是典型的Map映射关系。Java的HashSet和HashMap在处理项目集去重、查找状态转移时非常顺手,加上equals和hashCode配合自定义类,能省掉大量手工去重代码。

第二个好处是面向对象建模清晰。用于表示文法规则的Production类、表示项目集状态的State类、表示分析表的ParseTable类,每个类各司其职,代码结构一眼就能看明白。调试的时候哪里出了问题,顺着对象关系一层层查下去非常方便。

第三个好处是IDE生态成熟。毕竟这个实验涉及大量的集合打印、状态栈和符号栈的跟踪调试,Eclipse或IDEA里的断点调试和表达式求值功能,能让你看着状态栈一步步变化,比对着日志猜测要高效得多。

当然,选型这件事不是银弹。用C++写也可以,但手动管理内存和指针会让实验的复杂度直接上升一个台阶;用Python写会更快,但课程要求Java的话就只能老实点。从我带过几个学弟学妹的经验来看,Java是平衡了“代码量”“调试体验”和“课程要求”三者的最优解。

1.3 整体架构:从文法输入到分析完成

整个SLR(1)分析器的架构,可以拆成一条清晰的数据流:

文法输入 -> First/Follow集计算 -> LR(0)项目集规范族构建 -> SLR(1)分析表生成 -> 驱动程序分析句子

第一步,文法从文件或控制台读入,需要做格式化处理,把默认的产生式规则存成标准结构。输入文法时有一个大坑:终结符与非终结符的判定规则必须提前定死,不然后面全靠后缀判断很容易搞混。

第二步,计算First集和Follow集。这一步是很多人的第一个痛点,因为涉及到不动点迭代——集合要一遍遍扩展直到不再变化。写完这个循环,你对“闭包”这两个字的理解会深刻很多。

第三步,构建LR(0)项目集规范族。核心是项目集的闭包运算和goto函数。闭包运算就是把空移进点后面的非终结符对应的所有产生式都加入当前项目集;goto则是找出某个项目集在某个符号输入下跳转到的目标状态。

第四步,根据项目集规范族生成分析表。对每个项目集,遍历所有项目,如果是移进项目就在ACTION表里填入“移进+Sj”,如果是归约项目就根据Follow集决定“归约+rk”,如果是接受项目就标记状态为“接受”。这里正是SLR(1)和LR(1)的分水岭——SLR(1)用Follow集来决定归约动作,LR(1)则用向前看符号来决定。

第五步,驱动分析。用一个状态栈和一个符号栈,读入词法分析器产出的Token流,根据分析表不断执行移进、归约、接受或报错。当分析器走到接受状态时,输入的句子就被证明符合文法;如果中途查不到表项或产生冲突,就说明句法有错。

2. 理论基础与核心算法

2.1 文法表示:数据结构决定了代码的上限

写SLR(1)分析器之前,第一件事就是把文法表示好。我见过不少同学在这个环节掉链子——文法定义胡乱丢进字符串数组里,后面前缀比对、求闭包的时候发现数据结构补不了天,只能推倒重来。

我用的方案是三个类联动。第一个是Production类,代表一条产生式,内部包括:产生式左部(一个非终结符String)、产生式右部(一个符号列表List)。第二个是Symbol类,保存符号内容的同时用布尔类型标记它是否为终结符。第三个是Grammar类,维护一个产生式列表、一个非终结符集合、一个终结符集合,全放在一个类里,后面到处要用而非到处重算。

举例来说,经典的表达式文法长这样:

E -> E + T E -> T T -> T * F T -> F F -> ( E ) F -> id

这里E、T、F是非终结符,+、*、(、)、id是终结符。代码里判断一个符号是终结符还是非终结符,简单粗暴的办法就是这个符号是否出现在Grammar维护的非终结符集合里。为什么强调这个?因为有人会用“大写字母=非终结符”的约定,但实际文法里一个非终结符可能有多个字符(比如Expr、Term),凭首字母判断纯属给自己埋雷。

文法读入的格式,我推荐用标准教材式写法:每行一个产生式,用->分隔左右部,右部各符号用空格隔开。不要省空格,不要用|合并同左部的多条产生式——虽然文法书里为了简洁会用E -> E + T | T,但你还需要手工为它拆分成两条。直接在输入层就拆开,免得在代码里再做一次分割逻辑。

2.2 First集与Follow集:朴素不动点算法的正确姿势

先搞清楚这两个集合是干什么用的,再动手写代码。

First集的定义是一个非终结符或终结符经过零次或多次推导后,能出现在串首的所有终结符的集合。直观理解:给你一个符号串X,First(X)就是“从X出发,兜兜转转可能走到的最前面的那个终结符”的集合。如果X能推导出空串,那么我们还要把空串标记也放进First集里。这里有一个容易漏掉的关键点:空串标记ε必须单独用一个特殊符号表示,不能用空字符串,否则各种表项判断时会出歧义。

Follow集的定义则更抽象一些:对非终结符A来说,Follow(A)是“在某些句型中紧跟在A后面的终结符”的集合。初始时把结束标记$(井号)加入开始符号的Follow集,然后反复扫描所有产生式,把右部中某个非终结符后面紧跟的First集元素加入它的Follow集,如果后面跟的非终结符能推出空串,还要继续往前找。

写计算代码时,最朴实的做法就是用固定点迭代:

boolean changed = true; while (changed) { changed = false; for (Production p : grammar.getProductions()) { // 尝试扩展某些非终结符的 First 集或 Follow 集 // 一旦集合 size 增大,置 changed = true } }

有人问了:为什么不快一点,按拓扑序一趟算完?因为文法可能存在左递归和间接左递归,拓扑序根本求不出来,只有这种“跑多轮直到稳定”的不动点迭代才是全场景通用解。虽然理论上慢了一点,但对教学实验级别的文法规模,毫秒级就能收敛,根本不用优化。

有个细节值得单独提醒:在处理A -> ε这种空产生式时,First集里要加入ε特殊符号,而Follow集计算时要注意不能把ε塞进Follow集。判定是否遇到空串时,用符号对象本身去比较是不是那个预先定义好的EPSILON对象,不要用字符串去比,否则“空串”和“没有任何符号”这两种状态混在一起,后面构建项目集时会试出一堆幽灵状态。

2.3 LR(0)项目集规范族的构建:闭包和goto的双人舞

LR(0)项目是这样一个东西:在一个产生式的右部某个位置插入一个圆点,表示“当前分析器已经看到了圆点左边的符号,期待继续看到圆点右边的符号”。比如产生式E -> E + T,可以生成四个项目:

E -> . E + T E -> E . + T E -> E + . T E -> E + T .

圆点在最右边的项目,意味着这个产生式右部已经完全匹配,可以进行归约——这就是归约项目。圆点后面是终结符的,称为移进项目;圆点后面是非终结符的,称为待约项目。

项目集的闭包运算规则是这样的:当前项目集里如果有一个项目是A -> α . B β,其中B是非终结符,那么B的所有产生式B -> . γ都要加入这个项目集。这个过程要一直重复,直到没有新项目能加入为止。这里的逻辑和First集一样也是不动点式的——每加入一个新项目,它可能又引入新的非终结符,又要继续扩展。

goto运算则回答一个问题:状态I在读到符号X之后,会跳到哪个状态?简单说,就是找出I中所有圆点后面紧跟着X的项目,把圆点往右移一格,得到新项目组,然后对这个新项目组再做闭包运算,得到的就是目标状态。

从初始状态开始——即增广文法的开始产生式对应的项目S' -> . S取闭包——不断对每个状态、每个文法符号执行goto,就可以用广度优先的方式生成整个状态机。这里需要强调一个程序设计时的坑:goto一个符号后可能跳到已经存在的状态,所以要维护一个“状态ID到项目集的映射”和一个“项目集内容到状态ID的反向映射”,用后者去重。如果你只判断项目集字符串是否相同,而不从符号对象层面判断,遇到格式相同但引用不同的项目时会去重失败,产生大量重复状态。

2.4 ACTION表与GOTO表:SLR(1)和LR(1)的分水岭

分析表是整个分析器的心脏。ACTION表的行是状态ID,列是终结符;GOTO表的行是状态ID,列是非终结符。填表规则其实很机械:

  • 如果项目集I中存在项目A -> α . a β,其中a是终结符,并且goto(I, a) = J,那么在ACTION表的(I, a)位置填shift J。
  • 如果项目集I中存在归约项目A -> α .,那么对于Follow(A)中的每个终结符a,在ACTION表的(I, a)位置填reduce A -> α,但前提是这个位置没有被填过其他动作。
  • 如果项目集I中有项目S' -> S .这个接受项目,那么在ACTION表的(I, $)位置填accept。

GOTO表填充很直接:如果goto(I, A) = J,那么gotoTable[I, A] = J。

SLR(1)和LR(1)的真正区别就在第二步。SLR(1)的做法是:只要当前状态能归约,就把Follow(A)里所有终结符对应的表格位置都填入归约动作。这个做法的优点是简单——Follow集是全局计算的,每个非终结符只有一个Follow集;缺点是过于武断——某个位置归约后、前面栈中状态可能根本不允许Follow(A)中的某个终结符跟在后面,这就是所谓的“移进-归约冲突”或“归约-归约冲突”的来源。

我在实验指导时经常用一个类比来帮助理解:LR(1)比SLR(1)多了一对可以“提前看”的眼睛,它能结合当前栈顶状态来判断某个向前看符号是否合法;而SLR(1)只能识别“这个非终结符后面跟的东西”,笼统地把路都铺上。这就是为什么某些文法SLR(1)有冲突而LR(1)没有。

3. 核心代码实现与关键环节解析

3.1 文法类与符号类:地基要打牢

写代码的第一个类,我建议从Symbol类开始,因为它是最小粒度的数据类型。我用的实现大概是这样的:

public class Symbol { private String name; private boolean terminal; private static final Symbol EPSILON = new Symbol("ε", true); public Symbol(String name, boolean terminal) { this.name = name; this.terminal = terminal; } // getters, equals, hashCode 省略 }

equals和hashCode必须重写,因为后面项目集、First集/Fol低集合里的Symbol都是对象,如果不重写这两个方法,两个内容相同但不同实例的符号对象会被Set判定为不同元素,去重逻辑全部失效。这是一个极其隐蔽的坑——同学们写的时候经常出现“同样的终结符居然被加了两次”的诡异bug,查了半天发现是对象比较的问题。

Grammar类负责存储产生式列表,并维护终结符集合与非终结符集合。读入文法时,我建议用一个二维结构:List> productions,外层是产生式索引,内层是符号列表。注意把每个产生式的右部符号列表用ArrayList保存,因为后面构建项目集时需要在任意位置插入圆点,用数组不方便。

Grammar类的关键方法有几个:getStartSymbol()返回开始符号(默认是第一条产生式的左部);getProductionsForNonterminal(String nt)返回某个非终结符的所有产生式;isTerminal(String name)和isNonterminal(String name)分别判断符号类型。还有一个容易忽略的点:增广文法。SLR(1)分析器的正式定义要求先对文法做增广,即新增开始符号S',产生式S' -> S。这么做的好处是:分析器唯一的接受项目就是S' -> S .,不会被干扰。在Grammer解析阶段直接把这个增广产生式放在最开头,后面一切都顺理成章。

3.2 First与Follow计算:集合运算的艺术

计算First集时,要用一个Map> first,key是非终结符,value是终结符集合。对每个非终结符的每条产生式依次处理:

for (Production p : grammar.getProductionsForNonterminal(nt)) { // 如果右部为空,加入EPSILON // 否则遍历右部符号 // 把每个符号的First集(如果是终结符,就是它自己)加入当前集合 // 一旦某个符号不能推出EPSILON,就停止继续向后取 }

这里的“符号的First集”有一点递归的味道:如果右部第一个符号是终结符a,那就直接把a加进来,结束;如果是非终结符B,就把B的First集整体拉进来,如果B的First集里包含EPSILON,说明B可能推导出空串,那么还需要继续看右部下一个符号的First集。

如果整个右部所有符号的First集都包含EPSILON,那么当前非终结符的First集也要加入EPSILON。

Follow集计算更麻烦一点,算法是全局扫所有产生式。对每条产生式右部,从后往前遍历更有优势:因为“后面的能推出空串”这个信息会向前传递。如果你从前往后遍历,不仅逻辑绕,还容易漏掉连续多个非终结符都推出空串的情况。

for (int i = 0; i < rhs.size() - 1; i++) { Symbol B = rhs.get(i); if (!isTerminal(B)) { // 把 rhs.get(i+1) 的 First 集(不含 EPSILON)加入 Follow(B) Symbol next = rhs.get(i + 1); followTable.get(B).addAll(firstTable.get(next)); followTable.get(B).remove(EPSILON); // 如果 next 能推出 EPSILON,还要继续看 i+2 位置 } }

最后一个符号需要特殊处理:如果产生式形如A -> α B,那么Follow(B)中必须加入Follow(A)。这个处理不能在单次遍历里简单完成,因为Follow(A)本身可能还没算完,所以必须放在外层不动点循环里反复更新。这也是我前面强调“迭代到收敛”的原因。

3.3 项目集状态类的设计与闭包实现

项目集状态是整个状态机的节点,每个状态包含一个项目Set。项目类ProductionItem需要保存:产生式对象reference、圆点位置dotIndex、闭包过程中附加的向前看信息(SLR(1)里通常不需要,但如果你后续想扩展成LR(1),这个字段可以留好注释位置)。

LR(0)项目集的闭包运算代码最核心的部分如下:

public Set closure(Set items) { Set result = new LinkedHashSet<>(items); boolean changed = true; while (changed) { changed = false; for (ProductionItem item : result) { Symbol next = item.getSymbolAfterDot(); if (next != null && nonterminals.contains(next)) { for (Production p : grammar.getProductionsFor(next)) { ProductionItem newItem = new ProductionItem(p, 0); if (result.add(newItem)) { changed = true; } } } } } return result; }

注意这里用LinkedHashSet而不是HashSet——LinkedHashSet保证迭代顺序稳定,输出状态表时能看到项目顺序一致,调试心情会好很多。另外项目的哈希值需要根据产生式索引和圆点位置联合生成,这样可以快速判断两个项目是否等价。

goto函数实现就是先收集所有圆点后符号等于X的项目,把圆点后移一位,再对收集结果做闭包。

public State getGoto(State s, Symbol x) { Set nextItems = new HashSet<>(); for (ProductionItem item : s.getItems()) { Symbol next = item.getSymbolAfterDot(); if (next != null && next.equals(x)) { nextItems.add(new ProductionItem(item.getProduction(), item.getDotIndex() + 1)); } } if (nextItems.isEmpty()) return null; return new State(closure(nextItems)); }

构建整个状态机时,用队列做BFS:初始状态是closure({S' -> . S}),出队时遍历文法所有符号,对每个符号计算goto,若目标状态是全新的,就分配新的状态编号并加入队列;若是已有的,就把这条转移记录下来。这个过程中去重逻辑的要领是:用项目集的规范化字符串做key,或用一个哈希函数基于排序后的项目列表生成唯一ID。我推荐后者——把项目排个序然后合并成字符串,用字符串作map的key,虽然牺牲了一点性能但调试时直接看字符串就知道状态内容,非常直观。

3.4 ACTION与GOTO表的生成:冲突检测必须做

分析表的实现我选择直接用二维数组,行数等于状态数,列数等于终结符数量非终结符数量之和。表格中的每个单元格存放字符串动作,比如"s3"表示shift到状态3,"r2"表示按第2号产生式规约,"acc"表示接受。

填表前需要先定义好“符号到列索引”的映射。我建议终结符和非终结符分开两个映射,ACTION表用终结符映射,GOTO表用非终结符映射。不要混在一起,否则代码可读性会下降。

填表时,冲突检测一定要写。核心逻辑是这样的:

// 移进项目:ACTION[state][a] = shift gotoState // 归约项目:for a in Follow(A): ACTION[state][a] = reduce // 如果 ACTION[state][a] 已经有值: // 如果与当前要填入的动作不同,报告冲突

SLR(1)分析表的冲突检测有两个常见类型:移进-归约冲突和归约-归约冲突。当你检测到冲突时,不要忽略——这恰恰是理解“为什么这个文法不是SLR(1)”的好机会。打印出冲突所在的状态、冲突项目的完整内容、冲突涉及的终结符,这个信息直接帮你判断该对文法做什么改造。

这里有一个非常关键的现实建议:不要把ACTION表做成String数组后,在驱动分析时反复split字符串来提取状态号——那是灾难。建议直接用两个独立的整数二维数组:actionKindTable(存0=错误, 1=移进, 2=归约, 3=接受)和actionValueTable(存状态号或产生式索引),GOTO也单独一个整数二维数组。这种设计不仅驱动代码写得清爽,运行效率也高不少。

3.5 驱动分析:栈操作的艺术

有了分析表,驱动代码其实非常短。维护一个状态栈和符号栈,初始时状态栈压入0,符号栈压入$。分析循环如下:

while (true) { int state = stateStack.peek(); Symbol token = nextSymbol(); int actionKind = actionKindTable[state][symbolIndex(token)]; if (actionKind == SHIFT) { int nextState = actionValueTable[state][symbolIndex(token)]; stateStack.push(nextState); symbolStack.push(token); token = nextSymbol(); // 前进一个token } else if (actionKind == REDUCE) { int prodIndex = actionValueTable[state][symbolIndex(token)]; Production p = productions.get(prodIndex); // 弹出 右部长度 个状态和符号 for (int i = 0; i < p.getRhs().size(); i++) { stateStack.pop(); symbolStack.pop(); } symbolStack.push(p.getLhs()); int prevState = stateStack.peek(); int nextState = gotoTables[prevState][symbolIndex(p.getLhs())]; stateStack.push(nextState); } else if (actionKind == ACCEPT) { // 分析成功 } else { // 语法错误 } }

驱动这块我真的建议加详细日志:每次移进、归约都在控制台打印当前状态栈内容、符号栈内容、剩余输入串。很多同学说“我的分析器结果不对但调试无从下手”,其实加上三个栈的打印,出错位置一目了然。我甚至见过有人用日志才发现自己的Follow集少算了一个终结符的——这种bug不打印栈根本看不出来。

4. 调试实战与常见问题排查

4.1 经典问题一:First零点五集、Follow集算不齐全

症状:分析某句话时报“表格项为空”的错误,查了半天发现某个非终结符的Follow集竟然没包含结束标记$。

排查思路:先打印每个非终结符的First集和Follow集,对照教材上的标准答案人工核验。如果发现Follow集缺东西,多半是漏了不动点迭代——尤其是间接左递归的文法,Follow集的传播可能需要多轮才能完成。你会不会在遍历产生式时只循环了一次?一定要用while循环跑到集合不再变大为止。

还有一种隐蔽错误:产生式的右部出现了未定义的符号,比如文法里写了id但在终结符集合里没正确登记。这会导致First集计算时,遇到未知符号直接报错。排查时检查文法定义,确保所有终结符都在终结符集合里声明过。我建议在读取文法时就做一次符号合法性校验——凡是出现在产生式右部、又不在任何产生式左部的符号,一律自动归入终结符集合,这样能减少人为遗漏。

4.2 经典问题二:项目集闭包不完整,状态机缺状态

症状:生成的ACTION表行列很多,但分析时总会走到一个查不到任何动作的死胡同;或者状态数量远少于预期。

排查思路:先检查闭包运算是否用了不动点循环。有些人写闭包时只对初始项目集闭包了一次就结束了,没有对新增项目继续进行闭包——这是最常见的原因。其次是goto运算时对目标项目集是否先行做了闭包——goto的定义中,移点之后必须先闭包,否则新状态里该有的产生式不全,状态机就从根上缺了。

同时检查去重逻辑。如果同一个项目集内容生成了两个不同状态ID,状态机就会矛盾——因为同一批项目内容出现了两份状态,转移关系混乱。用我前面说的“项目集内容排序拼接成字符串做key”的方法,能有效消除这个问题。

4.3 经典问题三:移进-归约冲突出现在SLR(1)文法不该出现的地方

症状:程序报告某个状态存在移进-归约冲突,但你使用的文法在教材上明明就是SLR(1)。

排查思路:直接打印冲突状态的所有项目。比如:

状态 5: E -> T . T -> T . * F

这个状态如果遇到*符号,既想归约成E(因为*在Follow(E)里吗?不在的话就不会归约),又想移进*,冲突与否取决于Follow(E)是否含*。如果Follow(E)算错了,把*错误地加了进去,就会制造幽灵冲突。所以这种场景先复查Follow集,再考虑文法是否真有问题。

另一种常见冲突来自文法本身的二义性,这就不是SLR(1)的锅了。比如算术表达式文法的二义性版本——不消除左递归,直接写E -> E + E | E * E | (E) | id——这种文法在构建SLR(1)表时必定出现移进-归约冲突,因为加法、乘法的优先级没有被文法结构固定下来。遇到这种情况,解决方案是先消除二义性,建立明确的优先级层次结构。

4.4 调试心得与实用工具推荐

调试这个项目,我有几个用了很多次的实在办法。

第一个是人工模拟小文法。自己拿一个最简单的文法,比如只有两条规则的那种,手推状态机和分析表,然后和程序输出的结果逐一对比。看起来笨,但一旦对比通过,后面大的复杂文法再出错,范围就一下缩小了。

第二个是图形化展示状态机。很多同学不知道IDEA里可以装Graphviz插件,把状态和转移关系导出成.dot文件,渲染出来的状态转移图能直观地看这个自动机长什么样。比盯着控制台文本信息要直观得多。每生成一个状态,就把它的项目列表和转移边输出成dot格式,跑一次直接出图,对排查“状态断裂”“闭包漏项”简直太好用了。

第三个是准备一小批测试用例。不要光测合法的句子,要同时测不合法的句子。比如对表达式文法,既测id + id * id,也测id + * id。合法句子能否正确规约到接受状态固然重要,但更重要的是不合法的句子能否在某个状态被明确拒绝——如果连非法输入也莫名通过了,说明你的分析表有严重问题。我带着某学弟做实验时,他的分析器居然能“接受”id + * id这种明显错误的串,最后定位原因:ACTION表中错误位置的单元格没有初始化,默认值为0,而0恰好意味着“接受”,直接把逻辑搞崩了。所以初始化表时,一定要把错误标记默认值设成-1而不是0。

5. 从SLR(1)到LR(1):这个项目还能怎么扩展

做完了SLR(1),很多人会问的一个问题是:下一步干嘛?我的答案是——不要急着丢代码,SLR(1)是个特别好的跳板,往上扩展的路径非常清晰。

第一层扩展是改成LR(1)分析器。区别在于,LR(1)的项目要附带一个向前看符号集合,闭包运算时向前看符号要根据Beta的First集进行传播。代码层面你只需要修改项目类的定义,增加一个Lookahead集合字段,再改闭包和归约动作生成的逻辑,其他架构基本不用动。亲测大约增加两到三小时的工作量,做完之后你对“SLR(1)和LR(1)的真正区别”会有教科书给不了的认知。

第二层扩展是增加错误恢复机制。教科书级的LR分析器往往在遇到第一个错误时就终止了,但真实编译器(比如各种语言的解析器)需要在错误发生时跳过若干Token,继续分析后面的内容来给出多个错误提示。这个扩展可以锻炼你对“可行前缀”“合法后继”的理解,也是一个可以写在简历上的加分项。

第三层扩展是把分析表构建算法原封不动地搬到别的语言上。因为Java版的架构是基于集合映射的,换成JavaScript或Python也是分分钟的事。有个朋友就把它移植成了TypeScript版,后来在做配置文件的公式校验器时直接复用了一套类似的逻辑——这大概就是这个实验唯一的“现实价值”。

从学习角度来说,我真心建议你把这个项目深耕成型再往下走。编译原理的“龙书”有个很扎心的比喻:语法分析是编译器的心脏,而LR分析器是语法分析的心脏。把这一块吃透,后面无论是写解释器、搞代码格式化工具、还是做静态分析,你都会有比别人更扎实的地基。

最后分享一个清洁的小技巧:项目代码里,所有分析表构建逻辑和驱动逻辑尽量分开成独立的类——前者叫AnalyzerBuilder,后者叫AnalyzerTableDriver。这样当你用不同文法做测试时,直接换文法输入就行,驱动代码完全不用动。我实际用了一年多,发现这种分离带来的便利远超一开始多写的几行代码。希望这篇内容能帮你少踩一些坑,做完实验后你对编译器前端那点事,真的会胸有成竹。

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/10/22 15:12:27

AI如何辅助理论推导与公式排版?一套可复用的实战工作流

先说结论&#xff1a;这类工具现在确实能帮上大忙&#xff0c;但前提是你知道怎么把活拆给它们干。我写技术博客、出讲义、做汇报PPT&#xff0c;碰到理论推导和复杂公式&#xff0c;基本都是"AI先出草稿人工验证公式排版环境收尾"这套流程。对天天跟符号、积分、矩阵…

作者头像 李华
网站建设 2026/10/22 15:12:24

HaaS506 UART驱动开发实战:从API调用到串口调试全攻略

1. 为什么在HaaS506上要先搞定UART 做嵌入式开发的人都知道&#xff0c;串口是排查问题的一等功臣。不管是看系统启动日志、跟传感器模块通信&#xff0c;还是跟Wi-Fi模组、4G模组对接&#xff0c;UART永远是那个最基础、也最离不开的外设接口。如果你正在玩HaaS506开发板&…

作者头像 李华
网站建设 2026/10/22 15:11:16

Linux namespace内核机制:从系统调用到容器调试实战

这篇是系列第二讲&#xff0c;核心目标是把 Linux 内核的容器技术——尤其是 namespace 这一层——从“知道有哪些种类”推进到“理解底层机制、能自己动手操作”。如果你已经看过第一讲&#xff0c;知道 namespace 是内核用于隔离进程视图的机制&#xff0c;今天这第二篇可以帮…

作者头像 李华
网站建设 2026/10/22 15:10:16

HMM词性标注实战:从参数统计到维特比解码全流程解析

词性标注&#xff08;POS Tagging&#xff09;是序列标注问题里最经典的一个入口&#xff0c;而隐马尔科夫模型&#xff08;HMM&#xff09;又是解决这个入口最朴素、最容易跑通的方法。前阵子我在做一个文本预处理需求时&#xff0c;把 HMM 词性标注的完整流程——从语料统计、…

作者头像 李华
网站建设 2026/10/22 15:07:09

压缩感知信道估计MATLAB实战:OMP、LS与MMSE的MSE对比

简介&#xff1a;这份资源面向通信工程、信号处理方向的学习者与研究人员&#xff0c;聚焦利用压缩感知&#xff08;CS&#xff09;技术完成稀疏信道估计这一核心问题。在无线多径环境下&#xff0c;信道响应往往呈现稀疏特性&#xff0c;传统估计方法采样开销大&#xff0c;而…

作者头像 李华
网站建设 2026/10/22 15:06:54

Agent Infra:智能体基础设施的四层架构与工程实践

1. 这不是概念炒作&#xff0c;是基础设施层的静默重构“Agent Infra”这个词最近在技术圈里冒得有点急&#xff0c;但你翻遍主流文档、开源仓库和会议PPT&#xff0c;会发现它既没有RFC标准&#xff0c;也不在任何ISO目录里——它甚至不是某个大厂注册的商标。它是一群人在深夜…

作者头像 李华