编译原理语法分析实验:C++实现递归下降与算符优先算法详解
1. 项目概述与核心价值如果你正在学习编译原理并且被“语法分析”这个听起来就有点玄乎的实验卡住了那这篇东西就是为你准备的。我当年做这个实验的时候也经历过从一头雾水到豁然开朗的过程尤其是在用C实现时既要理解抽象的理论又要处理具体的代码细节确实是个不小的挑战。语法分析实验说白了就是让你写一个程序它能像老师批改作文一样检查你写的“代码句子”是否符合既定的“语法规则”。这个实验是编译原理课程里承上启下的关键一环上承词法分析认单词下启语义分析理解意思搞懂了它你对编译器如何“读懂”代码就有了最直观的认识。这个实验的核心价值远不止完成一次作业。首先它能让你把龙书《编译原理》那本经典里那些关于上下文无关文法、推导、分析树的概念从纸上搬到屏幕上理解会深刻得多。其次用C来实现是对你面向对象编程和数据结构能力的一次绝佳锻炼你会频繁地和栈、树、递归这些玩意儿打交道。最后这也是一个经典的“造轮子”过程当你亲手实现了一个能分析简单表达式的语法分析器后再看那些成熟的编译器工具比如Flex和Bison你就能明白它们背后在做什么而不是只会敲命令。无论你是为了应付实验报告还是想夯实基础甚至为以后研究更复杂的编译技术打底子这个实验都值得你投入时间。2. 实验整体设计与思路拆解2.1 实验目标与常见实现路径这个实验的目标通常很明确给定一个简化的文法规则比如一个四则运算表达式的文法编写一个程序对输入的符号串通常是由词法分析器产生的单词序列或者直接由用户输入的字符串进行语法检查判断其是否合法并可能输出分析树或推导过程。实现路径主要有两大类对应着语法分析的两大主流方法自顶向下分析从文法的开始符号出发尝试推导出整个输入串。最典型的就是递归下降分析法。这种方法直观手工实现相对容易特别适合LL(1)文法。你需要为文法的每个非终结符写一个递归函数函数体就是根据当前输入符号选择对应的产生式进行展开。它的思路很像你在走一个迷宫从入口开始符号出发根据眼前的路径输入符号决定下一步怎么走选择哪个产生式。自底向上分析从输入串本身出发逐步规约到文法的开始符号。最常见的是LR分析法但其手工构造分析表非常复杂。在课程实验中一种更常见的简化实现是算符优先分析法它特别适合分析表达式。这种方法不严格按照最左规约而是根据算符运算符之间的优先关系来决定规约顺序实现起来比LR简单但文法限制较多。对于第一次做这个实验的同学我强烈推荐从递归下降分析法入手。因为它直观代码结构几乎就是文法规则的直接翻译容易理解和调试。锻炼能力能很好地训练你将形式化文法转化为递归程序逻辑的能力。足够完成实验对于实验常用的表达式文法基本都能处理。2.2 文法定义与设计考量一切分析的前提是文法。实验通常会给你一个文法但理解它为什么这么设计很重要。例如一个经典的消除左递归和提取左因子后的简单表达式文法可能是这样的E - T E E - T E | ε T - F T T - * F T | ε F - ( E ) | idE, T, F分别代表表达式、项、因子。这种分层是为了处理运算符优先级*高于。E, T引入这些非终结符是为了消除原始文法的左递归例如E - E T使其适用于递归下降分析。ε代表空串用于处理“后面可能没有更多同级运算符”的情况。注意在动手写代码前务必确认你拿到或设计的文法是LL(1)的即适合递归下降。检查要点包括消除左递归、提取左因子并确保每个非终结符的各个产生式SELECT集不相交。如果文法不合适递归下降程序会陷入无限递归或产生错误判断。2.3 核心数据结构规划用C实现我们需要规划好数据的存放方式。核心数据结构通常包括符号表与单词流语法分析的输入通常是一个单词Token序列。每个Token至少包含类型如标识符ID、整数NUM、运算符PLUS等和值如变量名“x”数字“5”。我们可以用一个vectorToken来存储并维护一个索引pos指向当前正在分析的Token。struct Token { enum Type { ID, NUM, PLUS, MINUS, MUL, DIV, LPAREN, RPAREN, END } type; std::string value; // 单词的字符串值如 count, 123 // 可以添加行列号信息用于错误定位 int line, column; }; std::vectorToken tokens; size_t currentPos 0;语法树节点为了输出分析结果如语法树我们需要定义树节点。一个简单的节点可以包含节点类型对应非终结符或终结符、值、以及子节点指针。class ASTNode { public: std::string type; // 如 E, T, F, ID, NUM std::string value; // 对于终结符存储其词素 std::vectorASTNode* children; ASTNode(const std::string t, const std::string v ) : type(t), value(v) {} // 记得实现析构函数释放children内存 };在递归下降函数中每成功匹配一个产生式就创建相应的节点并组合起来最终返回一棵完整的抽象语法树AST。3. 递归下降分析法的核心实现3.1 基础框架与输入处理首先我们要搭建一个基础框架负责读取输入并调用顶层的分析函数。假设我们的输入是一个字符串我们可以先实现一个简单的词法分析器Lexer来将字符串转换成Token流。对于实验来说一个足够简单的Lexer可以边分析边识别。#include iostream #include string #include vector #include cctype #include memory // for smart pointers if needed class Lexer { std::string input; size_t pos 0; int line 1, column 1; public: Lexer(const std::string str) : input(str) {} Token getNextToken() { while (pos input.length() isspace(input[pos])) { if (input[pos] \n) { line; column 1; } else { column; } pos; } if (pos input.length()) return {Token::END, , line, column}; char current input[pos]; // 识别单字符运算符和括号 switch (current) { case : pos; column; return {Token::PLUS, , line, column-1}; case -: pos; column; return {Token::MINUS, -, line, column-1}; case *: pos; column; return {Token::MUL, *, line, column-1}; case /: pos; column; return {Token::DIV, /, line, column-1}; case (: pos; column; return {Token::LPAREN, (, line, column-1}; case ): pos; column; return {Token::RPAREN, ), line, column-1}; } // 识别数字 if (isdigit(current)) { std::string num; while (pos input.length() isdigit(input[pos])) { num input[pos]; pos; column; } return {Token::NUM, num, line, column - num.length()}; } // 识别标识符由字母开头 if (isalpha(current)) { std::string id; while (pos input.length() isalnum(input[pos])) { id input[pos]; pos; column; } return {Token::ID, id, line, column - id.length()}; } // 无法识别的字符 throw std::runtime_error(Lexical error at line std::to_string(line) , column std::to_string(column) : unexpected character std::string(1, current) ); } std::vectorToken tokenize() { std::vectorToken tokList; Token tok; do { tok getNextToken(); tokList.push_back(tok); } while (tok.type ! Token::END); return tokList; } };3.2 递归下降函数编写有了Token流我们就可以根据文法编写递归下降函数了。每个非终结符对应一个函数函数内部根据当前Token选择对应的产生式分支。class RecursiveDescentParser { std::vectorToken tokens; size_t currentPos; Token lookahead; // 当前展望符 // 辅助函数获取当前token并前移 void match(Token::Type expectedType) { if (lookahead.type expectedType) { currentPos; if (currentPos tokens.size()) { lookahead tokens[currentPos]; } } else { // 错误处理抛出异常或记录错误 std::cerr Syntax error at line lookahead.line , column lookahead.column : expected token type expectedType , but got lookahead.type ( lookahead.value ) std::endl; throw std::runtime_error(Syntax error); } } // 辅助函数查看当前token类型 Token::Type peek() const { return lookahead.type; } public: RecursiveDescentParser(const std::vectorToken tok) : tokens(tok), currentPos(0) { if (!tokens.empty()) lookahead tokens[0]; } // 开始符号 E 的分析函数 ASTNode* parseE() { // E - T E auto* node new ASTNode(E); node-children.push_back(parseT()); // 解析 T node-children.push_back(parseEPrime()); // 解析 E return node; } ASTNode* parseEPrime() { // E - T E | ε auto* node new ASTNode(E); if (peek() Token::PLUS) { match(Token::PLUS); node-children.push_back(new ASTNode(, )); node-children.push_back(parseT()); node-children.push_back(parseEPrime()); } else { // 匹配 ε即什么都不做可以添加一个空节点或直接返回 node-children.push_back(new ASTNode(epsilon, ε)); } return node; } ASTNode* parseT() { // T - F T auto* node new ASTNode(T); node-children.push_back(parseF()); node-children.push_back(parseTPrime()); return node; } ASTNode* parseTPrime() { // T - * F T | ε auto* node new ASTNode(T); if (peek() Token::MUL) { match(Token::MUL); node-children.push_back(new ASTNode(*, *)); node-children.push_back(parseF()); node-children.push_back(parseTPrime()); } else { node-children.push_back(new ASTNode(epsilon, ε)); } return node; } ASTNode* parseF() { // F - ( E ) | id auto* node new ASTNode(F); if (peek() Token::LPAREN) { match(Token::LPAREN); node-children.push_back(new ASTNode((, ()); node-children.push_back(parseE()); match(Token::RPAREN); node-children.push_back(new ASTNode(), ))); } else if (peek() Token::ID) { std::string idValue lookahead.value; match(Token::ID); node-children.push_back(new ASTNode(ID, idValue)); } else if (peek() Token::NUM) { std::string numValue lookahead.value; match(Token::NUM); node-children.push_back(new ASTNode(NUM, numValue)); } else { std::cerr Syntax error in F: expected ID, NUM, or ( std::endl; throw std::runtime_error(Syntax error); } return node; } ASTNode* parse() { ASTNode* root parseE(); // 最后应该消费完所有token只剩下END if (peek() ! Token::END) { std::cerr Syntax error: extra tokens after expression std::endl; throw std::runtime_error(Syntax error); } return root; } };3.3 错误恢复与处理策略上面的代码在遇到错误时直接抛出异常程序终止。在实际的编译器中我们希望语法分析器能尽可能发现更多的错误而不是遇到第一个错误就停止。这就需要实现简单的错误恢复机制。一种常见的策略是“恐慌模式”恢复当在一个非终结符如parseE的分析过程中遇到错误时我们跳过一些输入符号直到看到一个“同步符号集”通常包含该非终结符的后继符号如E的后继可能是)和END中的符号然后重置分析状态继续分析。例如在parseF函数中我们可以修改ASTNode* parseF() { auto* node new ASTNode(F); if (peek() Token::LPAREN) { // ... 正常处理 } else if (peek() Token::ID || peek() Token::NUM) { // ... 正常处理 } else { // 错误处理报告错误并尝试恢复 std::cerr Syntax error at line lookahead.line : expected (, ID, or NUM in F std::endl; // 恐慌模式跳过输入直到遇到F的同步符号这里简单假设为), , *, END等 while (!(peek() Token::RPAREN || peek() Token::PLUS || peek() Token::MUL || peek() Token::END)) { currentPos; if (currentPos tokens.size()) lookahead tokens[currentPos]; else break; } // 返回一个错误节点或nullptr上层函数需要能处理这种情况 // 这里简单返回一个错误标记节点 node-children.push_back(new ASTNode(ERROR, )); } return node; }实现完整的错误恢复比较复杂但对于实验能报告错误位置并优雅终止或者实现基础的恐慌模式就已经是很大的加分项了。4. 算符优先分析法的实现思路递归下降虽然直观但对于某些表达式文法算符优先分析法可能更高效或更易于理解优先级和结合性。这里简要提及其实现思路作为实验的另一种选择或扩展方向。4.1 算符优先关系表构建算符优先分析的核心是一张算符优先关系表。对于文法中的终结符运算符和括号我们需要定义它们之间的三种关系·低于、·高于、≐等于。例如对于和*通常有 · *因为*优先级更高* · 。我们可以用一个二维数组或std::map来存储这个关系表enum class Precedence { LESS, EQUAL, GREATER, ERROR }; std::mapstd::pairchar, char, Precedence precedenceTable; void initPrecedenceTable() { // 假设终结符有, -, *, /, (, ), id, $ // $ 作为输入结束符 precedenceTable[{*, }] Precedence::GREATER; precedenceTable[{*, -}] Precedence::GREATER; precedenceTable[{*, *}] Precedence::GREATER; // 左结合 precedenceTable[{*, /}] Precedence::GREATER; precedenceTable[{*, )}] Precedence::GREATER; precedenceTable[{*, $}] Precedence::GREATER; precedenceTable[{, }] Precedence::GREATER; // 左结合 precedenceTable[{, -}] Precedence::GREATER; precedenceTable[{, )}] Precedence::GREATER; precedenceTable[{, $}] Precedence::GREATER; precedenceTable[{, *}] Precedence::LESS; precedenceTable[{, /}] Precedence::LESS; precedenceTable[{(, }] Precedence::LESS; precedenceTable[{(, -}] Precedence::LESS; precedenceTable[{(, *}] Precedence::LESS; precedenceTable[{(, /}] Precedence::LESS; precedenceTable[{(, (}] Precedence::LESS; precedenceTable[{), )}] Precedence::EQUAL; precedenceTable[{), }] Precedence::GREATER; // ... 需要填充所有可能的终结符对 }4.2 分析算法与栈操作算符优先分析使用两个栈一个操作数栈用于存放ID/NUM等一个算符栈用于存放运算符和$等。算法流程大致如下初始化将$压入算符栈。将输入符号串末尾也加上$。令输入指针指向第一个输入符号。比较算符栈顶符号a和当前输入符号b的优先级关系R。如果R是·或≐则将b压入算符栈输入指针后移。如果R是·则进行规约从算符栈顶弹出运算符以及可能的操作数取决于你的实现形成一个语法单元如一个表达式节点然后将这个新单元作为“操作数”压回操作数栈或者作为一个整体处理。注意算符优先分析规约时可能不是规约到某个具体的非终结符而是规约一个“句柄”。重复步骤4直到算符栈顶为$且输入符号也是$分析成功。这个算法的实现细节较多特别是如何确定规约的句柄以及如何构建语法树。它更适合于对表达式进行快速分析且文法必须是算符优先文法任何两个终结符之间至多有一种优先关系且不含ε产生式等。5. 语法树的构建与可视化语法分析的结果除了简单的“Accept/Reject”更宝贵的是那棵语法树或抽象语法树AST。它是后续语义分析、中间代码生成的基础。5.1 在递归下降中构建AST前面RecursiveDescentParser的代码已经展示了如何构建一个具体的语法树CSTConcrete Syntax Tree它包含了所有非终结符和终结符节点。有时我们需要更精简的抽象语法树AST它只保留对后续阶段有意义的节点。例如对于表达式a b * c其AST可能是一个以为根左孩子是a右孩子是以*为根的子树左b右c而不会出现E,T这样的节点。修改递归下降函数使其返回更有意义的AST节点// AST节点类型枚举 enum class ASTType { BIN_OP, NUM, ID, PROGRAM }; struct ASTNode { ASTType type; std::string value; // 对于NUM/ID存储值对于BIN_OP存储操作符如 ASTNode* left nullptr; ASTNode* right nullptr; // 可以扩展更多字段如运算符类型枚举 ASTNode(ASTType t, const std::string v ) : type(t), value(v) {} }; // 修改后的 parseE 函数直接构造表达式的AST ASTNode* parseE() { // E - T { ( | -) T } // 使用扩展的BNF表示更直观 ASTNode* node parseT(); // 解析第一个项 while (peek() Token::PLUS || peek() Token::MINUS) { Token op lookahead; match(op.type); // 消费操作符 ASTNode* rightNode parseT(); // 解析下一个项 // 创建新的二元操作节点左子树是之前的节点右子树是新解析的项 ASTNode* newRoot new ASTNode(ASTType::BIN_OP, op.value); newRoot-left node; newRoot-right rightNode; node newRoot; // 更新当前节点为新的根 } return node; } ASTNode* parseT() { // T - F { (* | /) F } ASTNode* node parseF(); while (peek() Token::MUL || peek() Token::DIV) { Token op lookahead; match(op.type); ASTNode* rightNode parseF(); ASTNode* newRoot new ASTNode(ASTType::BIN_OP, op.value); newRoot-left node; newRoot-right rightNode; node newRoot; } return node; } ASTNode* parseF() { if (peek() Token::LPAREN) { match(Token::LPAREN); ASTNode* node parseE(); match(Token::RPAREN); return node; } else if (peek() Token::ID) { std::string idVal lookahead.value; match(Token::ID); return new ASTNode(ASTType::ID, idVal); } else if (peek() Token::NUM) { std::string numVal lookahead.value; match(Token::NUM); return new ASTNode(ASTType::NUM, numVal); } else { // 错误处理 throw std::runtime_error(Expected identifier, number, or (); } }这样构建出来的AST更简洁直接反映了表达式的结构。5.2 树的遍历与输出构建好树后我们可以通过遍历来验证结果或输出。常见的有先序、中序、后序遍历。对于表达式AST中序遍历可以还原出表达式但要注意括号问题后序遍历常用于生成后缀表达式或进行求值。void printAST(ASTNode* node, int depth 0) { if (!node) return; // 打印缩进 for (int i 0; i depth; i) std::cout ; // 根据节点类型打印信息 switch (node-type) { case ASTType::BIN_OP: std::cout Op: node-value std::endl; break; case ASTType::ID: std::cout ID: node-value std::endl; break; case ASTType::NUM: std::cout NUM: node-value std::endl; break; default: std::cout Unknown Node std::endl; } // 递归打印子树 printAST(node-left, depth 1); printAST(node-right, depth 1); } // 后序遍历求值假设都是数字 int evaluateAST(ASTNode* node) { if (!node) return 0; if (node-type ASTType::NUM) { return std::stoi(node-value); } // 必须是二元操作符节点 int leftVal evaluateAST(node-left); int rightVal evaluateAST(node-right); if (node-value ) return leftVal rightVal; if (node-value -) return leftVal - rightVal; if (node-value *) return leftVal * rightVal; if (node-value /) return leftVal / rightVal; // 注意除零错误 throw std::runtime_error(Unknown operator); }5.3 可视化工具推荐在命令行打印树结构不够直观。你可以将AST输出为特定格式然后用外部工具可视化Graphviz DOT语言为每个节点生成DOT描述然后用dot命令生成图片。这是非常经典和强大的方法。void generateDot(ASTNode* node, std::ostream out, int idCounter) { if (!node) return; int currentId idCounter; out node currentId [label\; switch (node-type) { case ASTType::BIN_OP: out node-value; break; case ASTType::ID: out ID\\n node-value; break; case ASTType::NUM: out NUM\\n node-value; break; } out \]; std::endl; if (node-left) { int leftId idCounter; generateDot(node-left, out, idCounter); out node currentId - node leftId ; std::endl; } if (node-right) { int rightId idCounter; generateDot(node-right, out, idCounter); out node currentId - node rightId ; std::endl; } } // 调用 std::ofstream dotFile(ast.dot); dotFile digraph AST { std::endl; int counter 0; generateDot(root, dotFile, counter); dotFile } std::endl; dotFile.close(); // 然后在命令行执行dot -Tpng ast.dot -o ast.png使用现成的库如tree.hh等C树结构库可能自带可视化或遍历功能。6. 实验中的常见问题与调试技巧6.1 无限递归与栈溢出这是递归下降分析器最常见的坑。原因通常有两个文法存在左递归未消除如果你的文法里有类似A - A α的产生式那么parseA函数会无条件地调用parseA导致无限递归。必须在实现前将文法改写为等价的非左递归形式。错误恢复逻辑陷入死循环在恐慌模式恢复中如果同步符号集设置不当可能永远无法遇到同步符号导致循环不断跳过输入。确保同步符号集包含能正常结束当前分析过程的符号。调试方法在递归函数的入口打印当前函数名和输入位置观察调用栈。或者使用调试器设置断点查看currentPos和lookahead的变化。6.2 优先级与结合性错误症状表达式a - b - c被错误地分析成a - (b - c)右结合而减法应该是左结合的。在递归下降中确保你的文法正确地编码了优先级和结合性。左结合通常通过产生式的递归结构来实现如E - T { (|-) T }右结合则需要不同的结构如E - T (|-) E。仔细检查你的文法规则和对应的解析函数。在算符优先中检查优先关系表。对于左结合运算符如-应该有a · a当a为-时的关系。确保关系表完整且正确。6.3 内存泄漏我们用了new来创建节点但示例中没有delete。在析构函数或单独的函数中实现树的销毁void deleteAST(ASTNode* node) { if (!node) return; deleteAST(node-left); deleteAST(node-right); delete node; }更好的方法是使用智能指针如std::unique_ptrASTNode让资源自动管理。6.4 错误信息不友好直接抛出runtime_error对用户不友好。应该尽可能收集错误上下文行号、列号、附近的Token并输出。在词法分析阶段就记录每个Token的位置在语法分析阶段遇到错误时将这些位置信息连同期望的Token类型一起输出。6.5 测试用例设计不要只用一两个正确的例子测试。全面的测试用例应包括正确用例简单的a b、复杂的a * (b c) - d / e、嵌套深的表达式。语法错误用例缺少操作数a 缺少运算符a b括号不匹配(a b非法字符a b运算符连续出现a * b边界用例空输入、非常长的输入、数字超大的输入。可以编写一个简单的测试框架循环读取测试文件中的用例并对比输出与预期结果。6.6 与词法分析的接口实验中语法分析通常需要调用词法分析器获取Token。设计清晰的接口很重要。可以让词法分析器成为一个独立的类语法分析器持有它的引用或指针。或者更简单点如我们之前所做先进行一次词法分析将所有Token存入vector再交给语法分析器。后者的好处是语法分析可以“前瞻”多个符号便于调试但会消耗更多内存。对于课程实验后者完全够用。7. 实验报告与扩展思考完成代码实现后实验报告也是重要一环。报告不应只是代码的粘贴而应体现你的思考过程。报告内容建议实验目的与要求简述。文法说明给出你使用的文法并解释其如何体现优先级和结合性为什么它是LL(1)的如果用了递归下降。核心数据结构解释Token、ASTNode等结构的设计意图。核心算法描述用流程图或伪代码描述递归下降或算符优先的分析过程。关键代码与注释展示核心函数如parseE,parseT的代码并加上关键步骤的注释。测试与结果分析展示你的测试用例集包括正确和错误输入并附上程序输出的截图或日志分析结果是否正确。遇到的问题与解决方案将你调试过程中遇到的主要坑和解决方法写下来这是报告最出彩的部分。总结谈谈通过实验对语法分析的理解以及实现过程中的收获。扩展思考加分项支持更多的运算符比如关系运算符,、逻辑运算符,||、赋值运算符。这需要扩展文法和词法分析器。实现一个简单的解释器在构建AST的基础上实现一个遍历AST进行求值的解释器甚至可以支持变量存储。对比不同分析方法如果你实现了递归下降可以尝试再实现算符优先并对比两者的代码复杂度、分析能力、错误处理等方面的差异。集成词法分析将实验一如果做了的词法分析器与本实验的语法分析器无缝对接形成一个完整的前端。可视化分析过程不仅可视化AST还可以尝试可视化分析栈的变化过程这对理解算法非常有帮助。语法分析实验是编译原理学习中的一个重要里程碑。它就像是你第一次亲手搭建起理解程序结构的桥梁。虽然过程中可能会被各种递归、优先关系、错误处理搞得焦头烂额但当你看到自己写的程序能正确识别出复杂的表达式结构甚至能画出漂亮的语法树时那种成就感是实实在在的。希望这篇长文能帮你理清思路少走弯路。记住多动手调试从最简单的文法开始逐步增加复杂度遇到问题先别急着问自己看看栈跟踪和输入输出往往就能找到答案。