资讯详情

资讯详情

可运行可调试的C++语法分析实验工程:词法、递归下降与LL(1)实践

简介这份编译原理语法分析实验资料面向正在完成课程实验或准备CG实验平台评测的高校学生聚焦依据给定文法、以递归子程序法识别各类语法成分这一核心任务。压缩包共2个文件包含1份doc问题说明文档和1个cpp源码文件整体约17KB篇幅轻量却覆盖了从词法分析结果衔接到语法成分输出的关键环节。源码在CG实验平台上满分通过可作为递归下降分析流程、预读处理以及按指定格式输出单词信息和语法成分名的参考实现。借助配套说明读者能对照文法规则梳理各语法成分的分析顺序理解testfile.txt输入与output.txt输出的组织方式并排查输出格式、预读控制等常见问题。目前已有6110人学习适合作为编译原理实验对照与查漏补缺的样例。1. 从一份能跑通的语法分析实验工程说起它能替你省掉哪些调试时间编译原理 c 语法分析 的实验题很多人是这么过的词法分析器从第一版抄到第三版到了语法分析就对着教材伪代码硬搬交上去的工程能打印几条规则换一段输入就崩。卡住的原因往往不是算法本身而是没有一个能运行、能调试、能改文法的完整工程做参照。这个 C 语法分析实验工程解决的就是这件事它把词法分析、递归下降解析、LL(1) 预测分析表构造和 AST 输出串在一条链路上输入一段表达式或语句输出语法树文法出错能定位到产生式输入出错能给出错行号。适合正在做编译原理课程设计的学生也适合想补一补前端解析基础的 C 开发者。下面按目录结构、核心模块、运行方式、避坑和二次开发逐层拆开。2. 技术选型与目录结构C17 CMake 的最小解析器骨架2.1 为什么用 C17 CMake而不引入 Flex/Bison这个工程的第一原则是「不藏逻辑」。词法分析和语法分析必须手写不能靠生成器把过程吞掉否则实验报告里只能写「调用了 yyparse」答辩时问一句分析表怎么构造的就露馅。所以技术栈压到最小C17 标准库 CMake不引第三方依赖Windows 上用 Visual Studio 或 MinGW 都能编Linux 和 macOS 直接cmake --build。C17 在这里主要用三样东西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_iteratorchar(in)), std::istreambuf_iteratorchar()); 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.hToken 类型与结构 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_mapstd::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_castunsigned char(c))) { pos_; column_; continue; } if (std::isalpha(static_castunsigned char(c)) || c _) { return scanIdentifier(); } if (std::isdigit(static_castunsigned 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_castunsigned 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_castunsigned 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::vectorstd::string rhs; // 右部符号序列 int id -1; // 报错时定位用 }; class Grammar { public: void addProduction(const std::string lhs, const std::vectorstd::string rhs); void computeFirst(); void computeFollow(); const std::setstd::string firstOf(const std::string symbol) const; const std::setstd::string followOf(const std::string symbol) const; private: std::vectorProduction productions_; std::setstd::string nonterminals_; std::mapstd::string, std::setstd::string first_; std::mapstd::string, std::setstd::string follow_; };computeFirst要对每个非终结符反复迭代到不动点因为产生式右部第一个符号可能还是非终结符它的 FIRST 集可能还没算完。常见做法是用while (changed)循环直到一轮下来没有任何集合变大。// src/Grammar.cppFIRST 集迭代到不动点 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_ptrASTNode Parser::parseExpr() { auto left parseTerm(); // 先抓一个 Term while (check(TokenType::Plus) || check(TokenType::Minus)) { Token op advance(); // 吃掉运算符 auto right parseTerm(); left std::make_sharedBinaryNode(op.lexeme, left, right); } return left; } std::shared_ptrASTNode Parser::parseTerm() { auto left parseFactor(); while (check(TokenType::Star) || check(TokenType::Slash)) { Token op advance(); auto right parseFactor(); left std::make_sharedBinaryNode(op.lexeme, left, right); } return left; } std::shared_ptrASTNode Parser::parseFactor() { if (check(TokenType::Number)) { Token t advance(); return std::make_sharedNumberNode(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_sharedErrorNode(); }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.cppLL(1) 表驱动主循环 std::shared_ptrASTNode Parser::parseWithTable(const Grammar g) { std::stackstd::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_ptrASTNode left, right; BinaryNode(std::string o, std::shared_ptrASTNode l, std::shared_ptrASTNode 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 左递归导致递归下降栈溢出现象输入1234时程序直接崩溃调试器停在parseExpr的递归调用上栈帧一层套一层。输入单个数字反而正常。原因文法写成了Expr - Expr Term | TermparseExpr第一行就调parseExpr()没有任何消费 Token 的动作递归永远不会收敛。教材上的文法为了数学简洁经常保留左递归但递归下降解析器不能直接吃。解决把文法改写成右递归形式Expr - Term Expr、Expr - Term Expr | ε代码里用while循环消化 Term序列等价于消除了左递归。改写后的parseExpr就是 4.2 节那段代码先调parseTerm拿到一个非空起点再循环处理后续运算符。改完后同样输入1234栈深度是常数级不会再溢出。注意消除左递归会改变结合性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()在空输入时只返回一个EofparseExpr调parseTerm调parseFactorparseFactor看到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返回valueBinaryNode根据op递归求左右子树再运算ErrorNode返回 0 并记一条错误。这个改动不到三十行但能验证 AST 结构是否正确如果12*3求值得到 7 而不是 9说明parseTerm的优先级分层写反了。再进一步可以把evaluate换成三地址码生成每个BinaryNode分配一个临时变量输出t1 2 * 3、t2 1 t1这样的序列正好衔接编译原理后续的语义分析实验。6.3 验证改造是否正确的几个用例改完文法或求值逻辑后不要只跑一个12就收工。我一般固定跑这几组12*3验证优先级(12)*3验证括号1-2-3验证左结合空输入验证错误路径if (x) { y 1; }验证关键字和语句块1验证不完整表达式。每组不仅看退出码还要看 AST 打印的层级和错误行号是否对得上。表驱动和递归下降两条路径对同一输入输出一致的 AST才算改造没破坏原有逻辑。我最早做这个实验时改完文法只跑了一个用例就交结果答辩现场被问「你这个 FOLLOW 集怎么算的」翻代码发现computeFollow里有个break位置错了嵌套产生式的 FOLLOW 全漏。从那以后每次改文法我都先把Grammar::validate()和那六组用例强制走一遍再动手写报告。希望帮到你。本文还有配套的精品资源点击获取
觉得有用,分享给同行:

为您的企业打造数字门面

稳重轻奢商务风格,端正雅致视觉,长效耐看不易过时。

立即咨询 →