最近给学生改编译原理课设,发现一个有意思的现象:大家能背出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集会出现冲突,预测分析表里同一个格子会塞进多条产生式,分析器根本无法决策。
所以在进入计算流程之前,程序必须先做左递归检测。检
