最近给学生改编译原理课设,发现一个有意思的现象:大家能背出LL(1)分析表的构造步骤,但真要自己写一个程序,把文法文件读进去、算出FIRST集和FOLLOW集、最后在终端打印出一张预测分析表,很多人就直接卡在数据结构设计那一步了。这篇文章是我自己实现“从文法文件到LL1预测分析表C++实现”时的完整记录,内容包括文法文件格式约定、FIRST/FOLLOW集的不动点迭代计算、预测分析表的构建与冲突检测,以及一个表驱动分析器的验证思路。无论你是正在做编译原理课程设计,还是想给自己的脚本语言手写一个前端解析器,下面的实现思路和关键代码都可以直接参考。
1. 为什么要把文法文件变成一张预测分析表
1.1 这个项目解决的到底是什么问题
很多人第一次接触LL(1)分析时,以为目标就是“判断一个文法是不是LL(1)文法”。实际上,课程设计和工程实践里,更常见的需求是:给定一份文法文件,程序自动分析这个文法的结构,计算FIRST集和FOLLOW集,最终产出一张“非终结符 × 终结符 → 产生式”的预测分析表。这张表才是后续递归下降分析器或者表驱动分析器的核心输入。
换句话说,预测分析表本质上是把文法中蕴含的语法规则,转换成一个二维查表结构。程序在分析输入串时,遇到非终结符X和当前输入符号a,只需要查一下M[X][a],就知道应该用哪个产生式展开。这个查表操作是O(1)的,所以整个分析过程效率非常高,这也是LL(1)分析在工程上仍有生命力的原因。
1.2 用哪个经典文法贯穿全文
后面所有代码和调试过程,我统一用下面这个经典表达式文法作为测试用例:
code复制E -> T E'
E' -> + T E' | ε
T -> F T'
T' -> * F T' | ε
F -> ( E ) | id
这个文法几乎出现在所有编译原理教材里,网上也能找到大量对照资料。用它做测试有个好处:如果程序算出的FIRST/FOLLOW集和教材一致,那基本可以确认实现没有结构性错误;如果不一致,也方便对照排查。整个工程我用VS Code配置的C++环境开发,标准是C++17,没有使用任何第三方依赖库,拿任何一个主流编译器都能直接编译运行。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 文法文件的格式设计:容易被低估的第一步
2.1 文件格式的约定
最先要解决的是文法文件长什么样。这步看起来简单,实际很关键,因为格式决定了后续所有解析代码的复杂度。我采用的格式约定如下:
- 每个产生式单独占一行,格式为
A -> α β γ - 左部是非终结符,右部是符号序列,符号之间用空格分隔
- 支持
|表示同一个非终结符的多个候选式,例如E' -> + T E' | ε - 空串固定用
ε表示 - 以
#开头的行是注释,程序直接跳过
为什么要求符号之间必须用空格分隔?因为很多文法符号是多字符的,比如 ID、NUM、WHILE,如果不用空格,程序无法从 IDNUM 这串字符里准确切分出两个符号。如果你在做实验时把终结符设计成单个字符大小写,那确实可以不用空格,但一旦遇到多字符符号,这种设计就直接崩了。
2.2 文法的C++数据结构表示
接下来是数据结构的选择。常见的做法是用 char 表示符号,但我在项目一开始就放弃了。原因很简单:工程里的词法单元(token)往往不只是 a、b 这种单字符,而是 int、while、id 这样的字符串。所以符号一律用 std::string 表示,终结符和非终结符统一处理,只在判定时区分身份。
整个文法的存储结构我设计成这个样子:
cpp复制struct Grammar {
std::string startSymbol; // 文法开始符号
std::vector<std::string> nonTerminals; // 非终结符列表
std::vector<std::string> terminals; // 终结符列表
std::map<std::string, std::vector<std::vector<std::string>>> productions;
// productions[A] 返回 A -> α1 | α2 | ... 的候选式列表
// 每个候选式是一个 vector<string>,比如 {"+", "T", "E'"}
};
这里的关键设计是用 map<string, vector<vector<string>>> 作为产生式的存储结构,外层key是非终结符,value是一个二维数组。每个内层 vector<string> 对应一个候选式。为什么不用更简单的 vector<Production> 把所有产生式平铺?因为后面计算FIRST/FOLLOW时,频繁需要“取出某个非终结符的所有候选式”,用map按左部索引显然效率更高,代码也更清晰。
2.3 文件读入与符号判定
读取文件的代码并不复杂,但有几个细节值得注意。首先我建议用 std::getline 按行读取,然后逐行解析。在解析一行时,先找到 -> 分隔符,左边是左部非终结符,右边按 | 再按空格切分。注释行要提前过滤掉。
还有一个非常实际的坑:文法文件如果保存成 UTF-8 with BOM 格式,getline 读到的第一行字符串开头会有一个不可见的 \xEF\xBB\xBF,直接导致第一个非终结符的名字带上脏数据,后面所有匹配都会失败。解决办法是在读取第一行后手动跳过BOM,或者保存文件时统一用UTF-8无BOM编码。
非终结符和终结符的判定逻辑,我没有采用“大写字母开头就是非终结符”这种硬编码方式,而是采用动态判定:
- 所有产生式左部出现的符号,都是非终结符
- 出现在右部、且不是左部符号、也不是
ε的,就是终结符 - 第一个产生式的左部就是开始符号
这种判定方式的好处是文法符号命名非常自由,Expr、stmt、IF 都可以作为符号名,不会因为大小写规则不同而误判。
3. 前置检查:左递归、左因子与LL(1)边界
3.1 左递归检测
LL(1)文法有一个硬性前提:不能含左递归。如果文法里有 E -> E + T 这种直接左递归,构造的FIRST集会出现冲突,预测分析表里同一个格子会塞进多条产生式,分析器根本无法决策。
所以在进入计算流程之前,程序必须先做左递归检测。检测思路很直接:对于每个非终结符A,看它的候选式右部第一个符号是不是A自己。如果存在,就是直接左递归。间接左递归需要构造依赖图检测环,但工程上常见的左递归基本都是直接左递归,所以代码先处理这种情况:
cpp复制bool hasDirectLeftRecursion(const Grammar& g) {
for (const auto& [nt, candidates] : g.productions) {
for (const auto& candidate : candidates) {
if (!candidate.empty() && candidate[0] == nt) {
std::cout << "检测到直接左递归: " << nt << std::endl;
return true;
}
}
}
return false;
}
3.2 提取左因子:一个值得提醒的边界
提取左因子和消除左递归一样,是LL(1)化的重要预处理步骤。不过在这个项目里,我选择不自动做这些重写,而是直接把检测结果报告给用户,让用户自行修改文法。原因是自动消除左递归和提取左因子会改写产生式,改写后的非终结符名字需要重新生成,否则会产生命名冲突。这个过程一旦自动化,问题复杂度会上升很多,但带来的收益对“计算预测分析表”这个核心目标并不大。
我的实际建议是:代码里保留检测逻辑,遇到左递归或公因子时报错退出,并提示用户手工改写文法。这样项目的边界清晰,主流程代码也更稳。如果你确实需要自动化解法,可以另外写一个预处理阶段,先消除左递归、再提取左因子,然后把改写后的文法作为新的Grammar对象传入。
另外提醒一句:即使文法没有左递归、没有公因子,也仍然可能不是LL(1)文法。最典型的例子是 if E then S | if E then S else S,这就是悬空else问题。这种冲突只能在填表阶段暴露出来,所以前置检查只能拦截一部分问题,最终还是要以第5节的冲突检测结果为准。
4. FIRST集和FOLLOW集:不动点迭代的C++实现
4.1 FIRST集的计算框架
FIRST集是整个流程的第一步,也是后续所有计算的基础。它的定义很简洁:对于任意文法符号串α,FIRST(α)是从α能推导出的所有终结符开头的集合;如果α能推导出空串,则ε也在FIRST(α)中。
计算FIRST集时,真正需要算的是非终结符的FIRST集,终结符的FIRST就是自身。迭代算法的框架是:初始化所有非终结符的FIRST为空集,然后不断遍历所有产生式,把新元素加入对应集合,直到所有集合都不再变化。
关键代码片段如下:
cpp复制std::map<std::string, std::set<std::string>> computeFIRST(const Grammar& g) {
std::map<std::string, std::set<std::string>> first;
bool changed = true;
while (changed) {
changed = false;
for (const auto& [nt, candidates] : g.productions) {
for (const auto& candidate : candidates) {
auto before = first[nt].size();
bool allEpsilon = true;
for (const auto& sym : candidate) {
if (isTerminal(g, sym)) {
first[nt].insert(sym);
allEpsilon = false;
break;
}
// 非终结符:把 FIRST[sym] 中除了 ε 之外的元素加入
bool hasEpsilon = false;
for (const auto& s : first[sym]) {
if (s == EPSILON) hasEpsilon = true;
else first[nt].insert(s);
}
if (!hasEpsilon) {
allEpsilon = false;
break;
}
// 如果 FIRST[sym] 含 ε,继续看下一个符号
}
if (allEpsilon) first[nt].insert(EPSILON);
if (first[nt].size() != before) changed = true;
}
}
}
return first;
}
4.2 FOLLOW集的计算框架
FOLLOW集的定义是:在推导过程中,紧跟某个非终结符A之后可能出现的终结符集合。特别地,如果A是开始符号,则 $(输入结束符)属于FOLLOW(A)。
FOLLOW集的计算比FIRST集容易出错,因为它牵扯到产生式右部多个位置的相对关系。规则可以归纳为三条:
- 对于开始符号S,把
$加入FOLLOW(S) - 对于形如
A -> α B β的产生式,把 FIRST(β) 中除 ε 之外的所有符号加入 FOLLOW(B) - 对于形如
A -> α B的产生式,以及A -> α B β且 β 能推导出 ε 的情况,把 FOLLOW(A) 中的所有符号加入 FOLLOW(B)
实现时我依然采用不动点迭代,直到所有FOLLOW集不再变化为止。核心代码:
cpp复制std::map<std::string, std::set<std::string>> computeFOLLOW(const Grammar& g,
const std::map<std::string, std::set<std::string>>& first) {
std::map<std::string, std::set<std::string>> follow;
follow[g.startSymbol].insert("$");
bool changed = true;
while (changed) {
changed = false;
for (const auto& [nt, candidates] : g.productions) {
for (const auto& candidate : candidates) {
for (size_t i = 0; i < candidate.size(); ++i) {
const std::string& B = candidate[i];
if (isTerminal(g, B)) continue;
auto before = follow[B].size();
// 计算右部 B 后面剩余串的 FIRST(含ε判断)
bool restNullable = true;
for (size_t j = i + 1; j < candidate.size(); ++j) {
const std::string& beta = candidate[j];
if (isTerminal(g, beta)) {
follow[B].insert(beta);
restNullable = false;
break;
} else {
bool hasEps = false;
for (const auto& s : first.at(beta)) {
if (s == EPSILON) hasEps = true;
else follow[B].insert(s);
}
if (!hasEps) { restNullable = false; break; }
}
}
if (restNullable) {
// B 是产生式右部最后一个符号,或后面部分可空
for (const auto& s : follow[nt]) follow[B].insert(s);
}
if (follow[B].size() != before) changed = true;
}
}
}
}
return follow;
}
4.3 为什么坚持用不动点迭代
很多教材上介绍的是同时扫描一遍产生式,从右往左推导FOLLOW集,一次就能算完。但实际写代码时,这种单趟扫描很容易漏算,因为FOLLOW[A]可能依赖FOLLOW[B],而FOLLOW[B]又是在后续产生式里才出现的。
比如文法 A -> B C 和 C -> x,计算FOLLOW[A]时如果还没有算出FOLLOW[A]本身,那FOLLOW[B]就没办法从 A -> B C 和 C -> ε 中完整推导。不动点迭代的好处是彻底绕开了“谁依赖谁”的问题,每一轮把能加的集合都加进去,直到没有变化,算法必定收敛。缺点是多跑几轮循环,但文法的产生式数量通常只有几十条,性能完全不是瓶颈。
我调试时还有个习惯:在每一轮迭代结束后打印所有集合的当前状态。这样做能直观看到FIRST/FOLLOW集合逐步膨胀到最终稳定的过程,排查循环依赖类问题非常有效。
5. 预测分析表的构建与冲突检测
5.1 表的数据结构设计
预测分析表本身用嵌套map表示:
cpp复制std::map<std::string, std::map<std::string, std::vector<std::string>>> table;
// table[非终结符][终结符] = 产生式候选(右部符号序列)
用 map 而不是 std::unordered_map 的原因很简单:map按键排序,打印表格时能按字母顺序输出,调试友好。这个表相当于一个二维矩阵,行是非终结符,列是终结符,每个格子存放“当前应该使用的产生式右部”。
5.2 填表核心算法
建表的算法来自LL(1)分析表的标准构造方法。对每个产生式 A -> α,执行以下两步:
- 对每个终结符a属于FIRST(α),把
A -> α填入M[A][a] - 如果 ε 属于 FIRST(α),则对每个终结符b属于FOLLOW(A),把
A -> α填入M[A][b]
这里有一个需要仔细想的细节:填表前需要计算FIRST(α),即整个候选式α的FIRST集,而不只是左部A的FIRST集。计算FIRST(α)和计算FIRST(A)的逻辑类似,顺着候选式从左往右扫描,直到遇到终结符或遇到FIRST集不包含ε的非终结符为止。如果整个候选式可空,那么FIRST(α)包含ε。
核心填表代码:
cpp复制void buildTable(const Grammar& g,
const std::map<std::string, std::set<std::string>>& first,
const std::map<std::string, std::set<std::string>>& follow,
std::map<std::string, std::map<std::string, std::vector<std::string>>>& table) {
for (const auto& [nt, candidates] : g.productions) {
for (const auto& candidate : candidates) {
// 步骤1:计算 FIRST(candidate)
std::set<std::string> fc = firstOfSequence(candidate, g, first);
for (const auto& a : fc) {
if (a != EPSILON) {
table[nt][a].push_back(vectorToString(candidate));
}
}
// 步骤2:若候选式可空,把 FOLLOW[nt] 中的终结符加进来
if (fc.count(EPSILON)) {
for (const auto& b : follow.at(nt)) {
table[nt][b].push_back(vectorToString(candidate));
}
}
}
}
}
5.3 冲突检测:一张表能不能用,就看这一步
填完表之后,要逐格检查是否有多条产生式被填进同一个格子。只要存在 M[A][a] 中候选式数量大于1,就说明文法不是LL(1)文法,这张表不能被分析器使用。
检测代码很直观:
cpp复制bool checkLL1(const std::map<std::string, std::map<std::string, std::vector<std::string>>>& table) {
bool ok = true;
for (const auto& [nt, row] : table) {
for (const auto& [term, prods] : row) {
if (prods.size() > 1) {
std::cout << "冲突: M[" << nt << "][" << term << "] 有 "
<< prods.size() << " 个候选式" << std::endl;
ok = false;
}
}
}
return ok;
}
冲突是LL(1)分析最核心的风险点。出现冲突时,程序光报错还不够,最好能把具体冲突的产生式打印出来。比如悬空else文法会在 M[Stmt][else] 格子同时放入两条产生式,看到这个输出,你就知道问题出在哪个非终结符的哪个选择上。
6. 用一个表驱动分析器验证整条链路
6.1 表驱动分析器主体
预测分析表构建完成后,空有一张表不验证等于白做。我写了一个简化的表驱动分析器,输入是token序列,输出是该句子能否被接受。分析器的标准流程是维护一个符号栈和一个输入指针:
cpp复制bool parseWithTable(const Grammar& g,
const std::vector<std::string>& tokens,
const std::map<std::string, std::map<std::string, std::vector<std::string>>>& table) {
std::stack<std::string> st;
st.push("$");
st.push(g.startSymbol);
size_t ip = 0;
while (!st.empty()) {
std::string X = st.top();
std::string a = tokens[ip];
if (X == "$" && a == "$") return true;
if (isTerminal(g, X) || X == "$") {
if (X == a) { st.pop(); ip++; }
else return false;
} else {
if (table.count(X) && table.at(X).count(a) && !table.at(X).at(a).empty()) {
st.pop();
const std::string& prod = table.at(X).at(a)[0];
// 注意逆序压栈
auto symbols = splitProduction(prod);
if (!(symbols.size() == 1 && symbols[0] == EPSILON)) {
for (auto it = symbols.rbegin(); it != symbols.rend(); ++it) {
st.push(*it);
}
}
} else {
return false;
}
}
}
return true;
}
这段代码里有几个容易错的地方。第一个是压栈顺序:产生式右部是从左到右读的,但栈是后进先出,所以要把右部符号逆序压栈,才能保证弹出顺序和原产生式一致。第二个是遇到 ε 产生式时,右部直接入空集,即不需要压入任何符号。第三是如果查表发现某个格子是空的,直接返回false,代表输入串不是合法句子。
6.2 用表达式文法做端到端验证
我拿最开始的表达式文法来做验证。输入token序列为 id + id * id $,期望输出是接受,因为这是文法能推导出的合法算术表达式。实际操作步骤如下:
- 程序读入文法文件
- 计算FIRST集和FOLLOW集,打印出来对照教材
- 构建预测分析表
- 冲突检测通过(
checkLL1返回true) - 用分析器跑输入串,返回true
如果上述步骤全部通过,说明整个链路是通的。我在项目里还加入了每一步的时间统计,发现读取文法、计算FIRST/FOLLOW、建表整个过程用时都远小于1毫秒,性能上完全不用操心。
6.3 非LL(1)文法会出现什么情况
为了测试冲突检测是否有效,我把文法改成含左因子的版本:
code复制S -> if E then S | if E then S else S
运行后,程序在填表阶段检测到 M[S][if] 出现两条候选式,直接输出冲突警告。这条测试用例非常能说明问题:文法本身没有左递归、没有公因子,但依然不是LL(1)文法,因为两条候选式的FIRST集都包含 if,无法通过预读一个符号来区分。
这类文法的正确处理方式是提取左因子后进行文法重写,或者改用LR(1)分析。亲手跑一遍这个例子,你对LL(1)局限性的理解会比背任何理论都深刻。
7. 调试文法的实用技巧与常见坑
7.1 常用调试手段
在整个项目实现中,我踩过不少坑,这里分享几个最实用的调试手段。
第一个是集合打印函数。FIRST集和FOLLOW集计算完后,用格式化输出打印所有集合,不要只看最终值,多看看中间迭代过程。我在每一轮while循环结束后加了打印,效果类似:
code复制--- 第 1 轮迭代 ---
FIRST(E) = { }
FIRST(E') = { +, ε }
FIRST(T) = { ( , id }
...
通过观察集合从空集逐步增长的过程,能快速定位是哪个产生式没有被正确处理。
第二个是候选式可空判断的调试。计算FOLLOW集时最容易出错的就是 restNullable 分支。我建议把“当前非终结符后面剩余串是否可空”这个中间结果打印出来,因为这个布尔值直接决定是否执行 follow[B].insert(follow[A])。很多FOLLOW集算错都是因为这里少算了一条路径。
第三个是小规模测试优先。不要一上来就跑几十条产生式的大文法。先从 3~5 条产生式的微型文法开始,手工验证FIRST/FOLLOW之后,再逐步加大文法规模。我的做法是准备三个测试文件:test1.txt(简单文法)、test2.txt(经典表达式文法)、test3.txt(故意带冲突的文法)。跑完三个文件,基本能覆盖全部功能路径。
7.2 几个容易反复踩的坑
第一个坑是BOM问题,前面说过,文件保存时务必选UTF-8无BOM。这个问题在网上被问了无数次,因为报错非常隐蔽:第一行的符号名多一个不可见字符,后续所有匹配全部失败,但打印出来看起来又正常。
第二个坑是产生式右部的“空”表示不一致。有些教材用 ε,有些用 @,有些用 epsilon,还有用空串的。程序里必须统一成一个常量,我建议用 EPSILON 宏:
cpp复制const std::string EPSILON = "ε";
注意读入文件后要把右侧的 ε 统一替换成这个常量,不能出现文件里是 epsilon,程序里却是 ε 的情况。
第三个坑是FIRST集里是否要保存ε。我的做法是FIRST集保存ε,但在填表时把ε单独处理,不直接作为终结符列。这个细节非常重要。如果把ε当成普通终结符塞进预测分析表,会出现一个名为 ε 的列,整个表结构就全乱了。
第四个坑是标准库API的选择。C++的 std::set 自带 count 方法,所以判断元素是否存在可以写成 first[sym].count(EPSILON),代码简洁不少。另外遍历map时用结构化绑定(C++17特性)能省很多代码量,我在上面的代码里也使用了这个特性。
7.3 后续可以怎么扩展
预测分析表到手后,往下的扩展方向其实很清晰。最直接的是完善错误恢复机制:分析器遇到不匹配的token时,可以通过跳过输入符号或者弹出栈顶的方式恢复分析,而不是直接终止。这个功能在真实编译器中很重要,因为编译器要尽量报出所有语法错误,遇到一个错误就停反而会掩盖后面的问题。
另一个方向是把空白符、注释等词法层面的内容整合进来。目前的分析器输入是已经切分好的token数组,真正接上词法扫描器就形成了一个完整的语法分析前端。再往后就是语义分析、中间代码生成这些阶段了。
还有个偏工程的方向:把预测分析表可视化输出成HTML页面或者CSV文件。我用CSV导出过表格,直接在文档里插入作为课设报告,效果比截图好得多。原理就是把 table[nt][term] 逐个写入CSV文件,注意处理分隔符转义就行。
最后说一点个人体会。编译原理这门课,很多知识点停留在纸面上时总觉得抽象,但亲手把文法文件一路推进到预测分析表,你会发现LL(1)分析背后的核心逻辑其实非常朴素:把所有可能的推导选择提前算好,做成一张查表结构。真正难的不是某个算法本身,而是如何设计出清晰的数据结构,把几十行C++代码组织成一个可靠的整体。这套实现跑通之后,再看递归下降分析、LR分析这些内容,思路会开阔很多。
