简介:这份资源面向正在学习编译原理、需要完成语法分析实验的高校学生,聚焦递归子程序法这一经典实现思路。内容围绕给定文法设计并实现语法分析程序展开,要求基于词法分析识别出的单词进一步判定各类语法成分,并按规则输出单词信息与语法成分标记,适合作为课程实验的参考实现与对照样例。压缩包共2个文件,包含1个cpp源码文件与1个doc实验说明文档,整体约17KB,源码可直接编译运行,文档则交代问题背景与输入输出约定,便于快速理解实验要求。该实现在CG实验平台上满分通过,已有6110人学习,读者可据此对照自己的文法规则检查递归子程序划分是否合理、预读处理与输出格式是否符合评测要求,并借助源码梳理各语法成分的分析顺序与边界条件,减少调试中的反复试错。
1. 语法分析在编译流程里的真实位置:一张地图先给
很多人学编译原理,把词法分析、语法分析、语义分析当成三个并列的盒子,背完 LL(1) 和 LR(1) 的表格就以为掌握了。真正动手写一个 C++ 版的语法分析实验才会发现,语法分析其实是整条编译链里承上启下的那根轴:上游吃的是词法分析吐出的 token 流,下游要交给语义分析和中间代码生成,它自己则要在「能识别」和「识别得快、报错准」之间做取舍。这份实验资源围绕的就是这根轴——用 C++ 把 token 流读进来,构造分析表或递归下降程序,判断输入串是否属于某文法,并在出错时给出位置。梳理下来会发现,语法分析实验的难点从来不是背概念,而是把「文法 → 分析表 → 驱动程序 → 错误恢复」这条链在代码里跑通。它适合正在做课程实验、准备考试、或者想补上「看得懂伪代码但写不出能跑的 parser」这块短板的人。
2. 文法与 FIRST/FOLLOW 集:分析表的全部地基
语法分析实验里,凡是分析表算错、驱动程序跑飞的情况,九成能回溯到 FIRST/FOLLOW 集算错。这一章把地基打牢,后面 LL(1) 和 LR 才不会塌。
2.1 从文法到符号分类:终结符、非终结符与产生式
实验的第一步永远是把文法读进来。一个文法由四元组构成:非终结符集合、终结符集合、产生式集合、开始符号。在 C++ 实现里,通常用map<string, vector<vector<string>>>存产生式,键是非终结符,值是一组右部符号序列。这里最容易翻车的是符号的「身份判定」:大写开头约定为非终结符、小写和符号为终结符只是书写习惯,程序里必须显式维护两个集合,不能靠首字母猜。空产生式(ε)要单独用特殊标记表示,比如空串""或"epsilon",否则后续求 FIRST 集时无法区分「推导出空」和「没有产生式」。
梳理下来,一个能支撑后续所有算法的数据结构至少要有三样:产生式表、非终结符到编号的映射、终结符到编号的映射。编号化是为了后面用二维数组存分析表,table[非终结符编号][终结符编号]直接下标访问,比字符串查找快一个量级,也避免哈希顺序带来的玄学问题。
2.2 FIRST 与 FOLLOW 的迭代求法:不动点才是正解
FIRST 集的定义是「某符号串能推导出的所有可能的开头终结符」,FOLLOW 集是「某非终结符后面可能紧跟的终结符」。教科书给的递归定义看着清楚,直接翻译成递归函数却会死循环——因为文法里存在相互依赖,A 的 FIRST 依赖 B,B 又依赖 A。正确做法是迭代到不动点:
// 反复扫描所有产生式,直到某一轮没有任何集合发生变化 bool changed = true; while (changed) { changed = false; for (auto& prod : productions) { // prod: A -> X1 X2 ... Xn string A = prod.lhs; for (size_t i = 0; i < prod.rhs.size(); ++i) { string X = prod.rhs[i]; // 把 FIRST(X) 中除 ε 外的符号并入 FIRST(A) for (auto& t : first[X]) { if (t != EPSILON && first[A].insert(t).second) changed = true; } // 若 X 不能推出 ε,则停止向后看 if (!first[X].count(EPSILON)) break; // 若整个右部都能推出 ε,则 ε 也属于 FIRST(A) if (i == prod.rhs.size() - 1 && first[A].insert(EPSILON).second) changed = true; } } }这段代码的关键在changed标志:只要某一轮还有集合增长,就再扫一遍,直到稳定。参数上,EPSILON是一个不会与真实终结符冲突的哨兵值,first用map<string, set<string>>存,insert().second返回是否真的插入了新元素,正好用来判断变化。FOLLOW 集同理,只是规则多两条:开始符号的 FOLLOW 含结束符$;若 B → αAβ 且 β 能推出 ε,则 FOLLOW(B) 并入 FOLLOW(A)。失败时先看是不是漏了「β 全空才并入 FOLLOW」这个条件,这是最常见的错。
2.3 分析表构造:把集合翻译成二维表
有了 FIRST/FOLLOW,LL(1) 分析表的填法就机械了:对每条产生式 A → α,对 FIRST(α) 中每个终结符 a,令M[A][a] = A → α;若 ε ∈ FIRST(α),则对 FOLLOW(A) 中每个 b,令M[A][b] = A → α。填表时如果同一个格子被填了两次,说明文法不是 LL(1),这正是实验里检验文法性质的时刻,别急着改代码,先确认文法本身是否左递归或含公共左因子。
提示:左递归文法一定不是 LL(1),必须先消除左递归、提取左公因子,再求集合。这一步顺序错了,后面全白算。
3. LL(1) 与 LR 的取舍:两种驱动程序的写法与重点
集合算对只是准备,真正体现「语法分析」的是驱动程序。LL(1) 和 LR 是两条路线,实验里通常二选一或都做,理解它们的差别比背表更重要。
3.1 预测分析程序:显式栈 + 查表循环
LL(1) 的驱动程序本质是一个带栈的循环:栈里放符号,初始压入开始符号和结束符;每次看栈顶和当前输入 token,查表决定展开哪条产生式或匹配终结符。
stack<string> st; st.push("$"); st.push(startSymbol); size_t pos = 0; // 输入 token 流下标 while (!st.empty()) { string top = st.top(); string cur = tokens[pos]; // 当前输入符号 if (top == "$" && cur == "$") break; // 接受 if (isTerminal(top)) { if (top == cur) { st.pop(); ++pos; } // 匹配,双双前进 else { error(pos, top, cur); break; } // 终结符不匹配 } else { auto rule = table[top][cur]; // 查分析表 if (rule.empty()) { error(pos, top, cur); break; } st.pop(); // 右部逆序入栈,保证最左符号在栈顶 for (auto it = rule.rbegin(); it != rule.rend(); ++it) if (*it != EPSILON) st.push(*it); } }重点在「逆序入栈」:产生式右部是从左到右推导的,但栈是后进先出,所以要把右部反过来压,才能让最左符号先被处理。这个细节写反了,程序能跑但结果全错,属于典型的血泪经验。参数上pos是输入游标,error函数至少打印出错位置、栈顶符号和当前符号,方便定位。
3.2 LR 分析:状态栈与归约动作
LR 走的是自底向上路线,核心是状态栈加分析表(ACTION 和 GOTO 两张)。ACTION 表项分移进(shift)、归约(reduce)、接受(accept)、报错四种;GOTO 表在归约后决定新状态。驱动程序循环读栈顶状态和当前 token,查 ACTION:移进就把 token 和新状态压栈;归约就按产生式弹出相应数量的状态和符号,再用 GOTO 查新状态压栈。
LR 的重点难点在项目集规范族的构造:从增广文法开始,求闭包(closure)、求转移(goto),反复直到状态集不再增长。闭包运算里,对A → α·Bβ且 B 有产生式,要把B → ·γ加进来,这个「点后是非终结符就展开」的规则漏一条,状态机就残缺。相比 LL(1),LR 能处理的文法范围大得多,但构造过程更重,实验里建议先用一个小文法(比如算术表达式)把状态机跑通,再换复杂文法。
| 对比项 | LL(1) | LR(1)/SLR |
|---|---|---|
| 推导方向 | 自顶向下、最左推导 | 自底向上、最右推导的逆 |
| 核心数据结构 | 预测分析表 + 符号栈 | ACTION/GOTO 表 + 状态栈 |
| 能处理的文法 | 无左递归、无公共左因子 | 范围更广,含左递归 |
| 构造难点 | FIRST/FOLLOW 迭代 | 项目集闭包与状态转移 |
| 报错时机 | 查表为空即报错 | ACTION 无定义即报错 |
3.3 递归下降:手写 parser 的工程写法
除了查表,递归下降是另一种常见实现:每个非终结符写一个函数,函数内部按产生式依次调用其他函数或匹配终结符。它直观、好调试,适合文法不大且已消除左递归的场景。写法上每个函数返回bool表示是否匹配成功,用全局游标推进 token。难点在于回溯控制——如果文法有多个候选产生式,需要先看 FIRST 集决定走哪条,避免盲目尝试后回退。工程里更常见的是「预测式递归下降」,即用向前看一个 token 来选分支,本质和 LL(1) 表驱动等价,只是把表藏进了 if-else。
4. 错误处理与恢复:实验里最容易被跳过的一环
多数实验只要求「识别正确串」,但真正拉开差距的是出错时程序的表现。语法错误处理不是附加题,它决定了这个 parser 是玩具还是能用的东西。
4.1 错误检测:至少要知道错在哪
最低要求是检测:LL(1) 里查表为空、终结符不匹配;LR 里 ACTION 表项无定义。检测到之后,输出错误位置(第几个 token)、期望的符号集合、实际读到的符号。期望集合可以直接从分析表那一行取出所有非空列,这比只报「语法错误」有用得多。位置信息要精确到 token 下标甚至行列号,这依赖词法分析阶段保留的位置元数据,实验里常被忽略,等到报错时才发现拿不到行号。
4.2 错误恢复:恐慌模式与短语级恢复
恢复策略里最简单的是恐慌模式:发现错误后,丢弃输入 token 直到遇到一个「同步符号」(比如分号、右括号、FOLLOW 集里的符号),然后弹栈直到栈顶能继续。短语级恢复则尝试局部替换或插入一个 token 让分析继续。实验里实现恐慌模式性价比最高:
void panicRecover(size_t& pos, stack<string>& st, const set<string>& sync) { // 丢弃输入直到同步符号 while (pos < tokens.size() && !sync.count(tokens[pos])) ++pos; // 弹栈直到栈顶符号能接受当前 token while (!st.empty() && !canStart(st.top(), tokens[pos])) st.pop(); }sync集合一般取当前非终结符的 FOLLOW 集加上语句结束符。注意恢复后要保证游标不倒退、栈不无限弹空,否则会陷入死循环——这是踩坑高发区,加一个「本轮是否消耗了 token」的守卫就能避免。
5. 易错点清单:这些坑几乎每个人都踩过
5.1 易错点一:空产生式处理不彻底
为什么漏:ε 在集合运算、入栈、查表三个环节都要特殊对待,很多人只在求 FIRST 时处理了,入栈时忘了跳过,导致栈里压进一个永远匹配不上的 ε 符号。怎么补:统一用哨兵值表示 ε,在「入栈」「填表」「匹配」三处都显式判断并跳过,写完后用小文法S → aS | ε单独测一遍。
5.2 易错点二:左递归没消除就求集合
为什么漏:直接对含左递归的文法求 FIRST 会陷入自依赖,迭代不收敛或结果错误。怎么补:动手前先做文法预处理,消除直接左递归(A → Aα | β 改写成 A → βA',A' → αA' | ε)和间接左递归,再进入集合计算。这一步顺序不能颠倒。
5.3 易错点三:分析表冲突被当成代码 bug
为什么漏:同一格子被填两次,第一反应是查表逻辑写错,实际是文法本身不满足 LL(1)。怎么补:填表时检测冲突并打印冲突的产生式对,先判断是文法问题还是实现问题。若是文法问题,提取左公因子或改用更强的分析方法。
5.4 易错点四:LR 闭包漏展开
为什么漏:闭包规则「点后是非终结符就加入其所有产生式」需要递归到不再增长,只展开一层就停会漏状态。怎么补:闭包用工作队列实现,新加入的项目继续检查点后符号,直到队列空。用算术表达式文法对照手工算出的状态数验证。
5.5 易错点五:错误恢复后游标不前进
为什么漏:恐慌模式弹栈后如果当前 token 仍无法匹配,又没消耗输入,循环原地打转。怎么补:恢复函数里记录进入时的游标,若一轮下来既没弹栈也没前进,强制丢弃一个 token 打破僵局。
6. 复习与查漏补缺:一套能落地的自测流程
复习语法分析,光看笔记不够,得用「能不能手算 + 能不能跑通」两条线交叉验证。我自己的习惯是分三步走,每步都有明确的通过标准。
第一步,手算集合。拿一个三五条产生式的小文法,在纸上算 FIRST 和 FOLLOW,再填 LL(1) 表。通过标准是:能说清每个符号为什么进这个集合,尤其是 ε 的传播路径。这一步卡住,说明定义没吃透,回去看 2.2 的迭代逻辑,别急着写代码。
第二步,跑通驱动程序。用第一步的表驱动 3.1 的栈程序,输入两三个正确串和一个错误串。通过标准是:正确串接受、错误串在正确位置报错。这里建议把栈的每次变化打印出来,对照手算的推导过程,一旦对不上,问题多半在逆序入栈或 ε 跳过。
第三步,换文法压力测试。把文法换成含左递归的版本,先做预处理再重跑;再换一个 LR 能处理但 LL(1) 处理不了的文法,体会两种方法的边界。通过标准是:能解释为什么这个文法 LL(1) 不行、LR 行。
| 自测环节 | 检查项 | 不通过时回看 |
|---|---|---|
| 集合手算 | ε 传播、FOLLOW 的 β 全空条件 | 2.2 迭代代码 |
| 表驱动 | 逆序入栈、查表为空报错 | 3.1 驱动循环 |
| 错误恢复 | 游标前进、栈不弹空 | 4.2 恐慌模式 |
| 文法边界 | 左递归预处理、LL/LR 差异 | 3.2 对比表 |
最容易忽略的是第三步——很多人跑通一个文法就收工,结果考试换个文法就懵。语法分析考的是方法迁移能力,不是背某张表。从那以后我每次做这类实验,都强制先用纸笔把小文法的集合和表算一遍再碰键盘,跑通后再故意喂错误串看恢复逻辑,最后换一个文法验证方法边界。这套流程走下来,实验报告和考试都不慌。希望帮到你。
本文还有配套的精品资源,点击获取