简介:这份资源面向正在学习编译原理、需要完成语法分析实验的高校学生与自学者,核心是提供一个可直接参考的递归子程序法语法分析实现方案。它基于词法分析程序识别出的单词,按给定文法规则对各类语法成分进行识别,并按顺序输出单词信息与语法成分名称,便于在CG实验平台上自动评测。压缩包共2个文件,包含1个cpp源码与1个doc说明文档,整体约17KB,源码对应语法分析主程序,文档则给出问题描述与实验要求,方便对照理解实现思路。该资源在CG实验平台满分通过,已有6106人学习,适合作为课程实验的参考模板。读者可从中获取完整的递归下降分析框架、文法成分处理顺序、输出格式控制以及预读处理等关键细节,快速定位自身代码在语法成分识别与结果输出上的问题,提升实验通过效率。
1. 语法分析实验到底在做什么:从词法输出到语法树的落地路径
很多人第一次拿到「编译原理-语法分析实验(c++版)」这个资源时,会下意识觉得它只是课本第二章的配套练习,做完就扔。但真正跑过一遍的人会发现,这个实验是整个编译原理课程里最能拉开差距的一环——词法分析把字符流切成 token,语法分析则要判断这些 token 能不能组成合法句子,并输出语法树或错误位置。它解决的是「程序结构是否合法」这个核心问题,适合正在上编译原理课、需要交实验报告的学生,也适合想补编译基础、准备 c++ 面试题里编译相关追问的从业者。资源本身是 c++ 实现,意味着你能直接看到分析表、栈操作、递归下降或 LR 状态机的真实代码,而不是伪代码。
2. 文法设计与分析器选型:为什么先定 LL(1) 还是 LR(1)
2.1 从实验要求反推文法:消除左递归和提取公因子
语法分析实验的第一步不是写代码,而是把老师给的文法整理成分析器能接受的形式。常见做法是:如果选递归下降,就必须消除左递归;如果选 LL(1),还要提取左公因子并求 FIRST/FOLLOW 集。很多同学直接拿课本上的表达式文法开写,结果递归下降时栈溢出,这就是没做左递归消除的血泪经验。
以经典的算术表达式文法为例,原始形式是:
E -> E + T | T T -> T * F | F F -> ( E ) | id这个文法直接写递归下降会无限递归。消除左递归后变成:
E -> T E' E' -> + T E' | ε T -> F T' T' -> * F T' | ε F -> ( E ) | id逻辑说明:把E -> E + T | T拆成E -> T E',让E'负责处理后续的+ T序列。参数上,ε表示空产生式,在代码里通常用nullptr或特殊标记表示。这样改写后,每个非终结符的产生式右部首符号都不相同,递归下降才能一路向前不回头。
如果你选 LR(1) 或 SLR,就不需要消除左递归,但需要构造项目集规范族。实验里常见做法是用手写或脚本生成 ACTION 和 GOTO 表,再写一个驱动引擎。选型理由很简单:递归下降代码直观、调试方便,适合文法规模小的实验;LR 分析表驱动更通用,但构造过程容易在闭包计算上翻车。
2.2 递归下降 vs 表驱动:实验里怎么选不后悔
递归下降的核心是为每个非终结符写一个函数,函数内部按产生式匹配 token。优点是断点好打,出错时能直接看到走到哪个函数;缺点是文法一改就要改代码,而且遇到左递归直接崩。表驱动则是把分析表存成二维数组或 map,主循环只做「查表 → 移进/归约 → 压栈」三件事。
我一般会建议:如果实验要求只是验证表达式、if-else、while 这几类结构,递归下降足够,代码量在 300 行以内。如果要求覆盖完整 c 语言子集,或者老师明确要求 LR,那就老老实实做表驱动。下面是一个递归下降的匹配函数骨架:
// 全局 token 流和当前位置 std::vector<Token> tokens; int pos = 0; // 匹配当前 token,成功则前进,失败报错 bool match(TokenType expected) { if (pos < tokens.size() && tokens[pos].type == expected) { pos++; return true; } // 记录错误位置和期望类型,方便实验报告里写错误恢复 std::cerr << "Error at token " << pos << ": expected " << tokenTypeName(expected) << " but got " << tokenTypeName(tokens[pos].type) << std::endl; return false; } // E -> T E' bool parseE() { if (!parseT()) return false; return parseEPrime(); } // E' -> + T E' | ε bool parseEPrime() { if (pos < tokens.size() && tokens[pos].type == TOKEN_PLUS) { match(TOKEN_PLUS); if (!parseT()) return false; return parseEPrime(); } return true; // ε 产生式,直接成功 }逻辑说明:match负责消费 token 并推进pos,parseE和parseEPrime对应改写后的文法。参数上,tokens是词法分析输出的 token 序列,pos是全局游标。注意parseEPrime在遇到+时才递归,否则直接返回 true,这就是 ε 产生式的代码化。失败时输出期望类型和实际类型,实验报告里可以直接截图当错误处理部分。
2.3 分析表怎么存:二维数组还是 map
如果走 LR 路线,ACTION 表和 GOTO 表是核心数据结构。终结符数量少时,直接用二维数组int action[STATE_NUM][TERM_NUM],正数表示移进状态号,负数表示归约产生式编号,0 表示报错。非终结符的 GOTO 表同理。状态数一多,数组会浪费空间,常见做法是换成std::map<std::pair<int, std::string>, std::string>,键是「状态 + 符号」,值是动作。
// 用 map 存分析表,适合状态数多、稀疏的场景 std::map<std::pair<int, std::string>, std::string> actionTable; std::map<std::pair<int, std::string>, int> gotoTable; // 初始化示例:状态 0 遇到 id 移进到状态 5 actionTable[{0, "id"}] = "s5"; // 状态 0 遇到 E 转移到状态 1 gotoTable[{0, "E"}] = 1; // 状态 5 遇到 + 按产生式 3 归约 actionTable[{5, "+"}] = "r3";逻辑说明:s5表示 shift 到状态 5,r3表示用第 3 条产生式归约。驱动循环里解析这个字符串前缀即可。参数上,状态号从 0 开始,产生式编号要和文法数组对应。用 map 的代价是查找比数组慢,但实验规模下完全无感,换来的是不用手动数列号,减少翻车概率。
3. 从 token 流到语法树:手写解析器的完整落地步骤
3.1 词法分析接口对接:token 结构体怎么定
语法分析的输入是词法分析输出的 token 序列。实验里常见做法是定义一个Token结构体,包含类型、原始字符串和行号。类型用枚举,方便 switch 匹配。
enum TokenType { TOKEN_ID, TOKEN_NUM, TOKEN_PLUS, TOKEN_MINUS, TOKEN_STAR, TOKEN_SLASH, TOKEN_LPAREN, TOKEN_RPAREN, TOKEN_IF, TOKEN_ELSE, TOKEN_WHILE, TOKEN_EOF }; struct Token { TokenType type; std::string lexeme; // 原始文本,报错时显示 int line; // 行号,方便定位错误 };逻辑说明:lexeme保留原始字符串,归约时构造语法树节点要用;line用于错误报告。参数上,TOKEN_EOF是结束标记,解析循环必须处理它,否则会越界访问。如果你的词法分析器输出的是pair<int, string>,建议先转成这个结构体,后面代码会干净很多。
3.2 语法树节点设计:用联合体还是继承
语法树节点有两种常见写法:一种是 C 风格的 tagged union,一种是 C++ 继承加虚函数。实验里我倾向继承,因为节点类型不多,代码可读性更好。
struct ASTNode { virtual ~ASTNode() = default; }; struct BinOpNode : ASTNode { std::string op; // "+", "-", "*", "/" ASTNode* left; ASTNode* right; BinOpNode(std::string o, ASTNode* l, ASTNode* r) : op(std::move(o)), left(l), right(r) {} }; struct NumNode : ASTNode { int value; explicit NumNode(int v) : value(v) {} };逻辑说明:BinOpNode表示二元运算,NumNode表示数字字面量。参数上,left和right是子节点指针,构造时传入。递归下降里每匹配完一个产生式就 new 一个节点返回,最后得到整棵语法树。注意内存管理,实验里可以不 delete,但面试追问时要说清楚可以用std::unique_ptr替代裸指针。
3.3 解析主循环与错误恢复:panic mode 怎么用
主循环不断调用起始非终结符的解析函数,直到 token 流耗尽或报错。错误恢复常见做法是 panic mode:遇到错误后跳过 token 直到遇到同步符号(如分号、右括号),然后继续解析。这样一次运行能报多个错误,实验报告里更漂亮。
void parseProgram() { while (pos < tokens.size() && tokens[pos].type != TOKEN_EOF) { if (!parseStatement()) { // panic mode:跳到下一个分号或 EOF while (pos < tokens.size() && tokens[pos].type != TOKEN_SEMI && tokens[pos].type != TOKEN_EOF) { pos++; } if (pos < tokens.size() && tokens[pos].type == TOKEN_SEMI) { pos++; // 消费分号,继续下一条语句 } } } }逻辑说明:parseStatement失败后,内层 while 跳过所有非同步 token,遇到分号就消费并继续。参数上,同步符号集合可以根据文法调整,常见的是分号、右花括号、EOF。注意别跳过 EOF,否则外层循环条件失效。这套逻辑在 LL 和 LR 里都能用,LR 里叫 error recovery,思路一致。
4. 避坑与排查:语法分析实验里最容易翻车的五件事
4.1 现象:递归下降栈溢出,程序直接崩
原因:文法存在左递归,E -> E + T这种产生式让parseE无限调用自己。解决:先做左递归消除,把直接左递归和间接左递归都处理掉。间接左递归常见于多个非终结符互相引用,需要先代入再消除。检查方法:画一张非终结符依赖图,看有没有环。
4.2 现象:LL(1) 分析表出现多重入口
原因:FIRST 集或 FOLLOW 集算错,或者文法本身不是 LL(1)。解决:重新手算 FIRST/FOLLOW,重点检查 ε 产生式对 FOLLOW 集的贡献。如果文法确实有左公因子,提取后重新求集。实验里常见错误是忘记把$加入起始符号的 FOLLOW 集。
4.3 现象:LR 归约时栈里符号对不上
原因:GOTO 表填错,或者归约时弹栈数量算错。解决:归约产生式右部长度就是弹栈数量,弹完后用栈顶状态和产生式左部查 GOTO 表。建议在驱动循环里打印每一步的栈内容和剩余输入,对照分析表手工走一遍。这个黑匣子一旦打开,问题基本一眼可见。
4.4 现象:token 类型匹配不上,明明输入是对的
原因:词法分析器把关键字识别成了标识符,或者运算符优先级没处理好。解决:检查词法分析的关键字表是否包含if、while这些;检查多字符运算符(如<=、==)是否在单字符之前匹配。常见做法是在词法分析里用最长匹配原则。
4.5 现象:语法树打印出来顺序反了
原因:递归下降里先递归右子节点再构造当前节点,导致中序遍历顺序错乱。解决:二元运算节点先解析左操作数,再解析右操作数,最后构造节点。打印时用中序遍历,左-根-右。如果要求前缀表达式,就改成根-左-右。别小看这个,实验报告里语法树图错了直接扣分。
5. 进阶技巧:用脚本自动生成分析表并验证
5.1 用 Python 算 FIRST/FOLLOW 集,减少手算错误
手算 FIRST/FOLLOW 集是实验里最容易出错的地方。我一般会写一个几十行的 Python 脚本,把文法读进去,自动迭代到不动点。这样改文法后重新跑一遍就行,不用重新手算。
# 文法用字典表示:非终结符 -> 产生式列表,产生式是符号列表 grammar = { 'E': [['T', "E'"]], "E'": [['+', 'T', "E'"], ['ε']], 'T': [['F', "T'"]], "T'": [['*', 'F', "T'"], ['ε']], 'F': [['(', 'E', ')'], ['id']] } terminals = {'+', '*', '(', ')', 'id', 'ε'} non_terminals = set(grammar.keys()) first = {nt: set() for nt in non_terminals} follow = {nt: set() for nt in non_terminals} follow['E'].add('$') # 起始符号的 FOLLOW 包含结束符 changed = True while changed: changed = False for nt, prods in grammar.items(): for prod in prods: # 计算该产生式的 FIRST 并并入 first[nt] for sym in prod: if sym in terminals: if sym not in first[nt]: first[nt].add(sym) changed = True break else: before = len(first[nt]) first[nt] |= (first[sym] - {'ε'}) if len(first[nt]) != before: changed = True if 'ε' not in first[sym]: break else: if 'ε' not in first[nt]: first[nt].add('ε') changed = True print("FIRST:", first) print("FOLLOW:", follow)逻辑说明:外层 while 循环迭代到集合不再变化为止。参数上,ε用字符串表示,$是输入结束符。这段脚本只算了 FIRST,FOLLOW 的传播规则类似,需要在每个产生式里看当前符号后面能不能推出 ε。跑一遍脚本,把结果和手算对照,能省下大量排查时间。
5.2 用测试用例驱动验证:从表达式到嵌套语句
分析器写完后,别只跑一个1+2*3就交差。我一般会准备一组测试用例,覆盖优先级、括号嵌套、错误输入三类。
| 用例 | 输入 | 期望结果 |
|---|---|---|
| 优先级 | 1+2*3 | 语法树根为+,右子为* |
| 括号 | (1+2)*3 | 语法树根为*,左子为+ |
| 错误 | 1+*2 | 报错位置指向* |
| 嵌套 | if (a) { b = 1; } | 语句节点正确嵌套 |
逻辑说明:优先级用例验证*比+结合更紧;括号用例验证括号改变结合顺序;错误用例验证 panic mode 能定位;嵌套用例验证语句块解析。参数上,期望结果可以写成断言,用assert或简单 if 判断。跑通这四类,实验基本稳了。
5.3 把分析表导出成 CSV,方便对照和写报告
LR 实验里分析表是重点,老师 often 要求附在报告里。我一般会在构造完表后直接导出 CSV,用 Excel 打开截图。
std::ofstream csv("parsing_table.csv"); csv << "State,Symbol,Action\n"; for (auto& kv : actionTable) { csv << kv.first.first << "," << kv.first.second << "," << kv.second << "\n"; } csv.close();逻辑说明:遍历 map,把状态、符号、动作写成三列。参数上,kv.first.first是状态号,kv.first.second是符号,kv.second是动作字符串。导出后可以直接贴进实验报告,比手画表格快得多。注意 CSV 里如果有逗号,符号列要加引号,实验里符号一般不含逗号,可以忽略。
从那以后我每次做语法分析实验,都强制先跑一遍 FIRST/FOLLOW 脚本,再导出分析表 CSV,最后用四类测试用例过一遍。这套习惯让我少熬了好几个通宵,也希望帮到你。
本文还有配套的精品资源,点击获取