从文法文件到LL1预测分析表,C++实现的全过程我踩过的坑都在这里了
作为一个反复折腾过编译原理课程设计的人,我太清楚“从文法文件到LL(1)预测分析表”这件事的痛点了。课本上讲的都是FIRST集、FOLLOW集的定义和手算步骤,真到了要写C++代码从零实现时,你才发现问题远没有想象中简单:怎么设计文法文件的格式?用什么样的数据结构存产生式?求FOLLOW集的时候怎么处理循环依赖?预测分析表里出现冲突怎么定位?这些问题不踩一遍坑,永远只能停留在“好像懂了”的层面。
这篇文章我就把整个实现过程完整拆开讲一遍,从文法文件的格式定义开始,到FIRST集、FOLLOW集的计算,再到预测分析表的生成和冲突检测,最后附上我实际调试时总结的常见问题排查表。内容覆盖完整可运行的C++实现思路和关键代码片段,代码风格偏向工程实战而不是教科书伪代码,适合正在做编译原理课程设计、或者自己写解释器/编译器前端的朋友参考。如果你想直接看结论,我的建议是:数据结构决定一切,设计好存储方案,后面所有算法都是水到渠成的事。
先说清楚这个项目到底是干什么的。LL(1)预测分析表是语法分析阶段的核心产物,它的作用是:给定一个非终结符和当前读到的终结符(lookahead),告诉分析器下一步该展开哪条产生式。整张表构建的输入是一个符合LL(1)限制的上下文无关文法,一般用一个文本文件来存放,这也是项目标题里“文法文件”的由来。程序读取这个文件,解析出非终结符集合、终结符集合、开始符号和所有产生式,然后依次计算FIRST集、FOLLOW集,最后填表。整个过程不涉及词法分析,也不涉及后面的语法树构建,就老老实实把核心的“自动构造预测分析表”这条链路打通。
1. 项目整体设计:先定数据结构和文件格式,再谈算法
很多初学者一上来就急着写FIRST集的计算逻辑,结果写到一半发现不知道怎么存产生式,也不知道怎么表示终结符和非终结符,最后代码改得面目全非。我自己的经验是,这类项目必须先把两个东西定死:文法文件的存储格式、内存中的核心数据结构。这两个定好了,后面的算法实现就是按部就班地填代码。
1.1 文法文件格式怎么定:兼顾易写和易解析
LL(1)分析程序的输入文法文件,我在实践中选择了最经典的格式:一行一条产生式,箭头用->表示,产生式右部各符号之间用空格分隔,空串(epsilon)用#表示,开始符号规定为第一条产生式的左部。举个例子,一个支持加减乘除和括号的表达式文法是这个样子的:
code复制E -> E + T
E -> T
T -> T * F
T -> F
F -> ( E )
F -> id
这种格式的好处有三个。第一,用空格分隔符号,读取的时候直接用std::getline按行读,再用std::stringstream按空格切割就行,C++标准库轻松搞定。第二,每条产生式独立成行,左部和右部的对应关系一目了然,即使文法规模变大也不会混乱。第三,空串用#一个字符表示,避免了处理空字符串的尴尬。
这里有几个细节你在设计格式时就要考虑到。终结符id这种带字母的单词和文法的非终结符E、T、F在字面上会混淆,所以建议约定非终结符用大写字母或<...>包裹,终结符用小写字母或数字。最省事的约定是:大写字母或尖括号<>包围的符号视为非终结符,其他都是一定是终结符。我实际项目中用的是“大写开头为非终结符”的约定,配合#表示空串,这样解析时只需要判断一个字符就能分类。
另外,文法文件的编码建议只用ASCII,不要用中文注释,不要在行尾留多余空格。这些都是我在实际测试时被坑过的点:Windows记事本保存的UTF-8 BOM会让第一行第一个字符读出来是\xEF\xBB\xBF,如果第一行正好是开始符号的定义,整个文法解析直接崩掉。所以要么用VS Code等工具确保保存为UTF-8无BOM格式,要么在读取文件时手动跳过文件头的BOM标记。
1.2 核心数据结构设计:怎么存文法才能让后续算法不乱
确定了文件格式,接下来就是C++里数据结构的设计。我建议定义三个核心类型:Symbol表示符号(终结符或非终结符)、Production表示产生式、Grammar表示整个文法。
cpp复制#include <iostream>
#include <fstream>
#include <sstream>
#include <string>
#include <vector>
#include <set>
#include <map>
#include <algorithm>
enum class SymbolType {
TERMINAL, // 终结符
NON_TERMINAL // 非终结符
};
struct Symbol {
std::string name;
SymbolType type;
bool operator<(const Symbol& other) const { return name < other.name; }
bool operator==(const Symbol& other) const { return name == other.name; }
};
struct Production {
Symbol lhs; // 左部(非终结符)
std::vector<Symbol> rhs; // 右部符号序列
int id; // 产生式编号,便于调试和引用
};
class Grammar {
public:
std::vector<Production> productions; // 所有产生式
std::set<Symbol> terminals; // 终结符集合
std::set<Symbol> non_terminals; // 非终结符集合
Symbol start_symbol; // 开始符号
};
这里有人可能会问:Symbol里面直接存std::string名字,再用enum标记类型,为什么不直接用char或者int?原因很简单:id这种终结符不是单字符,用char根本装不下。用字符串存符号名,用set自动去重,是工程上最稳妥的做法。Symbol重载了<运算符,是因为后面要把它放进std::set和std::map的key位置,必须满足严格弱序的要求。
产生式里的id字段是我特别建议加的。调试预测分析表的时候,你会频繁地在“表项是几号产生式”和“这条产生式的内容”之间来回切换,没有编号只能靠打印整个右部来区分,效率很低。我在课设调试阶段就是靠这个编号,一分钟定位了三个冲突表项。
文法类里面还可以加一些辅助方法,比如判断一个符号是不是非终结符、把符号集合转成字符串方便打印、按顺序输入产生式等。这些方法不复杂,但是能让后面的FIRST集和FOLLOW集算法代码干净很多,值得多写几行。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 从文件到内存:文法解析模块的实现要点
有了数据结构和文件格式约定,接下来就是把磁盘上的文法文件解析成内存中的Grammar对象。这个模块看起来简单,但它是整个程序的地基,解析出错后面全部白搭。
2.1 解析步骤拆解:读行、切分、分类、登记
解析代码的流程大致分四步:逐行读取文件,跳过空行和以//开头的注释行;对每一行用->分割左部和右部;把左部和右部的符号串按空格切分,逐个创建Symbol对象;最后把非终结符加入non_terminals集合,终结符加入terminals集合,整条产生式存进productions数组。
cpp复制bool Grammar::load_from_file(const std::string& filename) {
std::ifstream infile(filename);
if (!infile.is_open()) {
std::cerr << "Failed to open file: " << filename << std::endl;
return false;
}
std::string line;
bool is_first_production = true;
int prod_id = 1;
while (std::getline(infile, line)) {
// 去除行尾回车符
if (!line.empty() && line.back() == '\r') line.pop_back();
// 跳过空行和注释
if (line.empty()) continue;
if (line.rfind("//", 0) == 0) continue;
// 分割左部和右部
auto arrow_pos = line.find("->");
if (arrow_pos == std::string::npos) {
std::cerr << "Invalid production, missing '->': " << line << std::endl;
return false;
}
std::string lhs_str = line.substr(0, arrow_pos);
std::string rhs_str = line.substr(arrow_pos + 2);
// 去掉多余空格
lhs_str.erase(0, lhs_str.find_first_not_of(" \t"));
lhs_str.erase(lhs_str.find_last_not_of(" \t") + 1);
rhs_str.erase(0, rhs_str.find_first_not_of(" \t"));
rhs_str.erase(rhs_str.find_last_not_of(" \t") + 1);
// 解析左部符号
Symbol lhs = make_symbol(lhs_str);
if (lhs.type != SymbolType::NON_TERMINAL) {
std::cerr << "Left-hand side must be non-terminal: " << lhs_str << std::endl;
return false;
}
// 解析右部符号
std::vector<Symbol> rhs_symbols;
std::stringstream ss(rhs_str);
std::string token;
while (ss >> token) {
rhs_symbols.push_back(make_symbol(token));
}
// 登记非终结符和终结符
non_terminals.insert(lhs);
for (const auto& sym : rhs_symbols) {
if (sym.type == SymbolType::NON_TERMINAL) {
non_terminals.insert(sym);
} else if (sym.name != "#") {
terminals.insert(sym);
}
// 注意:'#' 不加入终结符集合,它只是一个特殊的空串标记
}
// 保存产生式
productions.push_back({lhs, rhs_symbols, prod_id++});
// 第一条产生式的左部作为开始符号
if (is_first_production) {
start_symbol = lhs;
is_first_production = false;
}
}
return true;
}
这段代码里有一个关键设计:#符号作为空串的标记,但在划分终结符集合时,我特意把它排除在外。为什么?因为LL(1)分析表中,#出现在表的“列”上大概率表示输入串的结束符$,而空串并不是一个真实的输入符号。如果误把#当成普通终结符加进terminals集合,后面计算FIRST集和FOLLOW集时就会多出一个实际上不存在的“符号”,导致预测分析表多出一列,甚至产生虚假的冲突。这个坑我踩过一次,后面还会细说。
make_symbol函数的实现就是根据首字符判断类型:如果字符串的第一个字符是大写字母,或者整体被<>包裹,就认为是非终结符;否则是终结符。这里要注意,std::stringstream按空格切分时,要注意文法文件里不要有全角空格,否则token会包含\xEF\xBC\x8C这种UTF-8字节序列,直接破坏分类逻辑。
2.2 解析模块的调试闸门:加载后立刻自检
解析完成并不意味着文法就是合法的。一个常见的错误是:用户写的文法左部出现了终结符,或者右部出现了未定义的大写符号,或者第一条产生式的左部和后续产生式的左部完全不相关,导致文法里存在不可达的非终结符。这些错误在解析阶段不一定能暴露,但到了计算FIRST集和FOLLOW集阶段就会变成莫名其妙的“垃圾集合”。
所以我强烈建议在load_from_file成功后加一个自检函数,做以下几件事:
- 检查
non_terminals里面是否存在一个非终结符,它没有出现在任何产生式的左部(这种符号叫“无定义”或“悬空”符号,容易在求FOLLOW集时漏掉)。 - 检查是否存在没有出现在任何产生式右部的非终结符(不可达符号)。如果存在,从
non_terminals里剔除,避免计算FOLLOW集时对这些“孤立节点”做无效迭代。 - 检查是否存在产生式右部连续出现两个非终结符且中间没有终结符,这个在LL(1)文法下通常是合法的,但要检查是否会造成FIRST集冲突。
自检代码里可以输出一份文法摘要,包括:产生式数量、非终结符集合、终结符集合、开始符号。这一步能帮你快速核对文件是否被正确解析,减少后面排查的难度。实战中我建议你把摘要打印出来,哪怕只是简单的几行,也比出了bug再去翻日志要快得多。
3. FIRST集与FOLLOW集:LL(1)分析的两大基石
预测分析表之所以是“LL(1)”,核心就在于它基于两个集合:FIRST集和FOLLOW集。这两个集合的计算是整个项目里最绕、也最容易写错的部分。我会先用通俗的方式解释它们的意义,再给出C++实现的完整思路。
3.1 FIRST集怎么算:从右部第一个符号开始,逐层递推
FIRST集的定义很短:对任意文法符号串α,FIRST(α)是α能推导出的所有终结符开头的集合。如果α能推导出空串,那么空串#也属于FIRST(α)。听起来很简单,但计算非终结符A的FIRST集时,需要对所有以A为左部的产生式做迭代:
- 如果产生式右部以终结符开头,直接把这个终结符加入FIRST(A)。
- 如果右部以非终结符B开头,把FIRST(B)中除
#之外的所有符号加入FIRST(A);如果B能推出空串(即#在FIRST(B)中),则继续看右部下一个符号。 - 如果右部所有符号都能推出空串,则把
#加入FIRST(A)。
这个过程中最大的陷阱就是“环”。如果文法里有A -> B、B -> A这样的互相引用,或者A -> A b这种左递归,那么FIRST集的计算不是一次循环能收敛的。最稳妥的算法是使用一个循环,不断遍历所有产生式,直到所有非终结符的FIRST集合都不再变化为止。因为集合的大小是有限的(终结符数量有限),所以循环最多执行“非终结符数量 × 终结符数量”次就能收敛,理论上不会死循环。
cpp复制std::map<Symbol, std::set<Symbol>> Grammar::compute_first_sets() {
std::map<Symbol, std::set<Symbol>> first;
// 初始化:每个非终结符的FIRST集为空
for (const auto& nt : non_terminals) {
first[nt] = {};
}
bool changed = true;
while (changed) {
changed = false;
for (const auto& prod : productions) {
Symbol A = prod.lhs;
// 情况1:右部为空串(产生式 A -> #)
if (prod.rhs.size() == 1 && prod.rhs[0].name == "#") {
if (first[A].insert(prod.rhs[0]).second) changed = true;
continue;
}
// 情况2:右部非空,逐个扫描符号
bool all_nullable = true;
for (size_t i = 0; i < prod.rhs.size(); ++i) {
Symbol X = prod.rhs[i];
if (X.type == SymbolType::TERMINAL) {
if (first[A].insert(X).second) changed = true;
all_nullable = false;
break;
} else {
// 非终结符,把FIRST(X)中非#部分加入FIRST(A)
for (const auto& sym : first[X]) {
if (sym.name != "#") {
if (first[A].insert(sym).second) changed = true;
}
}
// 如果X不能推出空串,停止继续扫描
if (first[X].find(Symbol{"#", SymbolType::TERMINAL}) == first[X].end()) {
all_nullable = false;
break;
}
}
}
// 如果右部所有符号都能推出空串,则把#加入FIRST(A)
if (all_nullable) {
if (first[A].insert(Symbol{"#", SymbolType::TERMINAL}).second) changed = true;
}
}
}
return first;
}
这段代码的核心逻辑是两层循环:外层循环负责“迭代直到收敛”,内层循环遍历所有产生式逐一尝试扩充FIRST集。changed标志位是收敛判据,一旦某一次循环中没有任何集合发生变化,就认为计算完成。
你可能会问:为什么每次不是只处理一个产生式,而是把所有产生式都扫一遍?因为FIRST集合之间可能存在间接依赖,A的FIRST集依赖于B的FIRST集,B的FIRST集又依赖于A的FIRST集,只有通过多轮扫描才能让信息在所有非终结符之间“传播”开来。这就好比在一个社交网络里传播消息,最开始每个人只知道自己的信息,经过一轮一轮的传播,最终所有人都知道全部信息。
3.2 FOLLOW集怎么算:从开始符号出发,跟着产生式右部追踪
FOLLOW集的定义是:对非终结符A,FOLLOW(A)是所有句型中可能紧跟A之后的终结符集合。特别规定:如果A是开始符号,则$(输入结束标记)属于FOLLOW(A)。计算规则是:
- 首先把
$加入FOLLOW(开始符号)。 - 对形如
A -> αBβ的产生式,把FIRST(β)中除#之外的所有符号加入FOLLOW(B)。 - 如果β能推出空串(即
#在FIRST(β)中),那么把FOLLOW(A)的所有符号加入FOLLOW(B)。 - 对形如
A -> αB的产生式(即B在产生式右部的末尾),把FOLLOW(A)的所有符号加入FOLLOW(B)。
这里的难点还是“依赖传播”。FOLLOW(B)可能依赖于FOLLOW(A),而FOLLOW(A)又可能依赖于FOLLOW(B)。最典型的例子是A -> B和B -> A,它们的FOLLOW集会互相包含。所以FOLLOW集的计算同样要用迭代到收敛的算法,而且必须在FIRST集全部计算完毕之后再进行,因为规则二和三都依赖FIRST集合。
cpp复制std::map<Symbol, std::set<Symbol>> Grammar::compute_follow_sets(
const std::map<Symbol, std::set<Symbol>>& first) {
std::map<Symbol, std::set<Symbol>> follow;
for (const auto& nt : non_terminals) {
follow[nt] = {};
}
// 规则0:$ 加入开始符号的FOLLOW集
follow[start_symbol].insert(Symbol{"$", SymbolType::TERMINAL});
bool changed = true;
while (changed) {
changed = false;
for (const auto& prod : productions) {
Symbol A = prod.lhs;
for (size_t i = 0; i < prod.rhs.size(); ++i) {
Symbol B = prod.rhs[i];
if (B.type != SymbolType::NON_TERMINAL) continue;
// 计算 B 后面的符号串 beta = prod.rhs[i+1..end]
bool beta_nullable = true;
if (i + 1 >= prod.rhs.size()) {
// B 位于产生式末尾
beta_nullable = true;
} else {
for (size_t j = i + 1; j < prod.rhs.size(); ++j) {
Symbol Y = prod.rhs[j];
if (Y.type == SymbolType::TERMINAL) {
// beta 以终结符开头,FIRST(beta) 包含这个终结符
if (follow[B].insert(Y).second) changed = true;
beta_nullable = false;
break;
} else {
// 把 FIRST(Y) 中非#的部分加入 FOLLOW(B)
for (const auto& sym : first.at(Y)) {
if (sym.name != "#") {
if (follow[B].insert(sym).second) changed = true;
}
}
// 如果 Y 不能推出空串,停止
if (first.at(Y).find(Symbol{"#", SymbolType::TERMINAL}) == first.at(Y).end()) {
beta_nullable = false;
break;
}
}
}
}
// 如果 beta 能推出空串(或 B 在末尾),把 FOLLOW(A) 加入 FOLLOW(B)
if (beta_nullable) {
for (const auto& sym : follow[A]) {
if (follow[B].insert(sym).second) changed = true;
}
}
}
}
}
return follow;
}
这段实现里最需要注意的就是beta_nullable的计算。我见过很多同学的代码里直接判断“B是不是最后一个符号”,如果是就加入FOLLOW(A),但忽略了中间还有B -> C #这种情形。实际上只要B后面的所有符号都能推出空串,B就等价于在“末尾”,必须把FOLLOW(A)加进去。这块逻辑要仔细梳理,我建议在纸上把A -> B C和C -> #这种例子手推一遍,确认你的代码语义和手工推导结果一致。
另外,由于std::map::at在key不存在时会抛出异常,所以在调用compute_follow_sets之前,务必确保first集合里已经包含了所有非终结符。最保险的做法是在compute_first_sets里把所有非终结符都初始化成空集,而不是只初始化有产生式的非终结符。
4. 预测分析表构建:把FIRST/FOLLOW翻译成一张二维表
当FIRST集和FOLLOW集都拿到手,预测分析表的构建就是水到渠成的事了。表的结构是:行对应非终结符,列对应终结符(包括$输入结束符),表项M[A][a]存放产生式编号,表示当栈顶是非终结符A、当前输入符号是a时,应该用第几号产生式展开。
4.1 填表算法的两条核心规则
对每条产生式A -> α:
- 对
α的FIRST集中的每一个终结符a(不包括#),在M[A][a]中填入该产生式。 - 如果
#属于FIRST(α)(即α可以推导出空串),则对FOLLOW(A)中的每一个终结符b,在M[A][b]中填入该产生式。
这两条规则是LL(1)分析表构造的全部秘密。第一条对应正常展开,第二条对应“用空串匹配当前输入符号”的情况,本质上是告诉分析器:当我看到一个不在FIRST(α)中的输入符号时,如果α能空推,我可以选择“什么都不消费”,继续用后面的内容去匹配。如果同一个表项被两条产生式同时填入,那就是文法冲突,这个文法不是LL(1)的。
C++实现里,我建议用一个结构体ParseTableCell来封装表项,它能表示“空”“单条产生式”“冲突”三种状态:
cpp复制struct ParseTableCell {
bool isEmpty() const { return prod_ids.empty(); }
bool isConflict() const { return prod_ids.size() > 1; }
std::vector<int> prod_ids; // 填充的产生式编号,正常情况下最多1个
};
class PredictiveTable {
public:
std::map<Symbol, std::map<Symbol, ParseTableCell>> table;
void fill(int prod_id, const Symbol& row_nt, const Symbol& col_terminal) {
auto& cell = table[row_nt][col_terminal];
// 避免重复插入
if (std::find(cell.prod_ids.begin(), cell.prod_ids.end(), prod_id) == cell.prod_ids.end()) {
cell.prod_ids.push_back(prod_id);
}
}
};
用std::map嵌套来存储二维表,好处是不需要预知终结符的数量,代码也干净。如果追求极致性能,当然可以把符号映射成整数索引,用二维std::vector存储,但那种优化在几百个符号的文法规模下意义不大,反而让代码可读性变差,我建议优先保持简洁。
4.2 填表时如何同步处理冲突检测
填表代码本身很机械,麻烦的是冲突处理。一个优秀的实现应该在检测到冲突时,不仅仅报告“conflict”,还要打印出是哪个表项、涉及的几条产生式分别是什么。这样才能快速定位文法中的问题。
cpp复制bool PredictiveTable::build(const Grammar& grammar,
const std::map<Symbol, std::set<Symbol>>& first,
const std::map<Symbol, std::set<Symbol>>& follow) {
bool has_conflict = false;
for (const auto& prod : grammar.productions) {
// 计算 FIRST(prod.rhs)
std::set<Symbol> first_rhs;
bool nullable = compute_first_of_sequence(prod.rhs, first, first_rhs);
// 规则1:对 FIRST(α) 中每个终结符 a,填表
for (const auto& a : first_rhs) {
if (a.name == "#") continue;
this->fill(prod.id, prod.lhs, a);
}
// 规则2:如果 α 能推出空串,对 FOLLOW(A) 中每个 b,填表
if (nullable) {
auto it = follow.find(prod.lhs);
if (it != follow.end()) {
for (const auto& b : it->second) {
this->fill(prod.id, prod.lhs, b);
}
}
}
}
// 遍历每个表项,检查冲突
for (auto& row_entry : table) {
for (auto& col_entry : row_entry.second) {
auto& cell = col_entry.second;
if (cell.isConflict()) {
has_conflict = true;
std::cerr << "Conflict at M[" << row_entry.first.name
<< "][" << col_entry.first.name << "]: ";
for (int pid : cell.prod_ids) {
std::cerr << pid << " ";
}
std::cerr << std::endl;
}
}
}
return !has_conflict;
}
compute_first_of_sequence是一个辅助函数,给定一个符号序列,返回它的FIRST集以及这个序列是否可空(能否推出空串)。它做的事情其实和前面FIRST集计算里的内层逻辑是一样的:从左到右扫描,把每个符号的FIRST集中非#元素加入结果集,一旦遇到不能推出空串的符号就停止,最后如果所有符号都可空则整个序列可空。这个函数一定要独立出来,因为填表算法里要反复用到它。
这里还有一个细节:字符串$作为输入结束符,不能出现在文法文件里,而是由程序自动加入终结符集合和结束符号。所以在构建表的时候,除了把文法中显式的终结符作为列,还要额外把$作为一列加进表里,否则后续用预测分析器做句子分析时,读到$会直接查不到表项。
4.3 冲突检测后的处理策略
如果检测到冲突,程序不应该继续输出“预测分析表”,因为这张表根本无法用于LL(1)分析。正确做法是结束构建流程,报告冲突位置,并提示用户要么修改文法(提取左公因子、消除左递归),要么放弃LL(1)改用其他分析方技术。
我在实际写代码的时候,会自动给用户输出一条建议列表:
- 如果冲突是因为同一个非终结符的两条产生式FIRST集有交集,建议提取左公因子。
- 如果冲突是因为一条产生式可空,导致和另一条产生式的FIRST集重叠,要考虑改写文法,让可空产生式不会在错误时机被选中。
- 如果冲突大量出现且文法包含左递归,先消除左递归再回来填表。
这个自动提示不需要很智能,把可能的修复方向打印出来就够了。但对于使用者来说,这个提示往往比“conflict at M[E][+]”有用得多。
5. 完整流程串联:从命令行输入到可用的LL(1)分析表
上面各部分已经分别实现了,接下来就是把它们串成一个完整的程序。这个过程虽然简单,但整体协调很考验代码组织能力。
5.1 主流程和模块划分
我的程序主函数大概长这样:
cpp复制int main(int argc, char* argv[]) {
if (argc < 2) {
std::cerr << "Usage: " << argv[0] << " <grammar_file>" << std::endl;
return 1;
}
Grammar grammar;
if (!grammar.load_from_file(argv[1])) {
return 1;
}
std::cout << "Grammar loaded successfully." << std::endl;
std::cout << "Start symbol: " << grammar.start_symbol.name << std::endl;
std::cout << "Non-terminals: ";
for (const auto& nt : grammar.non_terminals) {
std::cout << nt.name << " ";
}
std::cout << std::endl;
std::cout << "Terminals: ";
for (const auto& t : grammar.terminals) {
std::cout << t.name << " ";
}
std::cout << std::endl;
auto first_sets = grammar.compute_first_sets();
std::cout << "\nFIRST sets:" << std::endl;
for (const auto& nt : grammar.non_terminals) {
std::cout << "FIRST(" << nt.name << ") = { ";
for (const auto& s : first_sets[nt]) {
std::cout << s.name << " ";
}
std::cout << "}" << std::endl;
}
auto follow_sets = grammar.compute_follow_sets(first_sets);
std::cout << "\nFOLLOW sets:" << std::endl;
for (const auto& nt : grammar.non_terminals) {
std::cout << "FOLLOW(" << nt.name << ") = { ";
for (const auto& s : follow_sets[nt]) {
std::cout << s.name << " ";
}
std::cout << "}" << std::endl;
}
PredictiveTable table;
bool ok = table.build(grammar, first_sets, follow_sets);
if (!ok) {
std::cerr << "Grammar is NOT LL(1). Fix conflicts first." << std::endl;
return 1;
}
table.print_table();
return 0;
}
这段主流程的另一个好处是,它天然地在每个阶段输出中间结果,方便你调试。比如你发现FIRST集算得不对,那就先别急着往下走,把compute_first_sets的逻辑修好,确认FIRST集都正确了再继续。这就是典型的“逐层验证、逐层推进”的开发方式,比一次写完所有功能再一起调试要轻松太多。
这里的print_table函数可以按行打印:
cpp复制void print_table() const {
std::cout << "\nLL(1) Predictive Parsing Table:" << std::endl;
for (const auto& row_entry : table) {
for (const auto& col_entry : row_entry.second) {
const auto& cell = col_entry.second;
if (!cell.isEmpty()) {
std::cout << "M[" << row_entry.first.name << "]["
<< col_entry.first.name << "] = ";
for (int pid : cell.prod_ids) {
std::cout << pid << " ";
}
std::cout << std::endl;
}
}
}
}
如果表项很多,这种打印方式比二维矩阵更清晰,因为大部分表项是空的,矩阵打印会刷屏。
5.2 验证表构建正确性的方法
表构建出来不意味着它一定是对的。我强烈建议你写一个小函数,用来“手工检查”几条关键表项。比如表达式文法E -> E + T | T在消除左递归后变成E -> T E'、E' -> + T E' | #,这时你应该验证M[E'][+]指向E' -> + T E',M[E'][$]和M[E'][)]指向E' -> #。把这些人工期望值写进测试用例,一旦表构建逻辑改动了,跑一遍测试立刻知道有没有回归。
真正能证明表正确的终极武器是写一个配套的预测分析器(LL(1) analyzer driver),输入一个句子,按照表和栈一步步展开,看最终能不能成功归约到开始符号。这个分析器实现起来也就是几十行代码,但因为是“端到端”的验证,它能发现填表逻辑里很多隐蔽的问题。比如我在测试时会输入id + id * id这样的算术表达式句子,如果分析通过且消耗完输入,说明表基本正确。
6. 常见问题与排查技巧实录
这部分是我最想分享的,因为我的课设和后续重构中,大量时间都花在了排查这些问题上。我把它们整理成一个速查表,每个问题都附上原因和解决办法。
| 问题现象 | 可能原因 | 排查方法 | 解决方案 |
|---|---|---|---|
| FIRST集计算后出现明显多算/少算 | 文法文件里有重复产生式,或符号名大小写搞混 | 打印所有产生式,检查右部符号是否按预期分类 | 统一符号命名规范,大写开头为非终结符; |
程序在compute_follow_sets中崩溃,map::at抛出异常 |
FIRST集里缺少某个非终结符的条目 | 在compute_first_sets里对所有非终结符初始化空集 |
不要只初始化有产生式的符号,要把集合里全部符号都初始化 |
| FOLLOW集迭代无法收敛,程序卡死 | 文法存在左递归,导致FOLLOW(A)依赖于自身且没有终止条件 | 在迭代循环里加最大迭代次数上限,打印每次变化量 | 先消除左递归,再重算FIRST/FOLLOW |
| 预测分析表出现大量冲突 | 文法不是LL(1),常见于可空产生式和普通产生式FIRST集重叠 | 打印冲突表项,对照产生式编号,手工推导FIRST集和FOLLOW集 | 提取左公因子、改写可空产生式,或换用LR分析 |
文法文件里有id,但程序把它当成了非终结符 |
符号分类算法把字母开头的都当非终结符了 | 检查make_symbol逻辑 |
调整分类条件:只有大写字母或尖括号包裹才算非终结符 |
表里多了一列# |
把空串标记#错误地加进了终结符集合 |
打印终结符集合,确认#不在里面 |
在建表阶段主动跳过#,只把它当空串标记 |
| 输入文件是UTF-8 BOM格式,第一行读取异常 | Windows记事本自动加了BOM头 | 用二进制方式打开文件,检查开头三个字节 | 打开文件后用\xEF\xBB\xBF判断并跳过,或改用ASCII保存 |
6.1 最容易被忽视的“空串终结符”陷阱
这个问题我想再多说两句。很多教科书在定义文法时默认空串用ε表示,但在电脑里输入ε很麻烦,所以大家都习惯用#或@代替。问题在于,有些同学的代码里把#当成了一个“特殊终结符”,加进了terminals集合。结果在构建预测分析表时,表里多了一列#,然后在做实际句子分析时,输入串里永远不会出现#,这一列完全没用;更糟的是,某些LL(1)冲突会被这一列掩盖,因为本来应该报冲突的表项被空串处理逻辑绕过去了。
正确做法是:#只作为产生式右部的特殊标记,只在计算FIRST集和判断可空性时使用,永远不进入terminals集合。等填表阶段,#自动转换成“这条产生式可空”的条件分支,而不是一个真正的表格列。这样代码逻辑才干净。
6.2 调试FIRST/FOLLOW集的实用技巧
如果你手算的FIRST集和程序算出来的不一致,不要直接去看代码,先缩小范围。我的做法是构造几个最小文法,比如A -> a,A -> B b,B -> #,分别运行,确认每个阶段输出是否符合预期。这样做比直接debug一个十几个产生式的文法要快得多,因为你能快速定位是“判断符号类型的逻辑”出了问题,还是“可空判断”出了问题。
还有一个技巧:在迭代算法里临时加一个计数器,如果循环超过non_terminals.size() * terminals.size() + 10次还没有收敛,直接报错退出。这样可以防止因为文法写错(比如某产生式右部引用了未定义的非终结符)导致死循环。虽然后面加入的符号分类检查能提前发现这类问题,但加个保险总是好的。
6.3 从课程作业到可复用工具:我的一点经验
做这个项目的时候,我一开始只是为了完成作业,但后来把它封装成了一个小的命令行工具,可以接受任意LL(1)文法文件,输出预测分析表和FIRST/FOLLOW集,还能通过额外参数选择是否生成可读的文本报告。这个过程中最大的收获是:把“能跑”变成“好用”,需要做很多细节打磨,比如优雅的错误提示、合理的模块划分、可复用的数据结构。
如果你也想把这个做深,我建议在完成基础功能后,再增加一个“预测分析器”模块,这样整个程序就变成了一个真正可用的LL(1)语法分析器前端。分析器的实现就是教科书里的经典算法:一个栈、一张表、一个输入缓冲区,再加上一些错误恢复策略。这部分代码不多,但能让你的项目从“算了张表”升级成“能分析句子”,成就感完全不一样。
最后再分享一个小技巧:程序里的输出建议分两个层级,正常运行只输出必要信息,调试时加上-v参数打印所有中间集合。这能让你在不污染正常输出的前提下,随时切换到调试模式。我用这个方式在重构时少走了很多弯路。
