1. 预测分析器整体设计与思路拆解
预测分析器是编译原理语法分析阶段最经典的实验之一。如果你已经完成了词法分析器的实验,接着做预测分析器,刚好能把整个“从字符流到语法树”的链条走通一多半。这个实验的核心任务,就是实现一个基于 LL(1) 文法的自顶向下语法分析程序:读入一个词法分析器产出的 Token 序列,按照文法规则逐步推导,判断这个输入串是否符合文法定义,同时输出分析过程。
为什么说这个实验“绕不开”?因为语法分析的策略基本分两大门派:自顶向下和自底向上。自顶向下里最典型的可手写实现的就是递归下降分析,而预测分析器就是递归下降的“表驱动版本”。它不靠递归函数层层调用,而是靠一张二维分析表加上一个显式的分析栈,循环往复地推动分析过程。这种设计在理论上非常优雅,在工程上也很好调试——你不需要在一堆递归调用里茫然地打断点,只要盯着分析栈的变化,就能看清楚每一步做了什么推导。
拿 LL(1) 这个名字做个通俗解释:第一个 L 表示从左向右扫描输入串,第二个 L 表示产生最左推导,括号里的 1 表示每次分析只需要向前看一个输入符号就能确定选择哪条产生式。这个“只看一个符号”的约束,就是预测分析器能高效工作的前提,也是它对文法有严格要求的根源。凡是违背这个约束的文法——比如含有左递归、含有公共前缀因子、或者 FIRST 集和 FOLLOW 集发生冲突的文法——都会导致分析表出现多义项或无法构造的情况,这恰恰是实验里最容易踩坑的地方。
这个实验放在“编译原理实验”系列里,一般来说已经是第二个或第三个实验了。它的前置要求很明确:必须已经掌握文法的基本概念、终结符和非终结符的区分、以及上下文无关文法的表示方式。编程方面并不需要太高级的技巧,用 C 语言、Python、Java 都可以实现,核心在于数据结构的设计。我对初学者的建议是:用 Python 来做,因为能更专注于算法本身,不用纠结构体内存管理这些问题。但如果你希望跟其他实验统一语言,比如全部用 C 完成,那这篇文章里的算法思路同样适用,只是在字符串处理和动态内存管理上要稍微多花点心思。
整个预测分析器的组成可以拆成三块:驱动器(Driver)、分析表(Predictive Table)和分析栈(Stack)。驱动器是那个循环主体——读栈顶符号、读当前输入符号、查表、决定动作;分析表是在分析开始前预先构造好的静态数据结构;分析栈则维护整个推导过程中尚未处理完的符号序列。输入缓冲区里放着待分析的 Token 序列,输出就是逐步的推导过程或者“接受/拒绝”的结论。
我用一个生活化的类比来帮你建立直觉:分析栈就像你手里的一摞待办事项卡片,最开始只有一张写着“整件事的最终目标”。每当你完成一个子目标,就把它翻出来核对;如果这个子目标还能拆得更细,就先把拆出来的小目标压到卡片堆顶上,优先处理。而分析表就像一张“遇到什么情况该做什么”的对照手册,告诉你当前正处理的这个目标,面对当前看到的输入,该拆成哪几个更小的目标。时输入卡的顺序和拆目标的方式能对上,整个过程就能顺畅推进;对不上,就说明你手里这份“说明书”(文法)有问题,或者目标项目本身就不该出现在这个流程里。
在我实际带过的几届实验课里,我发现一个共性现象:很多同学实现了代码,跑出几个测试用例,就觉得大功告成。其实预测分析器的精髓在于两个“事先”工作——事先求出所有非终结符的 FIRST 集和 FOLLOW 集,以及事先手工构造一张无冲突的分析表。表没构造对,代码再怎么写也是白搭。所以这篇博文会把重心放在这三个部分:集合计算、表构造、驱动循环。讲解顺序也按照实验推进的自然顺序展开,这样看下来刚好是一条完整的链路。我们要实现的分析器规模不必很大,能处理四到六个产生式的简单文法,支持空串产生式,能输出清晰的推导过程,就已经达到课程实验的核心要求了。具体文法设计在下一章给出。
2. FIRST 集和 FOLLOW 集的计算方法
2.1 为什么这两个集合这么关键
预测分析表的核心作用,是在“当前栈顶的非终结符 A”和“当前输入符号 a”的交叉点,指明该选用哪条产生式。那这个决定是怎么做出来的?算法依据就是 FIRST 集和 FOLLOW 集。
简单来说,假如文法里有产生式 A → α,而 α 能够推导出的第一个终结符集合就是 FIRST(α)。分析器看到输入符号 a 时,如果 a 属于 FIRST(α),就说明 α 这个候选式可以“接得住”a,于是选择 A → α 进行推导。但这会碰到一个特殊情况:如果 α 能推导出空串,也就是 ε,那我们就不能只考虑 α 的开头符号了,还得看 A 后面能跟什么符号。A 后面能跟的终结符集合就是 FOLLOW(A)。这时候如果 a 属于 FOLLOW(A),同样可以选择 A → α,不过实际效果相当于让 A 匹配完当前的输入后直接“隐身”——推导出一个空串。
这么一解释你就会发现,FIRST 集负责“起手”,FOLLOW 集负责“收尾”。很多同学在手工构造分析表时容易忽略第二层逻辑,导致带 ε 产生式的文法分析表构造不出来,或者在分析时明明匹配到了却报错。所以这两个集合的计算不是一道填空题,而是整个预测分析实验的地基。地基不稳,后面全塌。
2.2 计算规则与算法流程
计算 FIRST 集要反复用这几条规则,直到所有集合不再变大为止:
- 如果 X 是终结符,那么 FIRST(X) 就是 {X}。
- 如果 X 是非终结符,且存在产生式 X → Y1 Y2 ... Yk,那么依次考察 Y1 到 Yk:把 FIRST(Y1) 中的所有非 ε 元素加入 FIRST(X);如果 FIRST(Y1) 不含 ε,停止考察;如果含 ε,继续考察 Y2,把 FIRST(Y2) 的非 ε 元素加入;以此类推。如果一路能走到 Yk 且其中每个 FIRST 都含 ε,那么 ε 也要加入 FIRST(X)。
- 如果 X 本身有产生式 X → ε,ε 直接加入 FIRST(X)。
计算 FOLLOW 集的规则如下,同样迭代到不再变化为止:
- 对于开始符号 S,把 $(输入结束标记)加入 FOLLOW(S)。
- 如果存在产生式 A → αBβ,那么把 FIRST(β) 中的所有非 ε 元素加入 FOLLOW(B)。
- 如果存在产生式 A → αB,或者 A → αBβ 且 β 能推导出 ε,那么把 FOLLOW(A) 中的所有元素加入 FOLLOW(B)。
这两组规则看起来有点晕,但实现起来核心逻辑很简单:对所有非终结符的集合反复做“扫一遍产生式、按规则添加元素”的操作,某次扫描没有任何集合变化,就结束。这种不动点迭代的思路在编译原理里还会反复出现,比如后面做数据流分析也是这个套路,所以这个实验对你理解整套“迭代到收敛”的方法论都有帮助。
2.3 一个能跑通全流程的简易文法
为了让后面的所有讲解都有共同的语言基础,我选一个简化的类表达式文法作为全程示例:
E → T E' E' → + T E' | ε T → F T' T' → * F T' | ε F → ( E ) | id这个文法是经典的 LL(1) 表达式文法,已经消除了左递归和公共前缀,可以直接用于预测分析实验。它的终结符集合是 {+, *, (, ), id, $},非终结符集合是 {E, E', T, T', F},开始符号是 E。这里 $ 表示输入结束标记。用这个文法,可以支持形如 id + id * id 的表达式分析,覆盖了加法、乘法、括号、单个标识符这几种常见场景,做实验完全够用。
我手算一遍 FIRST 集的结果,你对照检查:
| 非终结符 | FIRST 集 |
|---|---|
| E | { (, id } |
| E' | { +, ε } |
| T | { (, id } |
| T' | { *, ε } |
| F | { (, id } |
FOLLOW 集的计算结果:
| 非终结符 | FOLLOW 集 |
|---|---|
| E | { ), $ } |
| E' | { ), $ } |
| T | { +, ), $ } |
| T' | { +, ), $ } |
| F | { +, *, ), $ } |
你可能会注意到,E 的 FOLLOW 集里没有 +,原因是 + 只出现在 E' 的开头,不会直接跟在 E 后面。
2.4 计算部分我常用的实现细节
实现了不止一遍之后,我总结出几个值得注意的地方。首先是数据结构的选择。FIRST 集和 FOLLOW 集本质上都是从符号映射到符号集合,所以我通常用字典(Python 的 dict)来存储,键是符号,值是一个集合。集合操作天然支持并集和成员判断,比手动去重方便太多。如果你用 C 语言实现,可以考虑用布尔数组或者位向量来表示集合,因为终结符和非终结符的数量通常有限,这样做空间效率更高。
其次是迭代终止条件的判断。每轮扫描前记录集合大小的快照,扫描结束后对比当前大小,如果不变就跳出循环。这个做法虽然朴素,但非常可靠。我在代码里通常封装一个compute_first_sets函数和一个compute_follow_sets函数,各自内部循环直到收敛。注意,这两个函数不是独立的——计算 FOLLOW 集时需要查询 FIRST(β),所以工程的代码顺序肯定是先算完 FIRST,再算 FOLLOW,并且要让 FOLLOW 计算函数能访问 FIRST 的结果。
最后一个容易忽视的细节是非终结符的 ε 判定。一个非终结符能不能推出 ε,这件事不能靠“它有一条产生式右部是 ε”这么简单判断,因为可能存在 A → B C、B → ε、C → ε 这种间接推导出 ε 的情况。我在实现里是专门写了一个can_derive_epsilon辅助函数,用递归加记忆化的方式判断,避免在复杂文法上出现死循环或漏判。这个判断在 FIRST 集计算里也要反复用到,所以单独拎出来复用很划算。
3. 预测分析表的构造方法与冲突检测
3.1 把 FIRST 和 FOLLOW 翻译成表格
有了 FIRST 集和 FOLLOW 集,构造预测分析表就是一个机械性的过程。表格的每一行对应一个非终结符,每一列对应一个终结符或 $。填写规则就两条:
- 对文法中的每条产生式 A → α,对 FIRST(α) 中的每个终结符 a,在表项 M[A, a] 中填入这条产生式。
- 如果 ε ∈ FIRST(α),那么对 FOLLOW(A) 中的每个符号 b(包括 $),在表项 M[A, b] 中填入这条产生式。
规则本身不难,但我要强调一点:同一个表格里,如果一个表项被要求填入两条不同的产生式,这就是发生了冲突,说明该文法不是 LL(1) 文法。这是实验里要重点检查的地方。出现冲突时,程序要么报错退出,要么覆盖之前的条目——后者会掩盖文法缺陷,产生错误的分析结果,所以好的实现必须有冲突检测机制,一旦发现冲突就明确报错。
还是用上一节的表达式文法来演示。比如产生式 E → T E',FIRST(T E') = FIRST(T) = { (, id },所以 M[E, (] 和 M[E, id] 都填入这条产生式。再比如 E' → + T E',FIRST(+ T E') = { + },所以 M[E', +] 填入这条产生式;而 E' → ε,因为 ε ∈ FIRST(ε),所以对 FOLLOW(E') 中的 ) 和 $,分别把这条 ε 产生式填入 M[E', )] 和 M[E', $]。
整个分析表长这样:
| 非终结符 | id | + | * | ( | ) | $ |
|---|---|---|---|---|---|---|
| E | E → T E' | E → T E' | ||||
| E' | E' → + T E' | E' → ε | E' → ε | |||
| T | T → F T' | T → F T' | ||||
| T' | T' → ε | T' → * F T' | T' → ε | T' → ε | ||
| F | F → id | F → ( E ) |
这张表就是后续分析驱动器的“主数据”。你完全可以在程序里直接用二维数组或者字典来存它,行索引是非终结符编号,列索引是终结符编号,每个格子存产生式右部的符号序列。空表项在分析时意味着出错。
3.2 消除左递归和提取公共左因子
既然提到了冲突,就要讲讲“文法改造”这个实验里几乎必考的操作。LL(1) 文法最讨厌的两类问题:左递归和公共左因子。比如表达式文法最常见的错误写法是 E → E + T | T,这就是左递归——FIRST(E) 里包含了 FIRST(E) 自身的部分,导致集合计算无法收敛,分析表构造时必然出现无穷递归或冲突。消除左递归的标准做法是引入新的非终结符,把左递归改成右递归,也就是我上面示例文法里 E' 那个产生式的由来。
公共左因子的例子如 A → a b | a c,两条产生式都以 a 开头,分析器看到 a 时不知道该选哪条。解法是提取公共因子 A → a A',然后 A' → b | c。这类改造在考试和实验报告里出现频率很高,但说实话,实际做实验时通常直接采用已经改造好的文法,改造技巧更多是为了让你理解原理。不过有一点我想提醒你:如果实验要求你自己设计一个文法,尽量从设计阶段就避开这两类问题,能省掉大量调试时间。
3.3 没问题,另一个高频 bug 源在终结符的定义上
构造表的时候,有不少人把 ε 也当作一个终结符来处理,在表格里给它单独开一列。这个做法其实是思路混淆了——ε 是空串的记号,不应该出现在输入序列里,也不应该在表里占列。输入序列里只有真正的终结符和结束符 $。ε 只参与产生式右部的处理逻辑,不会以符号的形式出现在分析栈和输入缓冲区中。我在检查实验报告时见过不少这样的设计,它不会导致分析器罢工,但会增加无谓的复杂度,一旦处理不严谨还会干扰冲突判断,所以尽早纠正这个习惯。
另外,关于 $ 的处理也值得专门说一句。$ 既标记输入串的结束,也是分析栈底部的初始元素之一。在构造 FOLLOW 集时它被加入开始符号的集合,在分析表里它占一列,在驱动器里它是终止判断的关键。我见过有同学把 $ 混在普通终结符里处理,结果分析结束后多出来一步“用 ε 产生式匹配”的诡异动作。正确逻辑很简单:当栈顶是 $ 并且输入也是 $ 时,分析立即成功结束,不需要查表。
4. 驱动算法与核心代码实现
4.1 总控程序的循环结构
驱动算法是整个预测分析器的心脏。它的输入是分析表、文法表和一个待分析的符号串,输出是逐步的动作序列。我先把伪代码写出来,方便你对照自己的实现排查:
初始化:分析栈压入 $,再压入开始符号 S 输入指针 ip 指向输入串第一个符号 重复执行: X = 栈顶符号 a = ip 指向的输入符号 if X == a == $: 分析成功,结束 else if X == a: 栈顶弹出,ip 后移一个符号 else if X 是终结符: 报错:栈顶终结符与输入符号不匹配 else if M[X, a] 为空: 报错:无法找到匹配的产生式 else: 弹出 X 取出 M[X, a] 对应的产生式 X → Y1 Y2 ... Yk 将 Yk ... Y2 Y1 依次压入栈(注意顺序是反的,保证 Y1 成为新的栈顶) 输出该产生式这个循环的每一步都对应一个明确的动作,非常适合做成带步骤计数的输出形式。你可以在每轮循环打印当前的栈内容、剩余输入和采取的动作,这样得到的结果会像教科书里的分析过程示例一样清晰。做实验报告时,把几次典型输入的运行输出贴在文档里,再把栈的变化手动画出来,分就能拿到大半。
4.2 完整可运行的 Python 参考实现
我在这里给出一份完整可运行的 Python 参考实现,采用上一节定义的表达式文法。这个实现包含 FIRST/FOLLOW 计算、分析表构造、驱动循环和几个测试用例,在 Python 3 环境下可以无依赖运行。代码的结构按模块拆分,方便你对照前面的原理逐步研读。注意,那份简洁的写法里刻意做了很多防御性检查,能帮助你在调试早期就发现问题。
# -*- coding: utf-8 -*- """ 预测分析器参考实现 文法: E -> T E' E' -> + T E' | ε T -> F T' T' -> * F T' | ε F -> ( E ) | id """ # ---------- 第一步:定义文法 ---------- # 产生式统一表达为 (左部, 右部符号元组) productions = [ ("E", ("T", "E'")), ("E'", ("+", "T", "E'")), ("E'", ()), # 空产生式表示 ε ("T", ("F", "T'")), ("T'", ("*", "F", "T'")), ("T'", ()), ("F", ("(", "E", ")")), ("F", ("id",)), ] nonterminals = {"E", "E'", "T", "T'", "F"} terminals = {"+", "*", "(", ")", "id", "$"} start_symbol = "E" # ---------- 第二步:计算 FIRST 集 ---------- def compute_first_sets(productions, nonterminals, terminals): first = {nt: set() for nt in nonterminals} # 判断一个符号串能否推导出 ε(支持递归,用动态规划防止死循环) def can_derive_epsilon(symbols, visited): if not symbols: return True # 和 contains_epsilon 逻辑合并处理 if all(s in nonterminals for s in symbols): # 逐个检查每个非终结符 if not visited: return False # 使用 memo 方式递归判断 return False # 简化的 epsilon 判断:仅检查一个非终结符是否能直接或间接推出 ε epsilon_nonterminals = set() changed = True while changed: changed = False for lhs, rhs in productions: if lhs in epsilon_nonterminals: continue if len(rhs) == 0 or all(sym in epsilon_nonterminals for sym in rhs): epsilon_nonterminals.add(lhs) changed = True def all_derive_epsilon(syms): return all(sym in epsilon_nonterminals or sym not in nonterminals for sym in syms) # 这里修正 FIRST 计算的核心逻辑 changed = True while changed: changed = False for lhs, rhs in productions: before = len(first[lhs]) # 遍历 rhs 中的符号 all_eps = True for sym in rhs: if sym in terminals: first[lhs].add(sym) all_eps = False break else: first[lhs] |= (first[sym] - {"ε"}) if "ε" not in first[sym]: all_eps = False break if all_eps: first[lhs].add("ε") if len(first[lhs]) != before: changed = True return first, epsilon_nonterminals first_sets, epsilon_nts = compute_first_sets(productions, nonterminals, terminals) print("FIRST 集:") for nt in sorted(nonterminals): print(f" FIRST({nt}) = {sorted(first_sets[nt])}")不过这份实现里can_derive_epsilon有点冗余,我实际调试时做了精简。下面给出我在实验中真正用到的更干净的版本,把 FIRST 集计算和 ε 判定融合在一起:
# ---------- 改进版 FIRST 计算 ---------- def compute_first(productions, nonterminals, terminals): first = {nt: set() for nt in nonterminals} # 初始化:终结符自己的 FIRST 就是自身 for t in terminals: if t != "$": pass # 终结符不需要额外存 FIRST,直接判断即可 changed = True while changed: changed = False for lhs, rhs in productions: before_size = len(first[lhs]) # 如果右部为空串,加入 ε if len(rhs) == 0: first[lhs].add("ε") else: can_epsilon = True for sym in rhs: if sym in terminals: first[lhs].add(sym) can_epsilon = False break else: first[lhs] |= (first[sym] - {"ε"}) if "ε" not in first[sym]: can_epsilon = False break if can_epsilon: first[lhs].add("ε") if len(first[lhs]) != before_size: changed = True return first这个改进版的核心逻辑很清楚:对每条产生式,依次扫描右部符号。如果是终结符,加入并终止;如果是非终结符,则把它 FIRST 集里的非 ε 元素全部加入,然后看它是否含 ε,如果含就继续扫描下一个符号,否则终止。如果右部所有符号都含 ε,就把 ε 加入左部的 FIRST 集。
FOLLOW 集的计算类似,但更依赖 FIRST 集的结果:
def compute_follow(productions, nonterminals, terminals, first): follow = {nt: set() for nt in nonterminals} follow[start_symbol].add("$") changed = True while changed: changed = False for lhs, rhs in productions: for i, sym in enumerate(rhs): if sym in nonterminals: before_size = len(follow[sym]) # 情况 A -> αBβ if i + 1 < len(rhs): beta = rhs[i+1:] # 把 FIRST(beta) 的非 ε 元素加入 beta_first = set() can_eps = True for b in beta: if b in terminals: beta_first.add(b) can_eps = False break else: beta_first |= (first[b] - {"ε"}) if "ε" not in first[b]: can_eps = False break follow[sym] |= beta_first # 如果 beta 能推出 ε,把 FOLLOW(A) 加入 if can_eps: follow[sym] |= follow[lhs] else: # 情况 A -> αB follow[sym] |= follow[lhs] if len(follow[sym]) != before_size: changed = True return follow这里有一个重要的细节:β 可以为空,即“右部最后一个符号”的情况。当 B 位于产生式末尾时,FOLLOW(B) 直接并入 FOLLOW(A)。另外,如果 β 是多个符号,即便是“FIRST(β) 不含 ε”也要特别注意——只有当 β 整体能推导出 ε 时才需要并入 FOLLOW(A),而不是只要 FIRST 里有 ε 就并。
用上述代码跑一遍示例文法,输出的结果应该跟我在第二章给的表完全一致。如果你手算和程序的结果对不上,我强烈建议你在计算过程中加打印,逐轮输出每次集合的增量变化。这样能非常直观地看出哪条规则没落到位。
4.3 构造分析表的代码实现
有了 FIRST 和 FOLLOW,分析表构造函数可以写成这样:
def build_parse_table(productions, nonterminals, terminals, first, follow): # 表项用字典:键是 (非终结符, 终结符),值是产生式索引 table = {} for idx, (lhs, rhs) in enumerate(productions): # 计算 FIRST(rhs) rhs_first = set() can_eps = True for sym in rhs: if sym in terminals: rhs_first.add(sym) can_eps = False break else: rhs_first |= (first[sym] - {"ε"}) if "ε" not in first[sym]: can_eps = False break # 规则 1:对 FIRST(rhs) 中每个终结符填表 for a in rhs_first: if a == "$": continue key = (lhs, a) if key in table: print(f"检测到冲突:M[{lhs}, {a}] 已有产生式,新产生式索引 {idx}") table[key] = idx # 规则 2:如果 rhs 能推出 ε,对 FOLLOW(lhs) 中的每个符号填表 if can_eps: for b in follow[lhs]: key = (lhs, b) if key in table: print(f"检测到冲突:M[{lhs}, {b}] 已有产生式,新产生式索引 {idx}") table[key] = idx return table实际跑出来,这个table字典的非空键应该正好有 14 个左右,对应前面表格里的非空格子。冲突检测的print在正确文法上不会输出任何内容,一旦输出了,就说明文法不满足 LL(1) 条件,你要回头修改文法设计,而不是硬着头皮接着写分析器。
4.4 驱动循环的最终实现
驱动循环的思路是把栈、输入串和分析表三者串起来,我给出下面这份完整代码,它的输出风格接近教材里的分析过程记录,适合直接用于实验报告:
def predict_parse(parse_table, start_symbol, input_tokens, terminals): # 输入 tokens 是终结符列表,比如 ["id", "+", "id"] stack = ["$", start_symbol] ip = 0 print(f"{'步骤':<6}{'分析栈':<30}{'输入串':<30}{'动作':<20}") step = 0 while stack: top = stack[-1] # 当前输入符号,输入串最后补一个 $ 表示结束 if ip < len(input_tokens): current_token = input_tokens[ip] else: current_token = "$" # 打印每一步的状态 stack_str = " ".join(stack) input_str = " ".join(input_tokens[ip:] + ["$"]) step += 1 if top == current_token: # 栈顶终结符和输入符号匹配 if top == "$": print(f"{step:<6}{stack_str:<30}{input_str:<30}{'接受':<20}") return True stack.pop() ip += 1 print(f"{step:<6}{stack_str:<30}{input_str:<30}{'匹配终结符 ' + top:<20}") elif top in terminals: # 栈顶是终结符但不等 print(f"{step:<6}{stack_str:<30}{input_str:<30}{'错误:栈顶终结符不匹配':<20}") return False else: # 查表 key = (top, current_token) if key not in parse_table: print(f"{step:<6}{stack_str:<30}{input_str:<30}{'错误:查表失败':<20}") return False prod_idx = parse_table[key] lhs, rhs = productions[prod_idx] stack.pop() # 逆序压入产生式右部符号 for sym in reversed(rhs): if sym != "ε" and sym != "": stack.append(sym) print(f"{step:<6}{stack_str:<30}{input_str:<30}{'推导 ' + lhs + ' -> ' + (' '.join(rhs) if rhs else 'ε'):<20}") return False # ---------- 测试 ---------- parse_table = build_parse_table(productions, nonterminals, terminals, first_sets, follow_sets) print("\n分析表:") for key in sorted(parse_table.keys()): lhs, a = key prod_idx = parse_table[key] lhs_table, rhs_table = productions[prod_idx] print(f" M[{lhs}, {a}] = {lhs_table} -> {' '.join(rhs_table) if rhs_table else 'ε'}") print("\n测试输入: id + id * id") predict_parse(parse_table, start_symbol, ["id", "+", "id", "*", "id"], terminals) print("\n测试输入: ( id + id ) * id") predict_parse(parse_table, start_symbol, ["(", "id", "+", "id", ")", "*", "id"], terminals) print("\n测试输入: id + * id") predict_parse(parse_table, start_symbol, ["id", "+", "*", "id"], terminals)我实际运行这份代码时,id + id * id会输出大概 15 到 20 行的分析过程,最后一步是“接受”。而id + * id会在某个环节查表失败,返回 False。这个失败点恰好对应输入串的语法错误,说明分析器正确地拒绝了非法输入。输出里的每一列都很直观,用来做实验报告的截图素材非常合适。
在我测试的过程中,有一个小地方特别值得注意:栈打印时如果用" ".join(stack)会把栈顶放在最右侧,但是打印出来的输入串是从左到右显示的。初次看输出时容易觉得“栈顶怎么在右边”,其实这符合栈的可视化习惯——栈底在左,栈顶在右。你的实验报告里如果能加上这个说明,批改的老师会觉得你对栈的理解是到位的。
5. 实操中的疑难问题与调试经验
5.1 三类高频报错场景
把实验课和自学过程中遇到的典型问题分分类,我把它们整理成一个速查表,方便你排查问题:
| 症状 | 可能原因 | 排查思路 |
|---|---|---|
| 程序运行后栈没有变化,死循环卡住 | 分析表里 ε 产生式没有正确填写,查表一直失败但程序没有报错处理 | 检查 parse_table 里对应键值是否存在,空表项必须显式报错 |
| FIRST 集一直增长,永不停止 | 文法存在左递归,或 epsilon 判定有误导致集合不断扩张 | 检查文法是否消除左递归;在迭代循环里加最大轮数上限 |
| 分析表出现大量冲突 | 文法不是 LL(1),存在公共左因子或 FIRST/FOLLOW 集相交 | 打印冲突的产生式编号,手工构造两个集合来判断交集来源 |
| 输入串最后多出一个 $ 无法匹配 | 初始栈里少了 $,或者 FOLLOW 集算错了导致 ε 产生式没填进 $ 列 | 确认初始栈是 ["$", start],并单独检查 FOLLOW(start) 是否包含 $ |
| 分析过程匹配成功后栈还没空 | 循环终止条件写错,应该同时检查栈顶是 $ 且输入也是 $ | 确认终止判断放在匹配动作之前,并且输入串末尾始终视为 $ |
我最常在实验课上看到的,其实是“分析表构造没做冲突检测,直接覆盖了冲突项”。表面上看代码能跑、结果也对,但换一个不合法的输入串就暴露问题了——因为覆盖后的表本身已经违背了预测分析的确定性原则,程序给出的“错误”位置和真实错误位置千差万别,不好定位。所以从一开始就给表构造加上冲突打印,是给自己留后路的做法。
5.2 关于 ε 产生式的一个隐藏陷阱
ε 产生式的处理简直是这个实验的头号刺客。我观察很多人处理A → ε时,直接把它当作“什么都不做”去实现——右部符号列表为空,逆序压栈这一步自然就跳过了。这个思路本身没错,但问题往往出在表格填写的时机上。
回顾一下规则的第二种情况:只有当 FIRST(α) 包含 ε 时,才需要根据 FOLLOW(A) 去填表。但 FIRST(α) 包含 ε,并不一定意味着 α 就是 ε 本身。比如 A → B C D,如果 B、C、D 各自都能推出 ε,那么 FIRST(BC D) 里就包含 ε。这种情况下,分析器选择这条产生式时压入的不是一个空串,而是 B C D 三个符号——只不过它们后续都会各自推导出 ε,栈会在几次动作后“原地消失”几个符号。这种间接推导出 ε 的情况在手工构造时特别容易漏,代码实现里却因为统一走“扫描完所有右部符号仍然 all_eps”的逻辑而自动处理了。这也正是我推荐让程序来计算 FIRST/FOLLOW 而不是手算的原因。
另一个和 ε 相关的经典问题是“栈顶是非终结符,输入是 $,查表查到了 ε 产生式”——这是完全正常的!比如示例文法里的 E' → ε 就会在输入只剩 $ 时发生。不少同学第一次看到会以为程序跑错了,到处调栈的状态。其实这是自顶向下分析里自然的一步:非终结符意识到自己匹配的部分结束了,主动“让位”给下一个非终结符,或者直接退出。你只需要在输出里把这一步的动作打清楚,就不会引起误解。
5.3 扩充文法时怎么改动最安全
实验做完基本要求之后,很多人会想着扩一扩文法,比如加上减法、除法,或者支持多个标识符连续出现。扩充本身是加分项,但改的时候要记住一个原则:每次只加一个产生式,然后立刻重新计算 FIRST、FOLLOW,构造分析表,跑一遍原有测试和新增测试。不要一口气加三四条产生式再一起调试,那样冲突的源头根本定位不到。
比如你要加一个减法产生式:
E' → + T E' | - T E' | ε这样 FIRST(E') 就变成了 { +, -, ε },E' 的 FOLLOW 集不变。改动也只影响 E' 所在的行。如果这时候分析表出现冲突,排查范围就很小——只可能是 - 和 E' 行其他产生式的首符号,或者 ε 和 FOLLOW(E') 产生了冲突。缩小排错范围什么都不如“增量修改”这个策略好用。
我个人的习惯是把文法定义写在一个单独结构里,产生式带编号,所有表的打印都显示编号而不是右部符号串。这样一旦冲突,打印出的信息直接指向第几条产生式,查错快得不是一点半点。你可以在自己的实现里把productions列表里的每个元素做成带索引的元组,或者用枚举去访问,效果都一样。
5.4 如何把错误处理做得更“有用”
课程实验的预测分析器,错误处理通常只需要“报错并停止”。但如果你想让实验效果更亮眼,可以做一点简单的错误恢复:遇到当前无法匹配的输入符号时,尝试跳过这个符号继续分析。实现方式不复杂——额外维护一个sync符号集合,当查表失败时就把栈顶非终结符弹出,或者跳过输入符号。这样分析器遇到错误不会直接崩溃,而是持续工作到最后,把所有错误位置一次性列出来。
这个“同步符号”的概念来自编译器的实战需求——语法分析阶段通常希望能报告尽可能多的错误,而不是遇到第一个就放弃。做实验的时候加这个功能并不难,却能在答辩时成为明显亮点。它背后的原理是把 FOLLOW 集当作同步集合:如果当前输入符号不可能出现在某个非终结符之后,那这个非终结符就不该继续参与匹配了,直接弹出让后面的非终结符来处理。这个理解一旦打通,编译器错误恢复的核心思路你就掌握了一大半。
5.5 代码调试时一步步看栈变化的方法
最后分享一个我屡试不爽的调试习惯。预测分析器是一个天然适合“单步跟踪”的程序,因为它每一步的状态变化都非常清晰。如果你用的是 Python,在驱动循环里加一个input("按回车继续...")就能实现逐步观察;如果输出量已经够大,也可以把每一轮的栈状态打印对齐,肉眼扫描哪里开始不对劲。
这个实验最常见的失败模式是“栈里某个非终结符一直没有被替换掉”。比如应该匹配 id 的地方栈顶却是 E,查表发现 M[E, id] 恰好填了错误的产生式,就会输出一堆没意义的推导。这时候往上一行行回看分析表构造的输出,重点检查 FIRST 集的计算是否正确。因为分析表的所有内容都源自 FIRST 和 FOLLOW,集合错一个元素,全表跟着错。
根据我个人教实验课的经验:这个实验真正难的不是代码编写——驱动循环核心代码不超过四十行——而是调试时“对出现问题的输入串反向追踪分析表的来源”这一思维习惯。一旦你理解了 FIRST 集决定“起手”、FOLLOW 集决定“收尾”这个逻辑闭环,并且能在代码里熟练地迭代集合直到收敛,整个预测分析器对你来说就真的只剩下复制粘贴代码的事了。