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 ) | id- E, T, F:分别代表表达式、项、因子。这种分层是为了处理运算符优先级(
*高于+)。 - E', T':引入这些非终结符是为了消除原始文法的左递归(例如
E -> E + T),使其适用于递归下降分析。 - ε:代表空串,用于处理“后面可能没有更多同级运算符”的情况。
注意:在动手写代码前,务必确认你拿到或设计的文法是LL(1)的,即适合递归下降。检查要点包括:消除左递归、提取左因子,并确保每个非终结符的各个产生式SELECT集不相交。如果文法不合适,递归下降程序会陷入无限递归或产生错误判断。
2.3 核心数据结构规划
用C++实现,我们需要规划好数据的存放方式。核心数据结构通常包括:
符号表与单词流:语法分析的输入通常是一个单词(Token)序列。每个Token至少包含类型(如标识符ID、整数NUM、运算符PLUS等)和值(如变量名“x”,数字“5”)。我们可以用一个
vector<Token>来存储,并维护一个索引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::vector<Token> tokens; size_t currentPos = 0;语法树节点:为了输出分析结果(如语法树),我们需要定义树节点。一个简单的节点可以包含节点类型(对应非终结符或终结符)、值、以及子节点指针。
class ASTNode { public: std::string type; // 如 "E", "T", "F", "ID", "NUM" std::string value; // 对于终结符,存储其词素 std::vector<ASTNode*> 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::vector<Token> tokenize() { std::vector<Token> tokList; Token tok; do { tok = getNextToken(); tokList.push_back(tok); } while (tok.type != Token::END); return tokList; } };3.2 递归下降函数编写
有了Token流,我们就可以根据文法编写递归下降函数了。每个非终结符对应一个函数,函数内部根据当前Token选择对应的产生式分支。
class RecursiveDescentParser { std::vector<Token> 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::vector<Token>& 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::map<std::pair<char, 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的代码已经展示了如何构建一个具体的语法树(CST,Concrete 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_ptr<ASTNode>),让资源自动管理。
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,还可以尝试可视化分析栈的变化过程,这对理解算法非常有帮助。
语法分析实验是编译原理学习中的一个重要里程碑。它就像是你第一次亲手搭建起理解程序结构的桥梁。虽然过程中可能会被各种递归、优先关系、错误处理搞得焦头烂额,但当你看到自己写的程序能正确识别出复杂的表达式结构,甚至能画出漂亮的语法树时,那种成就感是实实在在的。希望这篇长文能帮你理清思路,少走弯路。记住,多动手调试,从最简单的文法开始,逐步增加复杂度,遇到问题先别急着问,自己看看栈跟踪和输入输出,往往就能找到答案。