1. 项目概述:这不是语法分析的“选择题”,而是一场确定性的推演实验
“18 | 移进和规约:用LR算法推演一个实例”——这个标题里藏着编译原理中最硬核也最常被误解的一环。很多人一看到“LR”就自动联想到“难”“抽象”“考试重点”,但其实它根本不是数学证明,而是一套极其严谨、可重复、每一步都必须有唯一答案的状态驱动操作流程。我带过几届某高校编译课程设计,发现学生卡在LR上的核心问题从来不是理解不了文法,而是把“移进-规约”当成需要凭感觉判断的模糊操作,结果在构造分析表时反复出错,在手工推演时频频回退重来。这完全违背了LR的本质:它之所以被工业级编译器(比如GCC、Clang的前端)广泛采用,恰恰是因为它的确定性——给定当前状态和下一个输入符号,下一步动作(移进、规约、接受或报错)是唯一且可查表的。
标题中的“18”很可能是某教材或讲义的章节编号,暗示这是一个教学场景下的标准练习;“移进和规约”是LR分析器的两个原子动作,前者把输入符号压入栈,后者用产生式右部匹配栈顶并替换成左部非终结符;而“推演一个实例”则点明了本文的全部价值:不讲空泛理论,只带你手把手走完一个完整、无歧义、每一步都经得起追问的推演过程。你不需要事先会写Yacc/Bison,也不需要背下LR(0)、SLR(1)、LALR(1)的区别——只要你能看懂文法、认识终结符和非终结符,就能跟着这个实例,像调试一段代码一样,亲眼看到分析栈如何一步步“生长”又“折叠”,最终判定一个句子是否合法。这个能力,直接决定了你能否读懂现代语言工具链中那些看似神秘的语法错误定位、冲突报告,甚至为后续自定义领域专用语言(DSL)打下不可替代的实操基础。
2. 核心思路拆解:为什么必须从LR(0)项集族出发?
2.1 LR分析不是“猜”,而是“查表驱动的状态机”
很多初学者试图跳过构造过程,直接记忆“遇到a该移进,遇到b该规约”,这注定失败。LR分析器本质上是一个确定性有限状态自动机(DFA),它的每个状态对应一个“项目集(Item Set)”,也就是一组带有圆点的产生式,例如E → • E + T或T → T * • F。这个圆点的位置,精确标定了当前分析进度:圆点左边是已识别的部分,右边是待期待的符号。整个分析过程,就是让这个DFA在输入流上“行走”,每读一个符号,就根据当前状态和该符号,查分析表决定下一步是移进到新状态、还是用某个产生式进行规约、或是接受/报错。
提示:把分析栈想象成一个“历史快照”。栈里存的不是原始字符,而是DFA走过的状态编号(s0, s1, s2…)和已归约出的语法单位(E, T, F)。每次移进,是把新符号对应的下一个状态压栈;每次规约,是弹出若干个状态和符号,再根据产生式左部查GOTO表,压入一个新的状态。这个机制保证了分析路径的唯一性和可追溯性。
2.2 为什么选这个文法?——一个精心设计的教学实例
我们选用一个经典、简洁但足以暴露所有关键细节的文法,它来自《编译原理》龙书(第2版)第4章的范例,经过微调以突出教学重点:
E → E + T | T T → T * F | F F → ( E ) | id这个文法有三个层级:E(表达式)、T(项)、F(因子),覆盖了加法、乘法、括号和标识符四种基本结构。它足够简单,不会因产生式过多导致项集爆炸;又足够典型,能清晰展示移进-规约冲突(如E → E + • T遇到*时)和规约-规约冲突(在更复杂的文法中)的根源。更重要的是,它能让我们亲手构造出一张真正可用的SLR(1)分析表——这是介于理论纯粹性(LR(0))与工程实用性(LALR(1))之间的完美平衡点,也是绝大多数教学和轻量级解析器的实际起点。
2.3 为什么必须从LR(0)项集族开始?——构造是理解的唯一路径
有人问:“能不能直接用工具生成分析表?”可以,但那等于只学会了“按按钮”,没学会“修机器”。手工构造LR(0)项集族的过程,就是强制你去思考:
- 文法的每一个产生式,在分析的不同阶段,其“期待点”(圆点位置)究竟在哪里?
- 当前状态(项目集)看到一个终结符(如
+,*,id),应该转移到哪个新状态?这个转移是否唯一? - 当前状态看到一个非终结符(如
E,T,F),通过GOTO函数,应该进入哪个由该非终结符驱动的新状态?
这个过程本身,就是在大脑里模拟DFA的构建。当你亲手算出I0(初始项集)包含E' → • E和E → • E + T等7个项目,并通过CLOSURE和GOTO函数一步步推导出I1,I2…I12共13个状态时,你对“状态”和“转移”的理解,就从教科书上的二维表格,变成了脑海里一条条清晰的、有向的、可验证的路径。这种肌肉记忆,是任何自动化工具都无法替代的底层认知。
3. 核心细节解析:从初始项集到分析表的完整推演
3.1 构造LR(0)项集族:CLOSURE与GOTO的实操心法
我们从增广文法开始。为确保分析有明确的起始和结束,引入新的开始符号E'和产生式E' → E。初始项集I0的核心是E' → • E。CLOSURE操作就是“穷尽所有可能的后续期待”:因为E是非终结符,所以要把所有以E为左部的产生式(E → E + T和E → T)的“圆点在最左端”形式也加入I0;接着,E → T中的T又是非终结符,于是再加入T → T * F和T → F;同理,F → ( E )和F → id也被拉进来。最终I0包含以下7个项目:
E' → • E E → • E + T E → • T T → • T * F T → • F F → • ( E ) F → • id注意:
CLOSURE不是盲目添加,而是有严格规则的递归过程。每加入一个X → • Yβ,就要检查Y是否是非终结符;若是,则将所有Y → • γ加入。这个过程必须迭代直到没有新项目可加。我第一次手算时,在I0里漏掉了F → • id,导致后续GOTO(I0, id)计算错误,花了半小时才定位——这就是手工推演的价值:它强迫你暴露并修正每一个思维断点。
接下来是GOTO操作,它是状态转移的引擎。GOTO(I, X)的含义是:“如果当前在状态I,并且下一个要处理的符号(终结符或非终结符)是X,那么圆点将向右移动一位,所有形如A → α • X β的项目,都会变成A → α X • β,然后对这个新集合再做一次CLOSURE。” 例如,计算GOTO(I0, E):
- 在
I0中,只有E' → • E和E → • E + T的圆点后是E; - 移动圆点后得到
E' → E •和E → E • + T; - 对这个集合做
CLOSURE:E' → E •后面没有符号,无需扩展;E → E • + T后面是终结符+,也不需扩展(CLOSURE只处理非终结符); - 所以
I1 = { E' → E •, E → E • + T }。
这个I1就是著名的“接受状态”(因为E' → E •表示整个输入已被成功识别)。而GOTO(I0, T)会导出I2,GOTO(I0, F)导出I3,依此类推。整个项集族共13个状态,每个状态都是一个项目集合,它们共同构成了DFA的所有节点。
3.2 构造SLR(1)分析表:FOLLOW集是解决冲突的钥匙
LR(0)分析表只依赖状态和输入符号,但它会遇到大量冲突。例如,在状态I2(由E → T •和T → T • * F等组成)中,当输入符号是*时,T → T • * F要求移进(把*压栈并转到新状态);但同时E → T •又要求规约(用E → T归约栈顶的T)。这就是典型的“移进-规约冲突”。
SLR(1)的解决方案是引入向前看符号(Lookahead)。它规定:只有当输入符号属于某个产生式左部的FOLLOW集时,才允许用该产生式进行规约。FOLLOW(E)是所有可能跟在E后面的终结符集合。对于我们的文法:
FOLLOW(E') = { $ }($是输入结束符)FOLLOW(E) = { +, ), $ }(因为E出现在E → E + T的+前,F → ( E )的)前,以及作为整个句子的结尾$前)FOLLOW(T) = { +, *, ), $ }(T出现在E → E + T的+前,T → T * F的*前,F → ( E )的)前,以及$前)FOLLOW(F) = { +, *, ), $ }(同T)
现在回到I2的冲突:E → T •的规约动作,只有在输入符号 ∈FOLLOW(E) = { +, ), $ }时才允许。而*不在这个集合里,所以当输入是*时,只能选择移进,冲突解除。这就是SLR(1)分析表的构造逻辑:对每个状态Ii,
- 如果
GOTO(Ii, a) = Ij且a是终结符,则在ACTION[i, a]处填sj(移进到状态j); - 如果
Ii包含项目A → α •且a ∈ FOLLOW(A),则在ACTION[i, a]处填rk(用第k个产生式规约); - 如果
Ii包含E' → E •,则在ACTION[i, $]处填acc(接受); GOTO[i, A](A是非终结符)则直接填入GOTO(Ii, A)对应的状态编号。
这张表,就是LR分析器的“大脑”。它把复杂的语法决策,压缩成了一张二维查找表。你不需要理解文法语义,只需要机械地查表、执行、更新栈——这正是编译器能高速工作的底层秘密。
3.3 推演实例:分析句子id + id * id的每一步真相
现在,我们用刚刚构造好的SLR(1)分析表,来推演句子id + id * id的完整分析过程。分析栈初始为[0](状态0),输入缓冲区为id + id * id $。我们将逐行记录栈状态、输入、动作和语义动作(规约时的归并)。
| 步骤 | 栈(状态) | 输入 | 动作 | 说明 |
|---|---|---|---|---|
| 1 | 0 | id + id * id $ | s5 | 查表ACTION[0, id] = s5,移进,压入状态5。栈变为[0, 5]。 |
| 2 | 0 5 | + id * id $ | r6 | 状态5对应F → id •,FOLLOW(F) = {+, *, ), $},+∈ FOLLOW(F),故规约6(F → id)。弹出5,查GOTO[0, F] = 3,压入3。栈变为[0, 3]。 |
| 3 | 0 3 | + id * id $ | r4 | 状态3对应T → F •,FOLLOW(T) = {+, *, ), $},+∈ FOLLOW(T),规约4(T → F)。弹出3,查GOTO[0, T] = 2,压入2。栈变为[0, 2]。 |
| 4 | 0 2 | + id * id $ | s7 | ACTION[2, +] = s7,移进,压入7。栈变为[0, 2, 7]。 |
| 5 | 0 2 7 | id * id $ | s5 | ACTION[7, id] = s5,移进,压入5。栈变为[0, 2, 7, 5]。 |
| 6 | 0 2 7 5 | * id $ | r6 | 状态5,+∈ FOLLOW(F),规约6(F → id)。弹出5,查GOTO[7, F] = 10,压入10。栈变为[0, 2, 7, 10]。 |
| 7 | 0 2 7 10 | * id $ | s8 | ACTION[10, *] = s8,移进,压入8。栈变为[0, 2, 7, 10, 8]。 |
| 8 | 0 2 7 10 8 | id $ | s5 | ACTION[8, id] = s5,移进,压入5。栈变为[0, 2, 7, 10, 8, 5]。 |
| 9 | 0 2 7 10 8 5 | $ | r6 | 规约6(F → id)。弹出5,查GOTO[8, F] = 11,压入11。栈变为[0, 2, 7, 10, 8, 11]。 |
| 10 | 0 2 7 10 8 11 | $ | r3 | 状态11对应T → T * F •,FOLLOW(T) = {+, *, ), $},$∈ FOLLOW(T),规约3(T → T * F)。弹出11,8,10,查GOTO[7, T] = 9,压入9。栈变为[0, 2, 7, 9]。 |
| 11 | 0 2 7 9 | $ | r1 | 状态9对应E → E + T •,FOLLOW(E) = {+, ), $},$∈ FOLLOW(E),规约1(E → E + T)。弹出9,7,2,查GOTO[0, E] = 1,压入1。栈变为[0, 1]。 |
| 12 | 0 1 | $ | acc | ACTION[1, $] = acc,接受!分析成功。 |
实操心得:我在第一次推演时,在步骤6卡住了。当时误以为状态5看到
*应该规约,但忘了查FOLLOW(F)——*确实在里面,所以规约是对的。真正的问题出在步骤10:GOTO[7, T]我算成了GOTO(I7, T),但I7是由E → E + • T闭包而来,它本身不包含T的产生式,所以GOTO(I7, T)必须先找到I7中所有• T的项目,再做CLOSURE,最终得到I9。这个细节,只有亲手画过状态转换图才会刻骨铭心。
这个12步的推演,清晰地展示了“移进”如何将输入符号及其上下文逐步加载到栈中,“规约”如何将底层的词法单元(id)一层层向上抽象为语法结构(F→T→E),最终形成一棵完整的语法树。每一步的“动作”都不是猜测,而是对分析表的一次精准查询,背后是整个文法的拓扑结构和符号的传播关系。
4. 实操过程详解:从零开始构造你的第一张SLR(1)分析表
4.1 工具准备:纸、笔、和一个清醒的大脑
别急着打开电脑。构造LR分析表,是典型的“低带宽、高专注”任务。我建议你准备:
- 一张A3大小的白纸,横向使用,左侧留出1/3写状态编号和项目,右侧2/3画状态转换图;
- 三支不同颜色的笔:黑色写项目,红色标
CLOSURE的递归路径,蓝色标GOTO的转移箭头; - 一个计算器(不是用来算数,是用来按
FOLLOW集合的并集运算的)。
为什么不用软件?因为软件会隐藏中间过程。比如,一个工具可能直接告诉你I4 = { T → F •, F → • ( E ), F → • id },但它不会告诉你,这个I4是怎么从GOTO(I3, F)得来的,而I3又是怎么从GOTO(I0, F)得来的。手工推演的“痛苦”,恰恰是建立直觉的必经之路。
4.2 构造项集族的五步法:一个不能跳过的流水线
我总结了一个可复用的五步法,适用于任何上下文无关文法:
第一步:增广与初始化
- 添加
E' → E,并将E' → • E作为I0的种子。 - 写下所有原始产生式,编号以便后续规约引用(如
E → E + T为第1条,E → T为第2条,以此类推)。
第二步:CLOSURE递归展开
- 对当前集合中的每个项目
A → α • B β,若B是非终结符,则将所有B → • γ加入集合。 - 重复此过程,直到没有新项目加入。此时,
I0完成。
第三步:GOTO批量生成
- 对
I0中的每一个符号X(终结符或非终结符),计算GOTO(I0, X)。 - 方法:找出所有
A → α • X β,将圆点右移得A → α X • β,再对此集合做CLOSURE。 - 将每个新得到的项目集命名为
I1,I2… 并记录其来源(如I1 = GOTO(I0, E))。
第四步:状态扩张与去重
- 对每一个新生成的状态
Ii,重复第三步,计算GOTO(Ii, X)。 - 关键:如果
GOTO(Ii, X)的结果与已有的某个Ij完全相同(项目集合相等),则不再新建状态,直接记为GOTO(Ii, X) = j。 - 这个“去重”步骤,就是DFA最小化的雏形,它保证了状态数的有限性。
第五步:冲突标注与SLR升级
- 遍历所有状态
Ii,检查其中是否存在:- 一个项目
A → α •(规约项目)和一个项目B → β • a γ(移进项目),且a是终结符。
- 一个项目
- 若存在,计算
FOLLOW(A),检查a是否在其中。若不在,则标记为“SLR(1)冲突”,意味着此文法不适合SLR,需升级到LALR(1)。
这个五步法,我教过的学生中,最快的一位用了47分钟完成全部13个状态的构造。慢的花了三天,但第三天晚上,他突然指着I6说:“原来F → ( • E )的CLOSURE会把E → • E + T拉进来,所以GOTO(I6, E)才会指向I1!”——这种顿悟,是任何自动化工具都无法给予的。
4.3 分析表的填充:一张表,两种视角
最终的SLR(1)分析表是一个二维数组,行是状态编号(0到12),列是所有终结符(id,+,*,(,),$)和非终结符(E,T,F)。填充时,必须同时具备两种视角:
视角一:动作表(ACTION)——面向终结符的决策
- 这是分析器运行时真正查询的表。每一格的内容只能是
s(移进)、r(规约)、acc(接受)或空白(错误)。 - 填写规则严格:
s来自GOTO(Ii, a)(a终结符),r来自A → α •且a ∈ FOLLOW(A),acc来自E' → E •。
视角二:转移表(GOTO)——面向非终结符的跳转
- 这张表不参与运行时决策,只在规约后使用。当执行
r k(用第k个产生式规约)后,栈顶弹出|α|个符号,此时栈顶状态为i,产生式左部为A,就需要查GOTO[i, A],得到下一个状态并压栈。 - 它的存在,保证了规约不是终点,而是通往更高层语法结构的桥梁。
这两张表,共同构成了一个闭环:输入驱动移进,栈内容驱动规约,规约结果又驱动新的状态跳转。理解这个闭环,你就理解了整个编译前端的脉搏。
5. 常见问题与排查技巧实录:那些没人告诉你的坑
5.1 “CLOSURE算不对”——递归深度与符号类型的致命陷阱
问题现象:I0构造出来只有4个项目,远少于预期的7个;或者GOTO(I0, id)得到一个空集。
根本原因:混淆了终结符与非终结符的处理逻辑。CLOSURE只对圆点后的非终结符进行递归展开。id是终结符,F → • id中的id后面没有产生式可展开,所以它就是一个“叶子项目”,不会再引出新项目。而F → • ( E )中的(是终结符,同样不展开;但E是非终结符,所以必须把所有E → • ...加进来。
排查技巧:
- 在纸上,给每个符号标上类型:
id,+,*,(,)是终结符(T);E,T,F,E'是非终结符(N)。 CLOSURE操作时,只关注•后面是N的项目。看到• T,立刻去翻T的所有产生式;看到• id,停笔。
我的教训:有次我把F → • id错误地当作F → • I D(把id当成非终结符缩写),结果CLOSURE层层递归下去,算出了一个根本不存在的I100。花了两小时才意识到,id就是词法分析器输出的原子符号,它没有内部结构。
5.2 “GOTO结果重复”——状态等价性判断的魔鬼细节
问题现象:GOTO(I2, +)和GOTO(I5, +)算出来项目集合看起来一样,但你不确定是否真的等价。
判断标准:两个项目集I和J等价,当且仅当它们包含完全相同的项目,顺序无关。例如:
I = { E → E • + T, T → • T * F, T → • F } J = { T → • F, E → E • + T, T → • T * F }I和J是等价的,因为项目完全相同。
但注意一个经典陷阱:E → • E + T和E → • T是两个不同的项目,不能因为E和T都是非终结符就认为它们“类似”。项目是精确的字符串匹配。
排查技巧:
- 把每个项目集的所有项目,按产生式左部字母序排列,再按圆点位置排序。
- 用文本编辑器的“比较文件”功能(如果你用电子版),或者用手机拍两张照片,逐行比对。
实测经验:在构造本例的13个状态时,I4和I6的项目列表非常相似,都包含F → • ( E )和F → • id。但I4来自GOTO(I3, F),而I6来自GOTO(I5, (),它们的“父状态”不同,导致后续GOTO出来的子状态也不同。所以,即使当前项目集相同,只要来源不同,也必须视为独立状态——这是DFA构造的基本原则。
5.3 “分析表填错”——FOLLOW集计算的三大误区
误区一:FOLLOW(A) 只看 A 直接跟在谁后面错误做法:看到E → E + T,就认为FOLLOW(E)包含+;看到F → ( E ),就认为FOLLOW(E)包含)。 正确做法:FOLLOW(E)是所有可能出现在 E 右侧的终结符。在E → E + T中,E后面是+,所以+ ∈ FOLLOW(E);在F → ( E )中,E后面是),所以) ∈ FOLLOW(E);但更重要的是,在E → T中,E是整个产生式的左部,而T的FOLLOW(T)会传递给E。所以必须递归计算:FOLLOW(E)依赖于FOLLOW(T),而FOLLOW(T)又依赖于FOLLOW(F)。
误区二:忽略起始符号的特殊性FOLLOW(E')必须包含$(输入结束符),这是硬性规定,与文法无关。
误区三:对 ε 产生式的处理不当本例中没有ε产生式,但在更复杂的文法中(如A → B C | D,其中C → ε),计算FOLLOW(B)时,如果C可以推出空串,则FOLLOW(B)还要并上FOLLOW(A)。这个规则极易被遗忘。
快速验证法:写一个小程序(Python几行就行),输入所有产生式,自动计算FIRST和FOLLOW。但请记住,程序只是验证工具,真正的理解,永远来自你亲手在纸上画出的依赖箭头。
5.4 “推演卡住”——栈状态与输入符号的实时同步术
问题现象:推演到第7步,栈是[0,2,7,10,8],输入是id $,但查ACTION[8, id]却是空白,报错。
原因分析:一定是前面某步的GOTO查错了。ACTION[i, a]的值,取决于状态i和终结符a,而状态i是由前面所有规约和移进共同决定的。一个微小的GOTO错误,会导致后续所有状态编号错位。
同步术:
- 在推演表的“栈”列,永远只写状态编号,不写符号。例如,栈
[0,2,7,10,8]表示当前栈顶是状态8。 - 在“输入”列,永远保持光标在第一个未处理符号上。
id $表示id是当前待处理符号,$是下一个。 - 每次动作后,立即更新两列:移进则栈加一个状态,输入去掉第一个符号;规约则栈弹出
n个状态(n是产生式右部长度),输入不变,然后查GOTO[新栈顶, 左部]。
终极检查点:当推演完成时,栈必须是[0,1],输入必须是$,且ACTION[1, $] = acc。如果栈是[0,1, something]或输入不是$,说明规约次数不够或多了。
6. 从实例到工程:LR技术在真实世界中的延展
6.1 为什么现代编译器不用SLR(1),而用LALR(1)?
SLR(1)的FOLLOW集过于“宽泛”,它把所有可能跟在A后面的符号都纳入规约条件,导致一些本可以安全移进的情况被错误地标记为冲突。LALR(1)的精妙之处在于,它计算的不是全局的FOLLOW(A),而是每个具体项目A → α •在特定状态Ii下的“向前看符号集”。这个集合,只包含那些在从初始状态I0到达Ii的所有可能路径上,真正可能出现在A后面的符号。它比FOLLOW更精确,因此能容纳更多文法。
举个例子,在某个状态中,A → α •的向前看符号可能只有{ +, ) },而FOLLOW(A)却是{ +, ), $, * }。LALR(1)允许在*上移进,而SLR(1)会报冲突。这就是为什么Bison默认生成LALR(1)分析器——它在保持构造可行性的同时,极大地扩展了可分析文法的范围。
6.2 LR不是编译器的专利:配置解析与协议校验
LR的思想早已溢出编译领域。某公司开发的物联网设备配置系统,其配置文件语法如下:
config → section* section → [ name ] newline param* param → key = value newline他们没有用正则表达式去“硬匹配”,而是用一个轻量级的LALR(1)解析器(基于Rust的lalrpop库)来加载配置。好处是:
- 强校验:非法的
key = value(如缺少=)或嵌套错误(如[section1]后紧跟[section2]而没有换行),能在加载时立即报错,而不是运行时崩溃。 - 结构化:解析结果直接是一棵
Config结构体树,字段名和类型由语法定义,无需手动json::from_str后再做类型转换。 - 可扩展:新增一个
include "file.conf"指令,只需在文法中加一行param → include "string" newline,并实现对应的语义动作,整个系统就支持了配置继承。
这印证了一个事实:任何需要将线性文本精确映射为结构化数据的场景,LR都是一个值得认真考虑的底层方案。
6.3 学习LR的终极价值:重塑你对“规则”的认知
最后分享一个个人体会。在我第一次成功手推完id + id * id的12步之后,我盯着那张密密麻麻的分析表,突然意识到:所谓“语法规则”,从来不是写在纸上的几行文字,而是内化在分析表里的、一张巨大的、确定性的决策网络。+和*的优先级,不是靠人脑记忆的“乘法优先”,而是体现在I2状态中,*被允许移进而+被强制规约的那一个格子里;括号的结合性,也不是抽象的“改变结合顺序”,而是F → ( • E )项目在I4状态中,通过GOTO(I4, E)跳转到I5,再通过GOTO(I5, ))回到I6的那一条闭环路径上。
学习LR,最终学的不是一种算法,而是一种将模糊意图转化为精确机器指令的思维方式。它教会你,世界上大多数看似复杂的系统,其底层都运行着一张巨大而沉默的“状态-动作”表。而你的任务,就是读懂它,然后,亲手把它画出来。