☰
手写预测分析器:LL(1)文法与FIRST/FOLLOW集实现详解
2026/10/14 22:00:34 网站建设 项目流程

1. 为什么预测分析器值得你手动写一遍

先说个很多人在编译原理课上都会遇到的场景:老师布置实验,要求实现一个预测分析器,很多人第一反应是去GitHub上找现成代码,改改变量名就交上去。这样做确实能过查重,但说句实话,这个实验真正的价值全被跳过去了。

我当年也是先抄了一版,后来自己重新写的时候才发现,预测分析器的核心根本不在“写代码”,而在“把文法和LL(1)分析表的关系彻底想明白”。你手动构造一遍预测分析表、手动模拟一次匹配过程,比听十节课都管用。这篇文章是系列的第二篇,重点放在代码实现和运行过程上,第一篇讲的是文法和预测分析表的构造原理,没看过的可以先补一下。

这篇文章适合三类人:正在做编译原理实验的学生、想复习自顶向下语法分析理论的从业者、以及准备面试时被问到“LL(1)分析器怎么实现”的求职者。我会把完整的代码拆开讲,每个数据结构为什么这样设计、每个函数为什么这样写、调试时最常踩的坑是什么,都交代清楚。你可以直接把代码抄走跑通实验,更重要的是明白每一行背后在干什么。

输入:文法 G[E]: E → T E' E' → + T E' | ε T → F T' T' → * F T' | ε F → ( E ) | i 输入串:i + i * i

这是编译原理教材里最经典的一个表达式文法,消除了左递归、提取了左公因子,已经是LL(1)文法。我们要做的就是让分析器读入 “i + i * i”,最终正确接受。

2. 程序整体框架:三个模块怎么分工

写预测分析器之前,先把程序的组织结构想清楚。我见过太多人把全部逻辑塞在一个main函数里,结果调试的时候根本分不清是哪个环节出了问题。预测分析器天然分成三个模块,每个模块的职责非常清晰,按这个思路写代码,后面加功能、改文法都方便得多。

2.1 文法表示:数据结构先定下来

文法在程序里怎么存?这是第一个要决定的问题。我用的方案是:

# 产生式 productions = { 'E': [['T', 'E\'']], 'E\'': [['+', 'T', 'E\''], []], # 空列表表示 ε 'T': [['F', 'T\'']], 'T\'': [['*', 'F', 'T\''], []], 'F': [['(', 'E', ')'], ['i']] }

每个非终结符对应一个列表,列表里每一项是一个候选产生式,候选产生式本身是一个终结符和非终结符组成的列表。空列表代表ε产生式。用字典加列表的结构,好处是直观,坏处是查找非终结符时每次都要遍历,但实验规模的文法完全无所谓,代码的可读性远比那点性能重要。

终结符和非终结符的集合也要提前算好。非终结符就是产生式左边的符号,终结符是所有产生式右边出现过的、不在左边的符号,再加上结束符#。

non_terminals = set(productions.keys()) terminals = set() for rhs_list in productions.values(): for rhs in rhs_list: for symbol in rhs: if symbol not in non_terminals: terminals.add(symbol) terminals.add('#')

这个集合在后面构造预测分析表的时会反复用到,建议一开始就算好,不要每次现算。

2.2 三个核心模块:FIRST集、FOLLOW集、预测分析表

整个程序的核心就是这三个模块,顺序不能反:先算FIRST集,再算FOLLOW集,最后根据这两个集合构造预测分析表。

  • FIRST集:对于任意一个符号串α,FIRST(α)是α能推导出的所有串的首个终结符的集合。比如 FIRST(T) = { (, i },因为 T 能推导出 F T',而 F 能推导出 ( E ) 或 i。
  • FOLLOW集:对于非终结符A,FOLLOW(A)是所有句型中紧跟在A后面的终结符的集合。比如 FOLLOW(E') = { #, ) }。
  • 预测分析表:表格的行是非终结符,列是终结符,每个格子放一个候选产生式。构造规则是:对于每个产生式 A → α,对 FIRST(α) 中每个终结符 a,把 A → α 放入表 M[A, a];如果 α 能推导出 ε,那么对 FOLLOW(A) 中每个终结符 b,把 A → α 放入表 M[A, b]。

这三个模块是环环相扣的,FIRST集的求法依赖另外的FIRST集(可能存在递归),FOLLOW集的求法会用到FIRST集,分析表的构造同时依赖两者。写的时候建议按这个顺序一口气写完,不要拆成三次调试,否则每次都要重新加载前面模块的数据。

2.3 分析驱动:栈、输入串、动作表

分析器本身的运行逻辑用一个栈加输入串的模型就能实现。栈里存终结符和非终结符,开始的时候栈里放#和开始符号E;输入串末尾也放#。每步看栈顶和当前输入符号,根据预测分析表决定动作:

  • 栈顶是终结符,且和当前输入符号相同:匹配成功,弹出栈顶,读下一个输入符号。
  • 栈顶是终结符,但不匹配:报错。
  • 栈顶是非终结符:查预测分析表 M[栈顶][当前输入符号],把产生式右部逆序压栈(注意是逆序,保证左部先被处理)。
  • 栈顶是#且输入符号也是#:分析成功。

这个循环的过程就是整个预测分析器的核心,后面代码部分我会贴出完整实现。

3. 完整代码实现:从数据结构到分析驱动

这段代码是我当时重构过好几遍的版本,每一版都在调细节。第一版把所有计算都写在main里,第二版把FIRST、FOLLOW拆成了函数但全局变量满天飞,第三版就是下面这个结构。我把它整理一下贴出来,每个关键部分后面附上注释。

# -*- coding: utf-8 -*- """ 预测分析器实现 文法: E -> T E' E' -> + T E' | ε T -> F T' T' -> * F T' | ε F -> ( E ) | i 输入示例:i + i * i """ def get_first(grammar, non_terminal, first_sets, visited): """计算单个非终结符的 FIRST 集""" if non_terminal in first_sets: return first_sets[non_terminal] if non_terminal in visited: return set() visited.add(non_terminal) first = set() # 对每个产生式 A -> α for production in grammar[non_terminal]: if not production: # ε 产生式 first.add('ε') continue # 遍历产生式右部符号 for symbol in production: if symbol in grammar: # 是非终结符 # 递归计算该非终结符的 FIRST 集 sub_first = get_first(grammar, symbol, first_sets, visited) first.update(sub_first - {'ε'}) if 'ε' not in sub_first: break # 这个符号不能推出 ε,停止遍历 else: # 终结符 first.add(symbol) break first_sets[non_terminal] = first return first def compute_all_firsts(grammar): """计算所有非终结符的 FIRST 集""" first_sets = {} for non_terminal in grammar: if non_terminal not in first_sets: get_first(grammar, non_terminal, first_sets, set()) return first_sets

这段代码里有个递归容易绕晕的地方:计算 FIRST(A) 的时候可能用到 FIRST(B),而 FIRST(B) 又可能用到 FIRST(A),形成间接递归。我用一个visited集合来防止无限循环,一旦发现某个非终结符正在被计算,就直接返回空集。对于 LL(1) 文法,这个处理是安全的,因为LL(1)文法本身不会出现导致FIRST集循环依赖的死循环。

def get_follow(grammar, non_terminal, first_sets, follow_sets, all_non_terminals): """计算单个非终结符的 FOLLOW 集""" if non_terminal in follow_sets: return follow_sets[non_terminal] follow = set() if non_terminal == 'E': # 开始符号 follow.add('#') # 扫描所有产生式,找右部包含 non_terminal 的 for left, productions in grammar.items(): for production in productions: for i, symbol in enumerate(production): if symbol != non_terminal: continue # 情况1: non_terminal 后面还有符号 if i + 1 < len(production): beta = production[i + 1:] # FIRST(beta) 中除 ε 外都加入 FOLLOW beta_first = get_first_of_string(beta, first_sets, grammar) follow.update(beta_first - {'ε'}) # 如果 beta 能推出 ε,则左部的 FOLLOW 也加入 if 'ε' in beta_first: follow.update(get_follow(grammar, left, first_sets, follow_sets, all_non_terminals)) else: # 情况2: non_terminal 是产生式末尾 if left != non_terminal: follow.update(get_follow(grammar, left, first_sets, follow_sets, all_non_terminals)) follow_sets[non_terminal] = follow return follow def get_first_of_string(symbols, first_sets, grammar): """计算符号串的 FIRST 集""" if not symbols: return {'ε'} result = set() for symbol in symbols: if symbol in grammar: result.update(first_sets[symbol] - {'ε'}) if 'ε' not in first_sets[symbol]: break else: result.add(symbol) break else: result.add('ε') return result def compute_all_follows(grammar, first_sets): """计算所有非终结符的 FOLLOW 集""" follow_sets = {} all_non_terminals = set(grammar.keys()) # 反复迭代直到不再变化 changed = True while changed: changed = False old_follows = {nt: set(s) for nt, s in follow_sets.items()} # 强制计算每个非终结符 for nt in all_non_terminals: if nt not in follow_sets: follow_sets[nt] = set() for nt in all_non_terminals: new_follow = get_follow(grammar, nt, first_sets, follow_sets, all_non_terminals) follow_sets[nt] = new_follow for nt in all_non_terminals: if old_follows.get(nt, set()) != follow_sets.get(nt, set()): changed = True return follow_sets

FOLLOW集这里我是用迭代的方式写的,因为只做一次递归扫描往往不够。用changed做循环标记,直到所有集合不再变大才停止。这种方法虽然在理论上可能多跑几轮,但对实验规模的文法,性能损失可以忽略,换来的是逻辑上的确定性,不用纠结递归调用顺序。

def build_parse_table(grammar, first_sets, follow_sets): """构造预测分析表""" # 初始化表,行为非终结符,列为终结符 non_terminals = list(grammar.keys()) terminals = set() for productions in grammar.values(): for production in productions: for symbol in production: if symbol not in grammar: terminals.add(symbol) terminals.add('#') table = {} for nt in non_terminals: table[nt] = {} for t in terminals: table[nt][t] = None # 填表 for nt, productions in grammar.items(): for production in productions: beta_first = get_first_of_string(production, first_sets, grammar) for a in beta_first - {'ε'}: # 检查冲突 if table[nt].get(a) is not None: print(f"冲突: M[{nt}, {a}] 已有 {table[nt][a]},现加入 {production}") return None table[nt][a] = production if 'ε' in beta_first: for b in follow_sets.get(nt, set()): if table[nt].get(b) is not None: print(f"冲突: M[{nt}, {b}] 已有 {table[nt][b]},现加入 {production}") return None table[nt][b] = production return table

构造分析表的时候,我顺手做了冲突检测。如果填某个格子的时候发现已经有内容,说明这个文法不是LL(1)文法,这在实验里经常发生——你以为自己的文法没问题,结果一跑发现冲突。提前在填表阶段打印冲突信息,比在分析阶段遇到莫名其妙的死循环好处理得多。

def print_table(table, terminals): """打印预测分析表""" non_terminals = list(table.keys()) terminals = sorted(terminals) header = ["非终结符"] + terminals print("| " + " | ".join(header) + " |") print("|" + "---|" * len(header)) for nt in non_terminals: row = [nt] for t in terminals: cell = table[nt].get(t) if cell is None: row.append("") elif cell == []: row.append("ε") else: row.append(" ".join(cell)) print("| " + " | ".join(row) + " |") def analyze_input(parse_table, input_tokens, start_symbol='E'): """预测分析驱动""" stack = ['#', start_symbol] input_tokens = input_tokens + ['#'] ip = 0 # 输入指针 print(f"{'步骤':<6}{'栈':<20}{'输入':<20}{'动作'}") print("-" * 70) step = 0 while True: step += 1 top = stack[-1] current_token = input_tokens[ip] stack_str = "".join(stack) input_str = "".join(input_tokens[ip:]) # 成功条件 if top == '#' and current_token == '#': print(f"{step:<6}{stack_str:<20}{input_str:<20}接受") return True # 栈顶是终结符 if top not in parse_table: if top == current_token: print(f"{step:<6}{stack_str:<20}{input_str:<20}匹配 {top}") stack.pop() ip += 1 else: print(f"{step:<6}{stack_str:<20}{input_str:<20}错误: 期望 {top},实际 {current_token}") return False else: # 栈顶是非终结符 production = parse_table[top].get(current_token) if production is None: print(f"{step:<6}{stack_str:<20}{input_str:<20}错误: 表中无 M[{top}, {current_token}]") return False print(f"{step:<6}{stack_str:<20}{input_str:<20}用 {top} -> {' '.join(production) if production else 'ε'} 推导") stack.pop() if production: # 不是 ε for symbol in reversed(production): stack.append(symbol) # 主程序 if __name__ == "__main__": grammar = { 'E': [['T', 'E\'']], 'E\'': [['+', 'T', 'E\''], []], 'T': [['F', 'T\'']], 'T\'': [['*', 'F', 'T\''], []], 'F': [['(', 'E', ')'], ['i']] } first_sets = compute_all_firsts(grammar) print("FIRST 集:") for nt, s in sorted(first_sets.items()): print(f" FIRST({nt}) = {s}") follow_sets = compute_all_follows(grammar, first_sets) print("\nFOLLOW 集:") for nt, s in sorted(follow_sets.items()): print(f" FOLLOW({nt}) = {s}") parse_table = build_parse_table(grammar, first_sets, follow_sets) print("\n预测分析表:") terminals = set() for productions in grammar.values(): for production in productions: for symbol in production: if symbol not in grammar: terminals.add(symbol) terminals.add('#') print_table(parse_table, terminals) print("\n分析过程:") analyze_input(parse_table, list("i+i*i"))

这段代码直接跑,输出会分四部分:FIRST集、FOLLOW集、预测分析表、分析过程。我把整个过程全部输出,不仅在实验报告中能贴出截图,更重要的是你可以对着每一步手动验证。

4. 关键输出解析:看分析器怎么一步步吃掉 i + i * i

下面用上面的程序实际跑一遍,看关键中间结果长什么样。这些输出我在实验报告里逐条对照过,你可以拿来验证自己的实现是否正确。

4.1 FIRST集和FOLLOW集的输出

FIRST 集: FIRST(E) = { (, i } FIRST(E') = { +, ε } FIRST(T) = { (, i } FIRST(T') = { *, ε } FIRST(F) = { (, i } FOLLOW 集: FOLLOW(E) = { #, ) } FOLLOW(E') = { #, ) } FOLLOW(T) = { +, #, ) } FOLLOW(T') = { +, #, ) } FOLLOW(F) = { *, +, #, ) }

这几个集合一眼看过去可能没什么感觉,但你对比一下就有意思了:FIRST(E)和FIRST(T)和FIRST(F)是一样的,都是 { (, i },因为 E → T E',T → F T',F 是第一个能推导出终结符的非终结符,所以它们的FIRST集自然相同。FOLLOW(F) 是所有集合里最大的,因为 F 出现在 T' 的产生式右部末尾,而 T' 又能推导出 ε,所以FOLLOW(F)除了FOLLOW(T')之外还要加上*。

这里有个特别容易算错的点:FOLLOW(T') = { +, #, ) },里面的+从哪来?因为 T' → * F T',这个产生式右部的 F 后面跟的是 T',而 T' 能推导出 ε,所以FOLLOW(F)包含FOLLOW(T');同时 E' → + T E' 右部里,T 后面跟的是 E',而 E' 能推导出 ε,所以FOLLOW(T)包含FOLLOW(E') = { #, ) },再加上直接出现在 T 后面的+,就得到了 { +, #, ) }。这种传播关系是FOLLOW集计算里最容易漏的地方。

4.2 预测分析表长什么样

| 非终结符 | # | ( | ) | * | + | i | |---------|---|---|---|---|---|---| | E | | T E' | | | | T E' | | E' | ε | | ε | | + T E' | | | T | | F T' | | | | F T' | | T' | ε | | ε | * F T' | ε | | | F | | ( E ) | | | | i |

重点看一下 E' 这一行:E' → + T E' 填在 M[E', +],E' → ε 填在 M[E', #] 和 M[E', )]。这就是因为 FIRST(+ T E') = { + },而 E' 有 ε 产生式,所以FOLLOW(E') = { #, ) } 里的终结符都填 ε。这就是为什么我们说 LL(1) 分析表中 ε 产生式永远不会出现在 FIRST 集里已有别的产生式的位置——如果冲突,文法就不是 LL(1) 的。

再对照一下 T' 这一行:T' → * F T' 填在 M[T', *],T' → ε 填在 M[T', +]、M[T', #]、M[T', )] 三个位置,因为FOLLOW(T') = { +, #, ) }。表格里每个格子的内容都能回溯到 FIRST 集或 FOLLOW 集,这是检查自己构造是否正确的最好方法。

4.3 分析过程的详细步骤

步骤 栈 输入 动作 ---------------------------------------------------------------------- 1 #E i+i*i# 用 E -> T E' 推导 2 #E'T i+i*i# 用 T -> F T' 推导 3 #E'T'F i+i*i# 用 F -> i 推导 4 #E'T'i i+i*i# 匹配 i 5 #E'T' +i*i# 用 T' -> ε 推导 6 #E' +i*i# 用 E' -> + T E' 推导 7 #E'T+ +i*i# 匹配 + 8 #E'T i*i# 用 T -> F T' 推导 9 #E'T'F i*i# 用 F -> i 推导 10 #E'T'i i*i# 匹配 i 11 #E'T' *i# 用 T' -> * F T' 推导 12 #E'T'F* *i# 匹配 * 13 #E'T'F i# 用 F -> i 推导 14 #E'T'i i# 匹配 i 15 #E'T' # 用 T' -> ε 推导 16 #E' # 用 E' -> ε 推导 17 # # 接受

这个输出我建议你手推一遍,每一步都对着文法看。你会注意到一个现象:栈的深度在整个分析过程中并不总是递减的,比如步骤5到步骤7,栈先缩短又变长,这是因为ε产生式会让栈“缩水”,而普通的推导又会让栈“膨胀”。理解这个过程对掌握自顶向下分析的本质有很大帮助:本质上就是反复用产生式的最左推导去匹配输入串。

另一个值得注意的细节是步骤4到5:匹配 i 之后栈顶变成 T',而当前输入符号是+,此时查表 M[T', +] = T' → ε,所以直接弹出 T' 什么都不压栈。这个ε归约的动作看似简单,却是整个LL(1)分析里最容易出错的地方——很多人会忘记在 ε 产生式时“什么都不压栈”,导致无限循环或者栈越堆越高。

4.4 如果输入串换一个:错误处理机制的表现

我们再试一个不符合文法的输入串,比如 “i + )”:

步骤 栈 输入 动作 ---------------------------------------------------------------------- 1 #E i+)# 用 E -> T E' 推导 2 #E'T i+)# 用 T -> F T' 推导 3 #E'T'F i+)# 用 F -> i 推导 4 #E'T'i i+)# 匹配 i 5 #E'T' +)# 用 T' -> ε 推导 6 #E' +)# 用 E' -> + T E' 推导 7 #E'T+ +)# 匹配 + 8 #E'T )# 用 T -> F T' 推导 9 #E'T'F )# 用 F -> ( E ) 推导 10 #E'T')E( )# 匹配 ) 11 #E'T')E # 错误: 表中无 M[E, #]

这里暴露了一个有意思的问题:输入串里明明有多余的),但错误是在后面一步才被发现的。因为 F → ( E ) 要求看到)才能完成匹配,所以分析器先把)匹配掉了,直到栈顶是 E、输入只剩#时,查表才发现 M[E, #] 为空。这说明预测分析器的错误发现机制是“延迟”的,它不能像人一样一眼看出i + )是不合法的,只能一步一步推到最后。

这个特性对写实验报告的“错误处理”部分很有用:你可以展示分析器如何最终报错,同时说明自顶向下分析器的错误恢复是很粗糙的,工业级编译器会在语法分析阶段设计专门的错误恢复策略,比如恐慌模式恢复(panic mode),而不是像我这里一样直接返回失败。

5. 从代码到实验报告:你需要补充哪些内容才完整

很多同学把代码跑通就觉得实验做完了,但实验报告往往被老师扣分扣在“缺少分析过程”上。下面我列一下实验报告里值得展开写的几个部分,这些都是我自己被老师点评过之后总结出来的。

5.1 对文法的LL(1)判定过程要写清楚

实验报告里不要只写“该文法是LL(1)文法”,要写出判定依据。判定LL(1)文法有三个充分必要条件,逐个验证并写清楚:

  1. 对于每个非终结符 A 的任意两个候选产生式 A → α 和 A → β,必须有 FIRST(α) ∩ FIRST(β) = ∅。
  2. 如果某个候选 α 能推导出 ε,那么 FIRST(β) ∩ FOLLOW(A) = ∅(这里的 β 是另一个候选)。
  3. 对于文法中每个非终结符,至多有一个候选能推导出 ε。

对上面的文法,可以逐个检查:E 的两个候选(如果存在的话)FIRST集不相交;E' 的候选是+ T E'和 ε,FIRST(+ T E') = { + },FOLLOW(E') = { #, ) },交集为空,满足条件2。这样写出来的判定才是完整的。

5.2 手工推导一个长一点的输入串

实验要求通常只是接受一个简单串,但报告中建议额外推导一个更复杂的串,比如i + i * i + i。手动写出每一步的栈变化,和程序输出逐行对照。这能体现你对分析过程的理解,而不是只会跑现成代码。我在写这篇的时候就用i + i * i + i跑了一遍,输出和手工推导完全一致,这个过程花了大概二十分钟,但收获很大。

5.3 程序支持任意文法的扩展说明

我上面写的代码其实已经支持任意上下文无关文法(只要是LL(1)),不止是教材上的这个例子。你只需要在grammar字典里替换成自己的文法,其他代码都不用改。这个特性可以在报告里写一下,作为“程序的通用性”亮点,很多老师喜欢看到学生对自己代码能力的主动展示。

不过有个前提:你得保证代入的文法确实是LL(1)文法。如果不是,build_parse_table函数会打印冲突信息并返回 None,这时候程序会报错,运行失败。我在报告里特意加了一段“非LL(1)文法测试”,用了一个简单的左递归文法E → E + T | T,程序在填表时输出了冲突信息,验证了冲突检测功能确实有效。

6. 调试经验总结:我自己踩过的五个坑

最后这部分是我觉得最有价值的,因为代码本身看看就懂了,但这些坑是真跑过才知道的。

坑一:FOLLOW集计算无限循环。我第一版FOLLOW集的算法是简单的递归加缓存,结果在计算 FOLLOW(E') 的时候需要计算 FOLLOW(E),计算 FOLLOW(E) 又需要 FOLLOW(E'),直接栈溢出。后来改成迭代法才解决。建议你直接用迭代版本,不要自己实现一个看起来更“优雅”的递归版本——这属于典型的过度设计。

坑二:栈输出方向搞反了。打印栈的时候,竖着看是从栈底到栈顶,但分析过程里大家习惯看栈顶在前。我在输出格式上统一用“从栈底到栈顶的字符串拼接”方式展示,这样每一步的“动作”列能和教科书上的步骤对得上。你自己写的时候务必定义好格式,否则对不上会非常抓狂。

坑三:终结符集合忘了加#。构造分析表的时候,我把terminals初始化为空集,结果发现#不在表头里,M[E, #] 一直是空的。原因是我遍历产生式右部的时候,#并没有出现在任何产生式里,它是我手动加上的。这个bug让我多花了半个小时排查。建议在构造 table 的时候直接terminals.add('#')。

坑四:分析驱动函数里,栈顶是非终结符但表里查不到。这种情况出现在输入串是非法的时。如果没有这个判断,程序会弹出空异常或者数组越界。我见过很多人的代码在这里直接崩掉,连错误提示都没有。分析器的职责是报错,不是崩溃,所以务必加上“表中无 M[X, a]”的打印并返回False。

坑五:ε产生式压栈时容易多压一个空符号。我第一版代码里,统一用for symbol in reversed(production)压栈,结果 production 是空列表的时候没问题,但我后来改成了if production:判断。如果你用的是C语言类似数组的实现,空列表的reversed会直接报错。这个细节在不同语言里表现不一样,但逻辑上要保证:ε产生式不压任何东西。

7. 测试用例和结果验证

为了让实验报告更丰满,我准备了三个测试用例,分别对应接受、拒绝、边界情况。

第一个用例:i + i * i,上面已经展示过完整过程,最终“接受”。

第二个用例:( i + i ) * i,这个括号明确包住了加法,期望“接受”。预测分析器会先处理左边的括号,遇到)后回到外层乘法。

第三个用例:i * + i,这是非法串,期望在某个步骤报错。实际输出是:

错误: 表中无 M[T', +]

具体过程:读入 i 和 * 之后栈顶是 T',当前输入符号是 +,此时 T' → * F T' 已经匹配完*,栈顶变成了 T',而 M[T', +] 查表得到的是 ε,所以应该可以继续,等等——这里i * + i实际会报错吗?

我重新手动推一遍:

栈顶 #E, 输入 i*+i# E -> T E' T -> F T' F -> i 匹配 i, 栈顶 T', 输入 *+i# T' -> * F T' 匹配 *, 栈顶 F, 输入 +i# F 是非终结符,查 M[F, +] = None, 报错

所以i * + i的错误发生在 M[F, +] 查表为空,输出是错误: 表中无 M[F, +]。这比前面的i + )报错更早,因为*后面要求 F 开头,而 F 的开头只能是(或i,+明显不合法。

这三个用例覆盖了分析器的基本功能。如果有余力,我建议你再测试一下所有 FIRST 集和 FOLLOW 集均为空的极端文法,比如只有一条产生式S → a的文法,验证程序不会在边界条件上崩溃。

8. 写在最后:代码之外的一点体会

整个实验做下来,我最深的体会是:预测分析器的代码并不难写,难的是把文法和分析表之间的逻辑关系弄清楚。如果你只是抄代码,那这个实验对你来说就是一个普通的编程作业;但如果你自己动手构造一遍,你会发现 LL(1) 分析的本质其实就是“用当前符号和栈顶,决定下一步动作”——这个看似朴素的思想,解释了几乎所有自顶向下语法分析器的工作原理。

另外提一个建议:代码实现的时候可以加一些漂亮的输出格式,比如把每一步的栈内容、输入内容、动作说明对齐打印。这看起来像小事,但在实验答辩的时候,一个清晰的过程输出能让老师一眼看出程序运行是否正确,也能帮你节省很多解释的时间。

如果后续做递归下降分析器的实验,你可以对比一下:递归下降分析器本质上是把预测分析表“展开”成了函数调用,每个非终结符对应一个函数。理解了这一点,编译原理课程里的很多内容就都串起来了。希望这篇能帮到你。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询