ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

C++递归下降语法分析器实现:文法、优先级与错误恢复全解析

C++递归下降语法分析器实现:文法、优先级与错误恢复全解析

简介:这是一份编译原理课程语法分析实验的C++实现资源,面向正在完成编译原理课程设计或实验作业的高校本科生。程序采用递归子程序法,依据给定文法对词法分析阶段识别的单词序列逐类进行语法成分分析,正确输出单词信息及各语法成分名称,完整覆盖实验中要求的全部语法成分,可直接用于课程报告与代码讲解;资源包共2个文件,含cpp格式的语法分析完整源码与doc格式的实验问题描述文档,前者逻辑清晰、注释易读,便于比对学习,后者完整还原题目输入输出及评测要求。整个压缩包仅17KB,轻量便携,下载后即可打开使用;代码已在CG实验平台满分通过,正确性与实用性有充分保障。目前已有6107人学习浏览,是同类实验资源中较受关注的一份,适合需要快速理解并完成语法分析实验的同学参考借鉴。

1. 这个 C++ 语法分析实验包:先分清"两个半月"和"两个半小时"的区别

我见过太多人把编译原理实验当成算法题来做——拿到题目先查递归下降分析法怎么念,然后找现成代码改改变量名就交差。结果答辩时老师问一句"你的 First 集合是怎么算的",当场语塞。语法分析实验的真正价值不在那个parse()函数能跑通,而在你理解了一棵语法树是怎么从字符流里"长"出来的。这个 C++ 版实验包走的就是经典路线:词法接口 + 递归下降 + 错误恢复,代码量不大,但把表达式、括号、优先级和错误定位这些课本里绕不开的点全过了一遍。

适合谁?两类人。一类是正在上编译原理课、作业就是"用 C++ 实现一个语法分析器"的在校生,拿这个包对照自己的实现,能省下大把查资料的时间;另一类是工作后想补编译基础的开发者——你天天写 C++,但可能从来没想过a + b * c这七个字符是怎么被程序拆成一颗树的。资源里没有晦涩的整段理论,直接用代码说话,我下面拆给你看。

2. 语法分析器的骨架:词法接口、Token 流与文法设计的连带关系

2.1 为什么实验包选择了手写词法接口而不是编译器工具

很多教材一上来就让你用 flex/bison 生成词法和语法分析器,但真正做过实验的人知道,用工具生成和手写实现的理解深度完全是两回事。这个实验包用的是手写词法 + 手写递归下降,理由很简单:语法分析实验的关键在"语法"两个字,词法部分只需要留一个干净的 Token 接口就够。

实验包里的词法接口设计成下面这样:

// lexer.h enum class TokenType { IDENT, // 标识符 NUMBER, // 数字常量 PLUS, MINUS, STAR, SLASH, // + - * / LPAREN, RPAREN, // 括号 ASSIGN, // = 赋值号 SEMI, // ; 分号 END // 输入结束标记 }; struct Token { TokenType type; std::string text; // 原始文本,用于报错和生成语法树节点 int line; // 行号 int column; // 列号 };

这段接口设计有一个关键取舍:Token 里不只存type,还存了text、line、column。很多同学的词法分析器只返回一个枚举值,等语法分析报错时只能干巴巴地输出"第X个Token出错",老师根本看不出来哪里错了。带上行列号和原始文本,错误信息就能直接指向源代码的具体位置,这是实验报告里一个非常容易拿分的点。

获取下一个 Token 的接口惯例是做成Token getNextToken()或者用一个lexer.next()方法推进。实验包里走的是前者——每次取一个 Token,语法分析器自己维护 Token 缓冲。实现时注意接口要保持无状态,或者把状态封装在 Lexer 类内部,不要用全局变量存当前位置,否则多个分析器实例就会互相踩。

为什么要强调这一点?因为我见过太多实验代码在词法接口这一层就把路走歪了——有的用 C 风格的yylex()全局函数,有的把整个词法分析和语法分析混在一个函数里,代码全揉在一起,后续扩展一个注释解析功能就得改三天。实验包把 Lexer 单独做成类,同时把 Token 类型定义放在独立头文件,语义动作和错误处理各自独立,这一步应该写进你的实验报告开头作为设计说明。

2.2 文法设计:从产生式到递归下降函数的映射规则

拿到任何语法分析题目,第一步都是把文法写清楚。这个实验包处理的文法:

prog → stmt_list stmt_list → stmt stmt_list | ε stmt → IDENT ASSIGN expr SEMI expr → term expr_tail expr_tail → PLUS term expr_tail | MINUS term expr_tail | ε term → factor term_tail term_tail → STAR factor term_tail | SLASH factor term_tail | ε factor → IDENT | NUMBER | LPAREN expr RPAREN

注意这份文法是消除左递归之后的版本。原始文法里如果直接写expr → expr PLUS term,递归下降会遇到无限递归的问题——先调用expr,里面对expr的调用是同一函数的活,永远停在原地。所以必须改写。这里给出的是最经典的改写:左递归转右递归,保证每一步只消耗一个 Token,然后递归往下。

映射成 C++ 函数的规则特别机械:非终结符对应同名函数,每个产生式右部对应一段代码路径,遇到终结符就匹配 Token。以上面文法的expr为例:

void Parser::expr() { term(); exprTail(); }

函数结束不返回结果、只负责消耗 Token?不对,实际实验里通常要生成语法树,所以函数签名不应是void。实验包的做法是每个非终结符函数返回一个ASTNode*:

ASTNode* Parser::expr() { ASTNode* left = term(); return exprTail(left); }

每个函数的名字、调用时机和文法产生式严格对应——你用expr()函数去匹配产生式term expr_tail,用term()去匹配factor term_tail。这层映射关系理解了,语法分析器在你眼里就不再是魔法,而是文法到代码的机械翻译。

注意实验包里factor函数的边界处理:遇到LPAREN时先匹配左括号,递归调用expr(),再匹配右括号;如果递归回来的expr()没读到RPAREN,说明括号不匹配,需要丢出带行列号的语法错误。这属于语义动作和错误恢复的范畴,后面避坑部分细说。

3. 递归下降分析法在 C++ 中的落地:表达式解析与优先级控制

3.1 核心解析流程:每个函数吃掉对应的 Token

表达式解析最怕的就是优先级反转。你按expr → term expr_tail的规则写完四个函数,1 + 2 * 3应该算出1 + (2*3)而不是(1+2)*3。为什么?因为expr_tail读取到PLUS时,term()已经优先解析了乘除法那一层。这就是层级设计:越靠底层的函数,绑定优先级越高。

看一下完整解析循环的实现:

ASTNode* Parser::parse() { // 预读第一个 Token,作为 lookahead 缓冲 lookahead = lexer->getNextToken(); // 从顶层非终结符开始递归展开 ASTNode* root = stmtList(); // 声明结束后检查是否真的到了输入末尾 if (lookahead.type != TokenType::END) { throw ParseError("输入结束后仍有未消费的 Token", lookahead.line, lookahead.column); } return root; }

lookahead是简化递归下降的关键调优。全代码只预读一个 Token,但每个函数在决策路径时,至少需要知道"下一个 Token 是什么才能决定走哪条分支"。比如expr_tail()需要判断当前 Token 是PLUS/MINUS就走运算分支,是SEMI/RPAREN/END就走 ε 分支。你不预读也可以,那就是在每个函数开头都调一次getNextToken(),但那样会产生大量的 Token 超前消费,某些分支里 Token 被读走了却用不上,后面的逻辑就很难正确定位错误。lookahead模式是社区里识别度最高的写法,实验报告里写清楚这一个缓冲的作用,老师一看就知道你懂得预读机制。

核心套路可以总结成三句话:每个函数对应一个非终结符,函数的每个分支对应一个产生式候选,函数内部每个终结符匹配都消费一个 Token。递归下降不是黑匣子,它只是把文法表翻译成了 C++ 函数调用。

3.2 左递归消除与优先级分层的关键实现细节

有些同学拿到文法直接写递归下降,写完跑a + b没问题,跑a - b - c就出问题——a-(b-c)的结果和期望的(a-b)-c不一致。这就是因为没消左递归也没做关联性处理。

实验包里的实现把expr_tail和term_tail写成循环而不是简单递归,原因在于右递归只适合处理右结合运算符。加减乘除是左结合,如果右递归直接返回树,树结构就天然变成右结合了。实验包的做法是收集同层运算符的节点数组,然后按顺序从左边开始建树:

ASTNode* Parser::exprTail(ASTNode* left) { // 如果当前 Token 是 + 或 - while (lookahead.type == TokenType::PLUS || lookahead.type == TokenType::MINUS) { char op = lookahead.text[0]; // 记录运算符 match(lookahead.type); // 匹配并推进 ASTNode* right = term(); // 关键细节:左结合通过把已有左子树和新右子树立即拼成二元节点来实现 left = new BinOpNode(op, left, right); } return left; // 没有运算符时直接返回左子树,对应 ε 产生式 }

与简单递归调用exprTail(left)传递左子树不同,这里用while循环持续检查下一个 Token 是否还是运算符。这做法的理由是:递归调用会让语法树一层层嵌套,但循环可以让同层运算符节点依次挂接,避免出现a+(b-c)的错误树结构。

至于term_tail的处理,逻辑和expr_tail完全对称。这里实验包里有一个细节值得抄:factor()遇到数字时,直接构造叶子节点;遇到标识符时,查符号表确认该变量是否已定义。符号表的查询放在factor层而非语义分析阶段,是为了让未定义变量的错误能在语法分析阶段就被定位到行号。

4. 实验包里配套的错误处理与恢复机制:识别、报错、跳过与同步

4.1 同步 Token 集合的设计:怎么从错误里爬出来

语法分析最麻烦的不是报错,而是报完错之后怎么办。一个a + * b的错误,如果直接终止分析,只能看到一个错误。如果跳过一些 Token 继续分析,就能一次性曝出多个错误——这叫错误恢复,也叫 panic mode。实验包里用了一个同步 Token 集合,当分析到* b时,进入错误恢复流程。

void Parser::errorRecovery(const std::unordered_set<TokenType>& syncTokens) { // 跳过当前可疑 Token lookahead = lexer->getNextToken(); // 一直跳到同步集合中的 Token 类型,恢复到一个"已知安全"的语法边界 while (lookahead.type != TokenType::END && syncTokens.find(lookahead.type) == syncTokens.end()) { lookahead = lexer->getNextToken(); } }

这里的同步集合通常取当前非终结符的 FOLLOW 集合元素,加上语句终结符SEMI。实验包里把SEMI和END放进所有同步集合的基底,配合line信息输出,错误日志格式形如:

Syntax error at line 2, column 8: unexpected token '*', expected one of [NUMBER, IDENT, '(']

4.2 实验包里用到的三个错误类型:非法 Token、括号不匹配、表达式不完整

实验包里错误类型不只是笼统的"有错误",而是细分成了三个可诊断的场景。

非法 Token:当前 Token 类型不在产生式期望集合中。比如进入factor()时,lookahead是STAR。处理方式就是报错 + 调用errorRecovery(),按同步集合跳到下一个语句边界。

括号不匹配:LPAREN匹配后递归调用expr()回来,发现lookahead不是RPAREN。这种情况报错误后要手动把 Token 跳转到RPAREN或SEMI,否则后续分析会在同一个括号里反复报错。

表达式不完整:a +后面没有右操作数。这种情况最容易暴露问题——term()内部调factor()时发现 Token 是SEMI或END。实验包的处理方式是构造一个占位节点给合成树用,让分析继续走完,最后在输出里标记这个节点为[ERROR]。

下面这段代码综合演示了实验包里怎么在一次误报错误后恢复并让程序继续输出:

ASTNode* Parser::factor() { if (lookahead.type == TokenType::NUMBER) { Token t = lookahead; match(TokenType::NUMBER); return new NumberNode(t.line, t.column, t.text); } if (lookahead.type == TokenType::IDENT) { // 查符号表确认已定义(实验包里简化做:先存表,未定义则报错) std::string name = lookahead.text; match(TokenType::IDENT); return new IdentNode(name); } if (lookahead.type == TokenType::LPAREN) { match(TokenType::LPAREN); ASTNode* inner = expr(); if (lookahead.type != TokenType::RPAREN) { reportError("missing ')'", lookahead); // 直接尝试消费下一个 Token,期望它正是右括号 if (lookahead.type == TokenType::SEMI || lookahead.type == TokenType::END) { return new ErrorNode(); // 占位,让上层继续收敛错误 } } else { match(TokenType::RPAREN); } return inner; } // 这里已经是"表达式不完整"边界 reportError("unexpected token '" + lookahead.text + "'", lookahead); errorRecovery({TokenType::SEMI, TokenType::END}); return new ErrorNode(); }

实验包的错误恢复策略有一个值得借鉴的分寸:它不会无限跳过 Token,跳过的过程中一旦遇到SEMI或END就停下来。这样每个语句的错误定位都是独立的,不会出现一个错误引发连锁误报。把错误恢复策略单独写成一个小节放进实验报告,答辩时说到"我设计了同步 Token 集合",老师的兴趣会大很多。

5. 避坑指南与常见问题:C++ 实现语法分析器的五个经典翻车点

5.1 一个递归函数无限递归到栈溢出

现象:运行程序后直接崩溃,控制台报stack overflow或者 Segmentation Fault。

原因:大概率是左递归没有消除,或者词法接口与语法接口没有配合好。如果expr()内部第一行就调用expr()且没有消费任何输入 Token,栈必然爆。

解决:确认文法中每一个递归调用前都先调用了match()(至少消耗了一个 Token)。在编译器启动参数里加-fstack-usage可以观察每个函数的栈使用量,辅助确认递归深度变化是否正常。另外,实验包里每个函数的第一步都是从 switch 判断lookahead.type开始的,这是强制约束——任何决策执行前先获得当前 Token 类型,就能显著减少意外递归的风险。从那以后我每次写递归函数,第一行必然检查"这个函数执行完,至少消耗了一个 Token"。这是一个可以写死在编码规范里的惯例。

5.2 表达式的优先级永远是反的

现象:1 * 2 + 3被解析成1 * (2 + 3),或者10 - 5 - 2算成10 - (5 - 2)。

原因:优先级分层归错了。term_tail的层级在expr_tail之下,乘法除法一定比加减法优先结合。另一个原因是左结合与右结合混用——加减乘除是左结合,右递归产生的树结构天然右结合,必须让运算节点立即与左子树拼接。

解决:把expr_tail和term_tail从递归改成 while 循环拼接,循环体内每读到一个运算符就立即构造一个BinOpNode,并把这个新节点作为下一次循环的左子树——就是上一节展示的那种写法。另外建议给每个节点打一个深度标记,调试时直接把树的前缀序列打印出来人工验证优先级。

5.3 错误定位参数 line/column 全是 0

现象:把 Token 定义成了值对象(struct 直接传值),词法分析器内部更新位置,但语法分析器拿到的 line 一直是 0。

原因:位置信息在 Lexer 内部被更新,但传给语法分析器的 Token 是拷贝,更新发生在下一次getNextToken()里,语法分析器手里的 Token 是过期副本。

解决:每次getNextToken()返回前,必须给Token的line和column赋值;在语法分析器里,如果报告错误时调用的是"预读 Token 的 error 方法",要确保拿到的是当前的lookahead,而不是已消费掉的上一个 Token。最简单的自检测是:在错误恢复分支专门打印一次lookahead.line和lookahead.column,如果出现 0,直接查 Lexer 的更新时机。

5.4 处理完一个错误后程序无限循环

现象:报一个错之后,输出内容停不住,持续刷同一条错误信息。

原因:错误恢复函数跳过 Token 后,回到主流程时并没有真正消费到新 Token——比如errorRecovery()里跳到一个SEMI,但上层函数忘了match(SEMI),下一次循环又把同一个SEMI当成错误报出来。

解决:在errorRecovery()里记录"本函数结束时当前 Token 类型"和"进入时的 Token 类型",保证二者不同;如果相同,主动向后推进一个 Token。我一般会在整个parse()里加一个计数器,每消费一个 Token 加一,如果错误恢复路线连续两次计数器不变,直接抛异常终止。这本质上是"前进保证"的工程化强制约束。

5.5 用 vector 存语法树节点然后全部泄漏

现象:程序跑几遍之后内存涨得厉害,用 Valgrind 一查全是ASTNode子类的泄漏。

原因:递归下降函数返回裸指针,树销毁时只删了根节点,子节点没有递归释放。

解决:不要自己手工递归 delete。C++ 里习惯做法是重写ASTNode的析构函数,让析构时递归删除 children;或者直接用unique_ptr,在构造BinOpNode/NumberNode时就直接存智能指针。实验包里用的是最直接的第一种——每个节点的析构函数里对每个子节点做delete,代价是复制节点时容易重复释放。如果你比较习惯现代 C++,推荐用std::unique_ptr+ 非拷贝构造。注意检查返回的错误占位节点ErrorNode是否也覆盖了析构路径。

6. 验证语法树正确性的一个实用方法:把 AST 序列化成括号表达式再人工核对

排查错误的最后一道关,是确认生成的语法树真的是我们预期的那棵树。直接看树节点指针地址没用,我建议做一步 AST 序列化。给ASTNode增加一个toDebugString()虚方法,递归拼接出反括号表达式。

std::string BinOpNode::toDebugString() const { return "(" + std::string(1, op) + " " + left->toDebugString() + " " + right->toDebugString() + ")"; } std::string NumberNode::toDebugString() const { return value; } std::string IdentNode::toDebugString() const { return name; }

把a = b + c * 2输入进去,输出应该是(= a (+ b (* c 2)))。这串输出可以在 write-up 里直接当成验证结果贴出来,老师一看就知道你的优先级处理是对的。这一步的代码量不大,但它让树的正确性从"我猜的"变成了"可验证的"。

我一般会在实验包的测试入口里放一组用例,覆盖正常优先级、连续减法、括号嵌套、错误恢复这四类场景,每个用例都调一次toDebugString()断言输出格式。从那以后我每次写完语法分析器,都会先跑这个序列化断言再往下做语义分析,避免把语法层的 bug 带到后续环节。希望帮到你——至少它能让你少熬夜排查两个小时的指针崩溃。

本文还有配套的精品资源,点击获取

RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进