ARTICLE DETAIL

资讯详情

深耕网站建设与运营推广的一线实战洞察。

编译原理语法分析实验C++实战:从词法输出到语法树落地

编译原理语法分析实验C++实战:从词法输出到语法树落地 简介这份资源面向正在学习编译原理、需要完成语法分析实验的高校学生与自学者核心是提供一个可直接参考的递归子程序法语法分析实现方案。它基于词法分析程序识别出的单词按给定文法规则对各类语法成分进行识别并按顺序输出单词信息与语法成分名称便于在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::vectorToken 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 并推进posparseE和parseEPrime对应改写后的文法。参数上tokens是词法分析输出的 token 序列pos是全局游标。注意parseEPrime在遇到时才递归否则直接返回 true这就是 ε 产生式的代码化。失败时输出期望类型和实际类型实验报告里可以直接截图当错误处理部分。2.3 分析表怎么存二维数组还是 map如果走 LR 路线ACTION 表和 GOTO 表是核心数据结构。终结符数量少时直接用二维数组int action[STATE_NUM][TERM_NUM]正数表示移进状态号负数表示归约产生式编号0 表示报错。非终结符的 GOTO 表同理。状态数一多数组会浪费空间常见做法是换成std::mapstd::pairint, std::string, std::string键是「状态 符号」值是动作。// 用 map 存分析表适合状态数多、稀疏的场景 std::mapstd::pairint, std::string, std::string actionTable; std::mapstd::pairint, 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 到状态 5r3表示用第 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是结束标记解析循环必须处理它否则会越界访问。如果你的词法分析器输出的是pairint, 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 循环迭代到集合不再变化为止。参数上ε用字符串表示$是输入结束符。这段脚本只算了 FIRSTFOLLOW 的传播规则类似需要在每个产生式里看当前符号后面能不能推出 ε。跑一遍脚本把结果和手算对照能省下大量排查时间。5.2 用测试用例驱动验证从表达式到嵌套语句分析器写完后别只跑一个12*3就交差。我一般会准备一组测试用例覆盖优先级、括号嵌套、错误输入三类。用例输入期望结果优先级12*3语法树根为右子为*括号(12)*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最后用四类测试用例过一遍。这套习惯让我少熬了好几个通宵也希望帮到你。本文还有配套的精品资源点击获取
返回列表