简介:这份编译原理语法分析实验资料面向正在完成课程实验或准备CG实验平台评测的高校学生,聚焦依据给定文法、以递归子程序法识别各类语法成分这一核心任务。压缩包共2个文件,包含1份doc问题说明文档和1个cpp源码文件,整体约17KB,篇幅轻量却覆盖了从词法分析结果衔接到语法成分输出的关键环节。源码在CG实验平台上满分通过,可作为递归下降分析流程、预读处理以及按指定格式输出单词信息和语法成分名的参考实现。借助配套说明,读者能对照文法规则梳理各语法成分的分析顺序,理解testfile.txt输入与output.txt输出的组织方式,并排查输出格式、预读控制等常见问题。目前已有6110人学习,适合作为编译原理实验对照与查漏补缺的样例。
1. 从一份能跑通的语法分析实验工程说起:它能替你省掉哪些调试时间
编译原理 c++ 语法分析 的实验题,很多人是这么过的:词法分析器从第一版抄到第三版,到了语法分析就对着教材伪代码硬搬,交上去的工程能打印几条规则,换一段输入就崩。卡住的原因往往不是算法本身,而是没有一个能运行、能调试、能改文法的完整工程做参照。这个 C++ 语法分析实验工程解决的就是这件事:它把词法分析、递归下降解析、LL(1) 预测分析表构造和 AST 输出串在一条链路上,输入一段表达式或语句,输出语法树,文法出错能定位到产生式,输入出错能给出错行号。适合正在做编译原理课程设计的学生,也适合想补一补前端解析基础的 C++ 开发者。下面按目录结构、核心模块、运行方式、避坑和二次开发逐层拆开。
2. 技术选型与目录结构:C++17 + CMake 的最小解析器骨架
2.1 为什么用 C++17 + CMake,而不引入 Flex/Bison
这个工程的第一原则是「不藏逻辑」。词法分析和语法分析必须手写,不能靠生成器把过程吞掉,否则实验报告里只能写「调用了 yyparse」,答辩时问一句分析表怎么构造的就露馅。所以技术栈压到最小:C++17 标准库 + CMake,不引第三方依赖,Windows 上用 Visual Studio 或 MinGW 都能编,Linux 和 macOS 直接cmake --build。
C++17 在这里主要用三样东西:std::optional表示可能不存在的语法成分,std::string_view减少 Token 里字符串的拷贝,std::shared_ptr管理 AST 节点生命周期。常见做法是用std::variant做 Token 值,但这个工程为了新手能一眼看懂,Token 结构体里直接放std::string lexeme和数值字段,牺牲一点性能换可读性。CMake 最低版本写 3.16,因为要开cxx_std_17特性,老版本 CMake 对target_compile_features支持不够顺。
不引入 Google Test 是另一个取舍。语法分析实验的测试用例通常就是十几段输入加预期输出,自己写个assert宏足够,配置 GTest 反而会让第一次跑项目的人卡在拉依赖上。我会在test/目录放一个不依赖框架的test_main.cpp,用返回码判断通过与否。
2.2 目录结构:每个文件只负责一件事
工程目录按「输入 → 词法 → 语法 → 输出」拆开,头文件和实现分离,避免所有代码堆在一个main.cpp里:
syntax-analyzer/ ├── CMakeLists.txt ├── include/ │ ├── Token.h # Token 类型枚举与结构体 │ ├── Lexer.h # 词法分析器声明 │ ├── Grammar.h # 文法、产生式、FIRST/FOLLOW │ ├── Parser.h # 递归下降与 LL(1) 表驱动解析器 │ ├── AST.h # 语法树节点定义 │ └── ErrorHandler.h # 统一错误上报 ├── src/ │ ├── Lexer.cpp │ ├── Grammar.cpp │ ├── Parser.cpp │ ├── AST.cpp │ └── main.cpp ├── grammar/ │ └── expr.bnf # 示例文法,可替换 ├── test/ │ └── test_main.cpp └── README.mdToken.h只定义数据,不包含逻辑,这样Lexer和Parser都依赖它却不会互相依赖。Grammar.h把文法抽象成「非终结符集合 + 产生式列表 + FIRST/FOLLOW 表」,递归下降解析器里写死的表达式文法在Parser.cpp中单独实现一份,方便对照学习。ErrorHandler是个单例,记录行号、列号和错误消息,词法和语法阶段共用同一个错误出口,避免错误信息散落在各处。
grammar/expr.bnf不是给程序直接读的配置文件,而是给人看的文法说明,同时被test_main.cpp用来做一致性检查。常见做法是把文法硬编码进Grammar构造函数里,但这个工程把它拆出来,二次开发时改文法只需要动一个文件,不用在Parser.cpp里翻半天。
2.3 一次解析的数据流:从字符流到 AST 的完整链路
main.cpp的职责很薄:读文件、建 Lexer、拉 Token 列表、建 Parser、打印 AST。整个数据流是单向的,没有回调也没有全局状态,调试时在哪一步断了都容易定位。
// src/main.cpp:解析入口,保持薄 int main(int argc, char** argv) { if (argc < 2) { std::cerr << "usage: parser <source-file>\n"; return 1; } std::ifstream in(argv[1]); if (!in) { std::cerr << "cannot open " << argv[1] << "\n"; return 1; } std::string source((std::istreambuf_iterator<char>(in)), std::istreambuf_iterator<char>()); Lexer lexer(source); auto tokens = lexer.tokenize(); // 一次性拉完,方便做 LL(1) 预读 Parser parser(tokens); auto ast = parser.parse(); // 递归下降入口 if (ErrorHandler::instance().hasError()) { ErrorHandler::instance().dump(std::cerr); return 2; } ast->print(std::cout, 0); // 缩进打印语法树 return 0; }这里的关键参数是tokens:词法阶段一次性产出完整 Token 数组,而不是流式nextToken。LL(1) 需要看一个 Token 决定走哪条产生式,数组下标天然支持peek(k),比维护一个环形缓冲简单。代价是内存占用,但实验规模的输入通常几 KB,不值得优化。
ErrorHandler::instance()用单例是为了让Lexer和Parser不需要互相传错误对象,代价是测试时没法并行跑多个解析实例。如果二次开发要做批量测试,可以把单例改成引用传参,这个后面第 6 章会提。ast->print的第二个参数是缩进层级,递归打印时每层加两个空格,输出结果直接能贴进实验报告。
3. 词法分析器拆解:Token 流是怎么从字符流里扫出来的
3.1 Token 类型设计与词法规则表
词法分析的第一步是把「有哪些 Token」定死。枚举里既要有运算符和分隔符,也要有关键字,还要有Eof和Illegal两个哨兵。关键字不要单独建枚举值,而是先按标识符扫出来再查表,这样新增关键字只需要改一张unordered_map,不用动扫描循环。
// include/Token.h:Token 类型与结构 enum class TokenType { Identifier, Number, Plus, Minus, Star, Slash, LParen, RParen, LBrace, RBrace, Semi, Comma, Assign, If, Else, While, Return, Eof, Illegal }; struct Token { TokenType type; std::string lexeme; // 原始文本,报错和 AST 打印都用得上 int line = 1; int column = 1; };lexeme保留原始文本是有意为之:语法分析报错时不能只报TokenType::Plus,得告诉用户是第几行第几列的哪个符号。line和column从 1 开始,符合编辑器习惯,比从 0 开始少解释一句。
关键字表放在Lexer.cpp的匿名命名空间里:
// src/Lexer.cpp:关键字表,新增关键字只改这里 static const std::unordered_map<std::string, TokenType> kKeywords = { {"if", TokenType::If}, {"else", TokenType::Else}, {"while", TokenType::While}, {"return", TokenType::Return}, };标识符扫描完后查这张表,命中就返回关键字类型,否则返回Identifier。常见翻车点是先判断字符是不是字母,然后直接把整个词当标识符返回,忘了查表,结果if在语法分析阶段被当成变量名,报错信息完全对不上。这个坑第 5 章会展开。
3.2 手写 DFA 扫描器:标识符、数字、运算符的识别
nextToken是词法分析的核心循环,用pos_、line_、column_三个游标推进。空格和换行直接跳过,换行时line_++、column_重置为 1。字母或下划线进scanIdentifier,数字进scanNumber,其余符号走switch。
// src/Lexer.cpp:主扫描循环 Token Lexer::nextToken() { while (pos_ < src_.size()) { char c = src_[pos_]; if (c == '\n') { ++line_; column_ = 1; ++pos_; continue; } if (std::isspace(static_cast<unsigned char>(c))) { ++pos_; ++column_; continue; } if (std::isalpha(static_cast<unsigned char>(c)) || c == '_') { return scanIdentifier(); } if (std::isdigit(static_cast<unsigned char>(c))) { return scanNumber(); } switch (c) { case '+': return makeToken(TokenType::Plus, 1); case '-': return makeToken(TokenType::Minus, 1); case '*': return makeToken(TokenType::Star, 1); case '/': return makeToken(TokenType::Slash, 1); case '(': return makeToken(TokenType::LParen, 1); case ')': return makeToken(TokenType::RParen, 1); case '{': return makeToken(TokenType::LBrace, 1); case '}': return makeToken(TokenType::RBrace, 1); case ';': return makeToken(TokenType::Semi, 1); case ',': return makeToken(TokenType::Comma, 1); case '=': return makeToken(TokenType::Assign, 1); default: return scanIllegal(); } } return Token{TokenType::Eof, "", line_, column_}; }makeToken接收类型和长度,内部截取src_.substr(pos_, len)作为lexeme,然后推进pos_和column_。这里用长度而不是传字符串,避免每识别一个符号就构造一次std::string。
scanIdentifier和scanNumber的结构类似,都是「记住起点 → 吃掉所有合法字符 → 截取词素 → 查表或构造 Token」:
// src/Lexer.cpp:标识符扫描,扫完后查关键字表 Token Lexer::scanIdentifier() { size_t start = pos_; int startCol = column_; while (pos_ < src_.size() && (std::isalnum(static_cast<unsigned char>(src_[pos_])) || src_[pos_] == '_')) { ++pos_; ++column_; } std::string word = src_.substr(start, pos_ - start); auto it = kKeywords.find(word); TokenType type = (it != kKeywords.end()) ? it->second : TokenType::Identifier; return Token{type, word, line_, startCol}; // line_ 用当前行 } // 数字只支持整数,够表达式实验用 Token Lexer::scanNumber() { size_t start = pos_; int startCol = column_; while (pos_ < src_.size() && std::isdigit(static_cast<unsigned char>(src_[pos_]))) { ++pos_; ++column_; } return Token{TokenType::Number, src_.substr(start, pos_ - start), line_, startCol}; }两个函数都用了unsigned char强转,这是为了避开std::isspace对负char的未定义行为。在 UTF-8 输入里如果混进中文注释,char可能为负,不转就会在调试器里看到随机跳过字符的玄学现象。
3.3 词法错误与位置追踪:非法字符不能让程序直接崩
scanIllegal要做的不是简单返回Illegal就结束,而是把字符、行号、列号写进ErrorHandler,然后继续扫描。这样一次编译能把所有词法错误都报出来,而不是像某些教学实现那样遇到第一个错就exit(1)。
// src/Lexer.cpp:非法字符记录后继续 Token Lexer::scanIllegal() { char c = src_[pos_]; ErrorHandler::instance().report( line_, column_, std::string("illegal character '") + c + "'"); ++pos_; ++column_; return Token{TokenType::Illegal, std::string(1, c), line_, column_ - 1}; }返回的IllegalToken 仍然会进 Token 流,语法分析阶段看到它可以直接跳过或当作错误恢复点。这种「词法阶段只记录不中断」的策略是手写解析器的常见做法,代价是错误信息可能较多,但调试时比只报一个错更有用。
位置追踪有个细节:makeToken里推进column_时,多字符符号(比如以后要加==)要按实际长度加。当前文法只有单字符运算符,所以len传 1 没问题,但二次开发加==、!=时,switch里要先看下一个字符再决定长度,否则列号会偏。
4. 语法分析核心:递归下降与 LL(1) 表驱动的双实现
4.1 文法表示与 FIRST/FOLLOW 集计算
Grammar类把文法存成产生式列表,每个产生式有左部非终结符、右部符号序列、以及一个用于报错的编号。符号用字符串表示,终结符和非终结符不区分类型,靠「是否在非终结符集合里」判断。这种设计不追求工业级效率,但改文法时直观。
// include/Grammar.h:产生式与文法容器 struct Production { std::string lhs; // 左部非终结符 std::vector<std::string> rhs; // 右部符号序列 int id = -1; // 报错时定位用 }; class Grammar { public: void addProduction(const std::string& lhs, const std::vector<std::string>& rhs); void computeFirst(); void computeFollow(); const std::set<std::string>& firstOf(const std::string& symbol) const; const std::set<std::string>& followOf(const std::string& symbol) const; private: std::vector<Production> productions_; std::set<std::string> nonterminals_; std::map<std::string, std::set<std::string>> first_; std::map<std::string, std::set<std::string>> follow_; };computeFirst要对每个非终结符反复迭代到不动点,因为产生式右部第一个符号可能还是非终结符,它的 FIRST 集可能还没算完。常见做法是用while (changed)循环,直到一轮下来没有任何集合变大。
// src/Grammar.cpp:FIRST 集迭代到不动点 void Grammar::computeFirst() { bool changed = true; while (changed) { changed = false; for (const auto& p : productions_) { auto& target = first_[p.lhs]; size_t before = target.size(); // 逐个看右部符号,遇到不能推出 ε 的就停 bool allNullable = true; for (const auto& sym : p.rhs) { if (nonterminals_.count(sym)) { for (const auto& f : first_[sym]) { if (f != "ε") target.insert(f); } if (!first_[sym].count("ε")) { allNullable = false; break; } } else { target.insert(sym); allNullable = false; break; } } if (allNullable) target.insert("ε"); if (target.size() != before) changed = true; } } }computeFollow的逻辑更绕:开始符号的 FOLLOW 集先放$;对每个产生式A -> αBβ,把FIRST(β) - {ε}加进FOLLOW(B);如果 β 能推出 ε,再把FOLLOW(A)加进FOLLOW(B)。同样要迭代到不动点,否则嵌套产生式会漏。
4.2 递归下降分析器:表达式文法的落地
递归下降适合手写,每个非终结符对应一个函数,代码结构和文法几乎一一对应。表达式文法常见写法是分层处理优先级:Expr处理加减,Term处理乘除,Factor处理括号和数字。
// src/Parser.cpp:表达式递归下降,优先级靠调用层次体现 std::shared_ptr<ASTNode> Parser::parseExpr() { auto left = parseTerm(); // 先抓一个 Term while (check(TokenType::Plus) || check(TokenType::Minus)) { Token op = advance(); // 吃掉运算符 auto right = parseTerm(); left = std::make_shared<BinaryNode>(op.lexeme, left, right); } return left; } std::shared_ptr<ASTNode> Parser::parseTerm() { auto left = parseFactor(); while (check(TokenType::Star) || check(TokenType::Slash)) { Token op = advance(); auto right = parseFactor(); left = std::make_shared<BinaryNode>(op.lexeme, left, right); } return left; } std::shared_ptr<ASTNode> Parser::parseFactor() { if (check(TokenType::Number)) { Token t = advance(); return std::make_shared<NumberNode>(std::stod(t.lexeme)); } if (check(TokenType::LParen)) { advance(); auto inner = parseExpr(); expect(TokenType::RParen, "expected ')'"); return inner; } ErrorHandler::instance().report(peek().line, peek().column, "unexpected token in expression: " + peek().lexeme); advance(); // 跳过错误 Token,防止死循环 return std::make_shared<ErrorNode>(); }check看当前 Token 类型但不推进,advance返回当前 Token 并推进,expect在类型不匹配时记错误但也不抛异常。这种「错误不中断」的写法是为了让解析器尽量把整棵树建完,最后统一报错。parseFactor出错时返回ErrorNode而不是nullptr,避免上层的left->eval()之类的操作直接空指针崩溃。
递归下降的局限是左递归文法不能直接用。Expr -> Expr + Term写成parseExpr里先调parseExpr,会无限递归直到栈溢出。所以文法要先消除左递归,改成Expr -> Term Expr',代码里用while循环等价实现。第 5 章会对比错误写法和正确写法。
4.3 LL(1) 预测分析表构造与表驱动执行
递归下降是「代码即文法」,LL(1) 是「数据即文法」。Grammar算完 FIRST/FOLLOW 后,Parser可以构造一张二维表:行是非终结符,列是终结符,格子里放产生式编号。表驱动解析器用一个栈模拟最左推导,遇到非终结符查表,遇到终结符匹配输入。
// src/Parser.cpp:LL(1) 表驱动主循环 std::shared_ptr<ASTNode> Parser::parseWithTable(const Grammar& g) { std::stack<std::string> stk; stk.push("$"); stk.push(g.startSymbol()); // 开始符号压栈 size_t idx = 0; while (!stk.empty()) { std::string top = stk.top(); Token cur = tokens_[idx]; if (top == "$") { if (cur.type == TokenType::Eof) break; error(cur, "expected end of input"); return nullptr; } if (!g.isNonterminal(top)) { // 终结符:必须和当前 Token 对上 if (tokenMatches(top, cur)) { stk.pop(); ++idx; } else { error(cur, "expected '" + top + "'"); return nullptr; } continue; } // 非终结符:查预测分析表 int prodId = g.tableAt(top, tokenName(cur.type)); if (prodId < 0) { error(cur, "no production for " + top + " on " + tokenName(cur.type)); return nullptr; } stk.pop(); const auto& rhs = g.production(prodId).rhs; for (auto it = rhs.rbegin(); it != rhs.rend(); ++it) { if (*it != "ε") stk.push(*it); // ε 不入栈 } } return buildAstFromDerivation(); // 可选:表驱动也能建树 }stk里压的是字符串符号,不是 Token,这样终结符和非终结符可以混在一起判断。右部符号要逆序入栈,因为栈是后进先出,逆序压入才能保证最左符号先处理。ε产生式不压栈,相当于直接消掉一个非终结符。
tableAt的构造逻辑在Grammar::buildTable()里:对每个产生式A -> α,对FIRST(α)里每个终结符a,把产生式编号填进M[A][a];如果α能推出ε,再对FOLLOW(A)里每个终结符b填进去。冲突检测就是看格子里是不是已经有编号,有就报「该文法不是 LL(1)」。
表驱动的好处是改文法不用改代码,坏处是建树麻烦:栈里只有符号,没有语义动作,要建 AST 得在归约时额外记录。这个工程里表驱动主要用于验证文法合法性,建树仍然走递归下降,两条路径的输出可以对比,测试时互为参照。
4.4 AST 节点设计与打印:让语法树能贴进实验报告
AST 节点用基类 + 派生类,基类只留虚析构和print接口,派生类分别表示数字、二元运算和错误占位。节点之间用shared_ptr连接,避免手动delete,也避免循环引用(树结构天然无环)。
// include/AST.h:节点定义,print 用缩进表现层级 struct ASTNode { virtual ~ASTNode() = default; virtual void print(std::ostream& os, int indent) const = 0; }; struct NumberNode : ASTNode { double value; explicit NumberNode(double v) : value(v) {} void print(std::ostream& os, int indent) const override { os << std::string(indent, ' ') << "Number(" << value << ")\n"; } }; struct BinaryNode : ASTNode { std::string op; std::shared_ptr<ASTNode> left, right; BinaryNode(std::string o, std::shared_ptr<ASTNode> l, std::shared_ptr<ASTNode> r) : op(std::move(o)), left(std::move(l)), right(std::move(r)) {} void print(std::ostream& os, int indent) const override { os << std::string(indent, ' ') << "Binary(" << op << ")\n"; left->print(os, indent + 2); right->print(os, indent + 2); } }; struct ErrorNode : ASTNode { void print(std::ostream& os, int indent) const override { os << std::string(indent, ' ') << "Error\n"; } };print的缩进用std::string(indent, ' ')而不是循环输出空格,少一层循环,输出也更整齐。BinaryNode的构造函数用std::move接管指针,避免引用计数无谓增加。ErrorNode的存在是为了让「出错但还能继续」的路径有地方落,打印时能看到错误出现在树的哪个位置。
如果实验报告要求输出带括号的中缀表达式,可以加一个toInfix()虚函数,BinaryNode返回"(" + left->toInfix() + op + right->toInfix() + ")",NumberNode返回数值字符串。这个改动很小,但能让 AST 从「只能看」变成「能验证求值结果」,第 6 章会用它做二次开发的验证手段。
5. 避坑与排查:语法分析实验里最容易翻车的四件事
5.1 左递归导致递归下降栈溢出
现象:输入1+2+3+4时程序直接崩溃,调试器停在parseExpr的递归调用上,栈帧一层套一层。输入单个数字反而正常。
原因:文法写成了Expr -> Expr + Term | Term,parseExpr第一行就调parseExpr(),没有任何消费 Token 的动作,递归永远不会收敛。教材上的文法为了数学简洁经常保留左递归,但递归下降解析器不能直接吃。
解决:把文法改写成右递归形式Expr -> Term Expr'、Expr' -> + Term Expr' | ε,代码里用while循环消化+ Term序列,等价于消除了左递归。改写后的parseExpr就是 4.2 节那段代码,先调parseTerm拿到一个非空起点,再循环处理后续运算符。改完后同样输入1+2+3+4,栈深度是常数级,不会再溢出。注意消除左递归会改变结合性,1-2-3要保证解析成(1-2)-3而不是1-(2-3),while循环从左到右累积左子树正好满足左结合。
5.2 FIRST/FOLLOW 集漏算 ε 导致分析表缺项
现象:LL(1) 表驱动解析器在某个输入上停住,报「no production for Expr on RParen」,但文法里明明有Expr -> ε这条产生式。
原因:computeFirst里只处理了「右部第一个符号是非终结符」的情况,没有继续往后看。比如A -> B C,如果B能推出ε,那FIRST(A)还应该包含FIRST(C),代码里遇到B的 FIRST 不含ε就break,导致C的 FIRST 没并进来。FOLLOW集同理,A -> B C且C可空时,FOLLOW(A)要并进FOLLOW(B),漏了就会在表里缺格子。
解决:computeFirst用一个allNullable标志一路往后扫,只有遇到「不能推出 ε」的符号才停;computeFollow对每个产生式从右往左扫描,维护一个「当前后缀是否可空」的标记。改完后加一个自检:对每个非终结符,FIRST和FOLLOW都不应该为空(开始符号的 FOLLOW 至少有$),为空就说明迭代没收敛或者文法有孤立符号。这个自检放在Grammar::validate()里,启动时跑一遍,比等到解析时才发现缺项省时间。
5.3 关键字被当成标识符:词法查表顺序翻车
现象:输入if (x) { y = 1; },词法阶段把if标成Identifier,语法分析报「unexpected token in expression」,报错位置指着if,但用户觉得自己写的没问题。
原因:scanIdentifier扫完整个词后直接返回Identifier,忘了查关键字表;或者查表了但表里键写成了大写"IF",输入是小写,find返回end(),静默退化成标识符。还有一种情况是关键字表用std::map但比较函数被改过,导致大小写不敏感匹配,If和if混在一起。
解决:scanIdentifier扫完后必须查kKeywords,命中就返回对应类型;关键字表统一用小写键,输入侧不做大小写转换,保持「语言区分大小写」的语义。测试用例里专门放一条if = 1;,预期是语法错误「if 不能作为赋值目标」,如果词法阶段没认出if,这条用例会变成「未定义变量 if」,错误信息不对,一眼就能发现。另外,关键字表不要用std::map的operator[]查询,operator[]在键不存在时会插入默认值,把表搞脏,用find或count。
5.4 空输入和非法字符下的崩溃与错误恢复
现象:输入文件为空,或者只有一个(,程序段错误;调试器显示peek()里tokens_[idx]越界,或者parseFactor返回的nullptr被上层直接解引用。
原因:tokenize()在空输入时只返回一个Eof,parseExpr调parseTerm调parseFactor,parseFactor看到Eof不匹配任何分支,走到错误分支返回nullptr,上层BinaryNode构造时把nullptr当左子树,打印时解引用崩溃。另一种是peek()没做边界检查,idx已经等于tokens_.size()还在取。
解决:peek()和advance()都加边界保护,idx越界时返回合成的EofToken,不访问数组。parseFactor的错误分支返回ErrorNode而不是nullptr,保证树上每个指针都可解引用。Parser::parse()入口先判tokens_.empty(),空输入直接报「empty input」并返回ErrorNode。错误恢复策略用「跳过当前 Token 直到遇到;或}」的同步集,这样一条语句写错不会导致后面全部误报。改完后用空文件、单括号、连续运算符三组输入做回归,确认都走错误路径而不是崩溃路径。
6. 二次开发:把固定文法改成可配置的解析流程
6.1 把文法从代码里抽成配置文件
当前递归下降的文法是写死在Parser.cpp里的,加一条if语句就要改代码、重编译。二次开发的第一步是把文法抽到grammar/*.bnf,用简单的行格式描述产生式,Grammar构造函数读取文件并建表。递归下降仍然保留手写函数处理表达式,但语句部分可以走表驱动,两者共用同一份Grammar对象。配置格式不需要复杂,一行一条lhs -> rhs1 rhs2 | rhs3,ε用空右部表示,解析器启动时先做 LL(1) 冲突检测,有冲突就打印冲突的非终结符和终结符,让人改文法而不是改代码。
6.2 加一层语法制导翻译做表达式求值
AST 建好后,加一个evaluate()虚函数就能把语法分析变成计算器。NumberNode返回value,BinaryNode根据op递归求左右子树再运算,ErrorNode返回 0 并记一条错误。这个改动不到三十行,但能验证 AST 结构是否正确:如果1+2*3求值得到 7 而不是 9,说明parseTerm的优先级分层写反了。再进一步,可以把evaluate换成三地址码生成,每个BinaryNode分配一个临时变量,输出t1 = 2 * 3、t2 = 1 + t1这样的序列,正好衔接编译原理后续的语义分析实验。
6.3 验证改造是否正确的几个用例
改完文法或求值逻辑后,不要只跑一个1+2就收工。我一般固定跑这几组:1+2*3验证优先级,(1+2)*3验证括号,1-2-3验证左结合,空输入验证错误路径,if (x) { y = 1; }验证关键字和语句块,1+验证不完整表达式。每组不仅看退出码,还要看 AST 打印的层级和错误行号是否对得上。表驱动和递归下降两条路径对同一输入输出一致的 AST,才算改造没破坏原有逻辑。
我最早做这个实验时,改完文法只跑了一个用例就交,结果答辩现场被问「你这个 FOLLOW 集怎么算的」,翻代码发现computeFollow里有个break位置错了,嵌套产生式的 FOLLOW 全漏。从那以后每次改文法,我都先把Grammar::validate()和那六组用例强制走一遍,再动手写报告。希望帮到你。
本文还有配套的精品资源,点击获取