与SLR(1)实战笔记)
简介编译原理课程的语法分析实验常因递归子程序法实现繁琐而难以下手这份C代码包恰好提供了可直接参考的完整方案适合正在完成编译原理课设、准备期末考试或需要应对OJ平台自动评测的高校学生。资源压缩包仅17KB只含2个文件1个doc实验说明文档和1个C源文件。doc文档完整复现了实验要求包括待编译源文件统一命名为testfile.txt、结果统一输出至output.txt的具体格式规范以及按词法分析顺序每行输出单词信息、并在指定语法成分分析结束前另起一行输出成分名的评测细则cpp源码则衔接上次词法分析作业的结果用递归子程序法覆盖文法中定义的全部语法成分识别对未要求输出的成分也会照常分析整体流程与自动评测完全对齐。代码已在CG实验平台满分通过相当于一套经过实战验证的标准参考实现。截至目前已有6107人学习下载对于需要比对评测维度、研究递归下降写法的读者来说是很有价值的对比样本。1. 语法分析实验不是“读代码”是让你动手造一个解析器编译原理课走到中段老师抛下一个“语法分析实验”的题目很多人第一反应是上网找一个能跑的 C 源码改一改交差。越往后你越会发现这个实验恰恰是最不该跳过的词法分析是线性扫描只要状态机画对代码写出来八九不离十语法分析却要把一个上下文无关文法变成能识别句子的分析器从 FIRST 集、FOLLOW 集到预测分析表或状态集任何一个环节算错结果就静默地错下去还特别难查。用 C 实现一遍语法分析等于把课堂上的形式化定义全部落到具体的数据结构和循环里做完之后你对 LL(1)、LR(0) 这些名词的体感完全不一样。这篇笔记写给三类人正在写编译原理实验的学生、考研复试前想补动手能力的读者、以及工作多年但想回头把编译器基础补扎实的工程师。2. 从 LL(1) 到 SLR(1)实验选型与 C 工程骨架2.1 做 LL(1) 还是做 LR 系列先看实验要求再决定投入实验题写“预测分析”时默认指 LL(1)写“自底向上分析”时常见要求是 SLR(1) 或 LR(1)。这两个方向的工作量差距很大选型直接影响你能不能在截止前写完。LL(1) 的思路是“自上而下推导”从开始符号出发看当前输入符号决定用哪条产生式展开。它的优点在代码量小核心就是三个东西——FIRST 集、FOLLOW 集、二维查表驱动的循环C 写下来两百行出头就能跑调试时每一步推导打印出来人工就能核对。缺点是文法必须满足 LL(1) 条件遇到左递归必须先消除提左因子处理完经常发现文法已经被改得不像原样了。LR 系列是“自底向上归约”从左到右读输入把读到的符号压栈一旦栈顶形成某个产生式的右部就做一次归约。它对文法的限制少得多常见的算术表达式文法不需要动就能直接用但代价是要先构造项目集规范族再生成 ACTION/GOTO 表代码量和出错率都明显上升。我通常建议实验要求明确“预测分析器”就死心塌地做 LL(1)要求里出现“自底向上”再做 SLR(1)。如果老师只给了一个笼统的“语法分析”题目优先 LL(1)时间富余再往 LR 扩展。2.2 C 工程怎么搭数据结构比代码逻辑更值得先想清楚做这个实验最忌讳一上来就写 main 函数我一般先把四个文件立好Grammar.h装文法数据结构FirstFollow.cpp算 FIRST 和 FOLLOWParser.cpp放查表驱动main.cpp负责读文法和读输入串。数据结构的选型直接决定后面每个函数好不好写CLion 或 VS 里建新项目时C 标准选 C17别用太老的默认配置。文法在内存里怎么表示是第一个关键决定。产生式右部如果当成一个字符串后面求 FIRST 集时还要反复切分更稳的做法是每个产生式右部直接存成vectorstring符号之间在读文件时就用空格切好。终结符、非终结符用std::setstd::string区分空串用一个不可能跟普通符号冲突的常量表示我习惯用。这些选择看着琐碎实际是做后面所有算法的基础。// Grammar.h #pragma once #include string #include vector #include set #include map const std::string EPSILON ; // 用 表示 ε避免读入文件时跟空白字符混淆 const std::string END #; // 输入结束符 struct Production { std::string lhs; // 产生式左部一定是非终结符 std::vectorstd::string rhs; // 产生式右部终结符/非终结符/ε 的序列 }; struct Grammar { std::setstd::string terminals; std::setstd::string nonTerminals; std::vectorProduction prods; std::string start; // 开始符号 void addProduction(const std::string lhs, const std::vectorstd::string rhs) { prods.push_back({lhs, rhs}); nonTerminals.insert(lhs); } };这里把nonTerminals在addProduction里自动收集终结符需要单独指定是因为实验输入文件通常会把终结符声明写在前面。右部里出现的符号如果在非终结符集合里查不到就默认归入终结符这样做能少写不少重复代码。EPSILON用还有一个实际好处如果文件里用希腊字母 ε不同编辑器的编码处理会带来一堆莫名其妙的匹配问题。2.3 工程配置和运行时的坑先把环境跑起来再写算法C 实验最常见的翻车不在算法在环境。如果你用 Visual Studio 写默认是动态链接运行库把 exe 拷到实验机上运行很容易弹一个“缺少 VCRUNTIME140.dll”之类的错误框对应的正是 Microsoft Visual C Redistributable 没装或版本不对。解决办法是在自己机器上把工程属性里的“运行库”从“多线程 DLL”改成“多线程”也就是静态链接/MT这样 exe 不再依赖外部运行库如果坚持动态链接就把对应版本的 Visual C Redistributable 一起拷过去。用 VS Code 写 C 的话需要自己配好 tasks.json 和 launch.json编译器我建议用 MinGW-w64 或直接装完整版 Visual Studio 的 MSVC。配环境本身不难但注意一点VS Code 默认的调试器路径经常找不到报错时先确认gdb或cdb路径正确。环境跑通的标准很简单——新建一个只打印hello的 cpp 文件编译运行通过再开始写文法数据结构的代码。别在环境没通的时候就开始写算法否则你根本分不清是算法错还是环境错。3. 用 C 实现 LL(1) 分析器FIRST 集、FOLLOW 集与预测分析表3.1 文法输入与 FIRST 集的不动点迭代实现求 FIRST 集有两种常见写法一种是按定义做 DFS 递归代码短但容易在左递归文法上死循环还得维护访问标记另一种是不动点迭代反复扫描所有产生式直到任何集合都不再变化。我推荐后者它几乎不用动脑考虑“递归会不会绕回自己”只要循环里有changed标志集合没变化就退出天然免疫左递归。下面这段代码把 FIRST 集存在std::mapstd::string, std::setstd::string里每个终结符和每个非终结符都有一个集合。// FirstFollow.cpp —— 求 FIRST 集 std::mapstd::string, std::setstd::string computeFirst(const Grammar G) { std::mapstd::string, std::setstd::string first; // 终结符的 FIRST 集就是它自己非终结符先给空集合占位 for (const std::string t : G.terminals) { first[t].insert(t); } for (const std::string n : G.nonTerminals) { first[n] {}; } // 不动点迭代只要能往里加新元素就继续直到所有集合稳定 for (;;) { bool changed false; for (const Production p : G.prods) { // 情况一A - ε直接把 ε 加进 FIRST(A) if (p.rhs.size() 1 p.rhs[0] EPSILON) { if (first[p.lhs].insert(EPSILON).second) changed true; continue; } // 情况二处理右部每个符号逐个传递 FIRST 集合 bool rightAllNullable true; // 右部是否全部可空 for (const std::string sym : p.rhs) { for (const std::string a : first[sym]) { // 传递时不带 εε 只在本产生式整体可空时单独处理 if (a ! EPSILON first[p.lhs].insert(a).second) changed true; } if (!first[sym].count(EPSILON)) { rightAllNullable false; break; } } if (rightAllNullable) { if (first[p.lhs].insert(EPSILON).second) changed true; } } if (!changed) break; // 没有任何集合增长迭代收敛 } return first; }first[sym]在sym是终结符时已经含它自身所以循环里对非终结符的传递和对终结符的传递走的同一套逻辑。std::set::insert返回pairiterator, bool.second表示是否真的插入了新元素这正好用来驱动changed标志——如果集合元素没有任何增长说明 FIRST 集已经固定再迭代也只是浪费时间。迭代次数不需要人工设上限理论的收敛性由集合有限性和单调增长保证实际跑文法时通常几十轮就稳定了。这里有个容易错的细节当A - B C且B可空时FIRST(C) 也得并入 FIRST(A)代码里rightAllNullable的循环正是为了解决这个“可空符号链”的传递问题。3.2 FOLLOW 集求解两个规则的顺序别搞反FOLLOW 集是在 FIRST 集算完之后才能求的因为它要用到 FIRST 集的结果。规则只有两条第一A - α B β时把 FIRST(β) 中除 ε 外的所有终结符加进 FOLLOW(B)第二如果 β 可以推导出 ε或者 β 根本不存在那把 FOLLOW(A) 整个并入 FOLLOW(B)。初始时开始符号的 FOLLOW 集里要放一个END也就是输入结束符#这是句子被完整识别的标志。// FirstFollow.cpp —— 求 FOLLOW 集 std::mapstd::string, std::setstd::string computeFollow( const Grammar G, const std::mapstd::string, std::setstd::string first) { std::mapstd::string, std::setstd::string follow; for (const std::string n : G.nonTerminals) follow[n] {}; follow[G.start].insert(END); // 开始符号的 FOLLOW 一定含 #表示句子结束 for (;;) { bool changed false; for (const Production p : G.prods) { for (size_t i 0; i p.rhs.size(); i) { const std::string B p.rhs[i]; if (G.terminals.count(B)) continue; // 终结符没有 FOLLOW 集 // 规则一把右侧剩余部分的 FIRST 集加进来 bool suffixAllNullable true; for (size_t j i 1; j p.rhs.size(); j) { const std::string s p.rhs[j]; for (const std::string a : first[s]) { if (a ! EPSILON follow[B].insert(a).second) changed true; } if (!first[s].count(EPSILON)) { suffixAllNullable false; break; } } // 规则二后缀全部可空或没有后缀时并入左部的 FOLLOW if (suffixAllNullable) { for (const std::string a : follow[p.lhs]) { if (follow[B].insert(a).second) changed true; } } } } if (!changed) break; } return follow; }跟 FIRST 集一样FOLLOW 集也要用不动点迭代因为规则二里 FOLLOW(A) 可能在未来几轮迭代中继续增长然后传导给 FOLLOW(B)。两个规则的顺序写反会出现漏算有些教程把规则二写在规则一前面但规则二依赖的 FOLLOW(p.lhs) 可能是这一轮还没更新到位的旧值多迭代几轮也能收敛不过把更依赖新值的规则二放到内层循环末尾收敛速度会快一些。手动验证时拿E - T E这种经典文法的几个非终结符过一遍比直接跑程序更容易找到直觉FOLLOW(E) 是#和)FOLLOW(E) 也是#和)看程序输出跟手算一致了再到下一步。3.3 构造 LL(1) 预测分析表一条产生式怎么填进格子预测分析表是一个二维表行是非终结符列是终结符再加末尾的#表项放产生式编号。填表规则硬记会乱我习惯用一句话理解产生式A - α能指导分析的条件是“当前输入符号 a 可能从 α 推导出来”这个条件由FIRST(α)描述若 α 可空则 a 也可能来自FOLLOW(A)。所以一条产生式可能填进多个格子里而且填进 FOLLOW 格子的前提是 α 可空。// Table.cpp —— 构造 LL(1) 预测分析表 std::mapstd::pairstd::string, std::string, int buildTable( const Grammar G, const std::mapstd::string, std::setstd::string first, const std::mapstd::string, std::setstd::string follow) { // key 是 (非终结符, 终结符)value 是产生式编号-1 表示无表项报错 std::mapstd::pairstd::string, std::string, int table; for (int pi 0; pi (int)G.prods.size(); pi) { const Production p G.prods[pi]; // 先求 FIRST(右部 α) std::setstd::string alphaFirst; bool alphaNullable true; for (const std::string s : p.rhs) { for (const std::string a : first[s]) alphaFirst.insert(a); if (!first[s].count(EPSILON)) { alphaNullable false; break; } } if (alphaNullable) alphaFirst.insert(EPSILON); // 规则一对 FIRST(α) 中的每个终结符填表 for (const std::string a : alphaFirst) { if (a ! EPSILON) table[{p.lhs, a}] pi; } // 规则二若 α 可空对 FOLLOW(A) 填表此时输入符号就是 # if (alphaNullable) { for (const std::string b : follow[p.lhs]) { table[{p.lhs, b}] pi; } } } return table; }为什么表项存索引 int而不是整个Production一是省内存二是驱动循环拿到编号后可以直接去prods[pno]取右部打印推导步骤时也更方便。这里有个必须警惕的点查表不能写成table[{X, a}]直接下标访问因为 C 的map下标操作在键不存在时会默认插入一个 0 值导致错误表项被当成产生式 0 使用。我所有查表的地方都先find找不到就报语法错误这两条路径不能混。如果一张表里同一个格子被两条不同产生式填了这个文法就不是 LL(1) 文法。算完表后扫一遍全表检查有没有格子已经有两个值有就直接向老师报告“该文法不是 LL(1)”而不是硬着头皮继续写驱动。3.4 LL(1) 驱动循环栈、输入串和推导过程打印分析器本体是一个循环核心是维护一个栈栈里放文法符号初始时压入#和开始符号。每一次循环读栈顶符号 X 和当前输入符号 a如果 X 是终结符或#它们必须相等相等就弹栈并移动输入指针如果 X 是非终结符查表得到要用的产生式弹掉 X 后把右部逆序压栈查不到表项则报错。我习惯在每一步把推导用的产生式和当前栈打印出来实验报告里这几行输出就是最好的“分析过程”证明材料。// Parser.cpp —— LL(1) 驱动单步 bool ll1Step(std::vectorstd::string stack, // 传引用每次调用修改栈 size_t inputPos, const std::vectorstd::string input, const Grammar G, const std::mapstd::pairstd::string, std::string, int table) { std::string X stack.back(); // 栈顶符号 std::string a input[inputPos]; // 当前输入符号 // 栈顶是终结符或 #必须与输入符号相等 if (G.terminals.count(X) || X END) { if (X a) { stack.pop_back(); inputPos; return true; } std::cerr terminal mismatch: expect X but got a std::endl; return false; } // 栈顶是非终结符查预测分析表 auto it table.find({X, a}); if (it table.end()) { std::cerr no table entry for [ X , a ] std::endl; return false; } int pno it-second; const Production p G.prods[pno]; stack.pop_back(); // 弹出即将被展开的非终结符 // 右部逆序压栈ε 不压栈 for (auto rit p.rhs.rbegin(); rit ! p.rhs.rend(); rit) { if (*rit ! EPSILON) stack.push_back(*rit); } // 打印推导步骤实验报告里直接能用作过程记录 std::cout use: p.lhs - ; for (const auto s : p.rhs) std::cout s ; std::cout \nstack now: ; for (const auto s : stack) std::cout s ; std::cout \n; return true; }主循环的终止条件是栈顶和输入串同时到达#也就是栈里只剩一个#且inputPos指向末尾末符号。判断匹配时有个顺序问题先看“栈顶是终结符还是非终结符”再看“是否相等”两个分支不能颠倒。我在实验里见过有人把X a写在判断 X 类型之前导致#和终结符都匹配了但非终结符展开逻辑被跳过。输入串的预处理也要注意提前把字符串按空格拆成vectorstring别在驱动循环里逐字符读否则#符号和终结符的边界会变得非常混乱。4. 用 C 实现 SLR(1) 分析器项目集规范族与 ACTION/GOTO 表4.1 项目管理这条产生式当前推进到哪个位置LR 分析的核心概念是“项目”。一个项目就是一条产生式加一个圆点圆点左边的符号表示已经看到了右边的表示还没看到。比如产生式E - E T对应三个项目E - ·E T、E - E· T、E - E ·T、E - E T·。在 C 里一个项目可以用pairint, int表示第一个值是产生式编号第二个值是圆点位置——正好是rhs数组的下标圆点在最后的项目叫“规约项目”。构造项目集规范族时第一步是把增广文法S - S的项目(0, 0)做闭包得到初始状态。闭包操作的意义是圆点右边如果是一个非终结符那么所有以这个非终结符为左部的产生式它们的“圆点在开头”的项目也要加入当前状态因为这些产生式随时可能被用来展开。// LR.cpp —— 项目类型与闭包、GO using Item std::pairint, int; // (产生式编号, 圆点位置) // 求一个项目集的闭包圆点右侧的非终结符要展开它的所有产生式 std::setItem closure(const std::setItem items, const Grammar G) { std::setItem J items; for (;;) { bool changed false; for (const Item it : J) { int pi it.first, dot it.second; const auto rhs G.prods[pi].rhs; if (dot (int)rhs.size()) continue; // 圆点在末尾是规约项目 const std::string X rhs[dot]; if (G.terminals.count(X)) continue; // 终结符不需要展开 // 对所有左部为 X 的产生式加入圆点在开头的项目 for (int q 0; q (int)G.prods.size(); q) { if (G.prods[q].lhs X) { if (J.insert({q, 0}).second) changed true; } } } if (!changed) break; } return J; } // GOTO(I, X)圆点越过符号 X 后再求一次闭包 std::setItem goTo(const std::setItem I, const std::string X, const Grammar G) { std::setItem J; for (const Item it : I) { int pi it.first, dot it.second; const auto rhs G.prods[pi].rhs; // 圆点位置正好指向 X就把圆点右移一位并加入新集合 if (dot (int)rhs.size() rhs[dot] X) { J.insert({pi, dot 1}); } } return closure(J, G); }闭包和 GO 都带changed标志因为闭包可能加入新项目后继续触发新展开需要迭代到稳定。这个迭代的存在是 LR 状态数量暴增的直接原因也是理解 LALR 表为什么比 LR(1) 小的关键起点。这里一个常见的小坑pairint, int在std::set里的默认比较是字典序正好给项目集提供了确定性排序输出调试时项目顺序可复现不会每次运行都乱序。4.2 从规范族生成 SLR(1) 的 ACTION 与 GOTO规约条件的核得到项目集规范族后每个项目集编号就是一个状态。ACTION 表告诉你面对当前输入符号该“移进”还是“规约”GOTO 表告诉你归约后左部非终结符该转到哪个状态。SLR(1) 跟 LR(0) 的唯一区别在规约条件的判断LR(0) 只要圆点在末尾就无脑规约SLR(1) 要求当前输入符号必须在左部非终结符的 FOLLOW 集里这能消除一部分“移进-规约冲突”。// LR.cpp —— 由规范族构造 ACTION/GOTO 表示意核心逻辑 // tableAction[{state, terminal}] 存储 // pairchar, int{s, 目标状态} 移进{r, 产生式编号} 规约{a,-1} 接受 void buildLRTable(const Grammar G, const std::vectorstd::setItem states, const std::mapstd::string, std::setstd::string follow, std::mapstd::pairint, std::string, std::pairchar, int action, std::mapstd::pairint, std::string, int goToTable) { for (int i 0; i (int)states.size(); i) { for (const Item it : states[i]) { int pi it.first, dot it.second; const auto rhs G.prods[pi].rhs; // 圆点不在末尾对符号 X 移进到 GO(I, X) 所在状态 if (dot (int)rhs.size()) { const std::string X rhs[dot]; if (G.terminals.count(X)) { int j getStateIndex(states, goTo(states[i], X, G)); action[{i, X}] {s, j}; // shift } else { int j getStateIndex(states, goTo(states[i], X, G)); goToTable[{i, X}] j; } } else { // 圆点在末尾SLR 规约的前提是 a ∈ FOLLOW(left) if (pi 0 rhs.size() 1) { action[{i, END}] {a, -1}; // 接受 } else { for (const std::string a : follow.at(G.prods[pi].lhs)) { action[{i, a}] {r, pi}; // reduce } } } } } }getStateIndex的作用是在状态集合里找到完全相同的项目集所对应的编号因为goTo计算出来的集合可能已经在规范族里存在不能重复创建状态。这个函数我自己第一次写的时候偷懒用了两层 for 循环逐个比对std::set文法小的时候没关系状态数上百以后性能会明显下降所以建议在构造规范族的同时用一个std::mapstd::setItem, int做状态的唯一映射建立编号-集合双索引。ACTION 表里同一个(状态, 终结符)被赋了两次值就是冲突SLR(1) 无法处理时要考虑换成 LR(1) 或改写文法。4.3 SLR(1) 驱动循环一栈一表归约时回退状态SLR 的驱动比 LL(1) 更依赖状态栈状态栈和符号栈必须同步变化。移进时压入当前输入符号再压入新的状态规约时弹出右部符号长度的状态和符号再根据新栈顶状态和左部非终结符查 GOTO 表压入新状态。这一步很容易写反归约只弹右部长度个状态不是弹一个右部是三个符号的就弹三个弹少了状态栈和符号栈就错位了。// LRDriver.cpp —— SLR 归约动作 int reduceOn(int pno, // 要归约的产生式编号 std::vectorint stateStack, // 状态栈 std::vectorstd::string symStack, const Grammar G, const std::mapstd::pairint, std::string, int goToTable) { const Production p G.prods[pno]; int len (int)p.rhs.size(); // 弹出右部长度的状态和符号 for (int k 0; k len; k) { stateStack.pop_back(); symStack.pop_back(); } // 压入左部非终结符按新栈顶状态查 GOTO 表 symStack.push_back(p.lhs); int top stateStack.back(); int nextState goToTable.at({top, p.lhs}); stateStack.push_back(nextState); return nextState; }注意代码里的goToTable.at(...)为什么不直接用[]因为括号下标在键不存在时会插入默认元素LR 表里查不到 GOTO 目标通常是文法或状态构造出了问题应该立刻暴露而不是默默把 0 号状态塞进去。我一般会在主循环里卡一个硬性步骤上限比如maxSteps 10000超过就报“分析未在限定步数内完成”防止规约逻辑写错导致无限循环把控制台刷爆。SLR 驱动跟 LL(1) 驱动最大的差别是驱动本身几乎不直接判断符号它只做“查 ACTION 表→执行动作→查 GOTO 表”理解这个间接层也就理解了 LR 分析比 LL 分析难在哪。5. 语法分析最容易翻车的 5 个坑现象、原因、解决5.1 程序一运行就报缺少 DLL 或直接闪退现象代码在自己机器上编译通过拷到实验机或交到助教那边执行双击 exe 弹窗提示缺少VCRUNTIME140.dll/MSVCP140.dll或者什么都没提示直接闪退。原因MSVC 编译的 exe 默认依赖动态运行库目标机器没有安装对应的 Microsoft Visual C Redistributable 运行时组件。实验机器上经常只装了某些软件的运行时版本和你本机的不一致动态链接的 exe 就起不来。解决工程属性 → C/C → 代码生成 → 运行库改为“多线程 (/MT)”重新编译新 exe 会变大几 MB但不再依赖外部运行库。要么把对应版本的 Redistributable 和 exe 一起给到对方。我自己会优先选/MT省得解释一堆运行库版本问题。5.2 FIRST 集计算陷入死循环或内存爆炸现象程序跑到computeFirst函数时卡死内存持续增长最后被系统杀掉或者输出一堆重复的灾难性结果。原因不动点迭代里没用changed标志每轮都无条件继续或者是把for(;;)写成了依赖某个计数器到固定次数才退出而文法规模大一点之后收敛轮数超过预期计数器没到就死循环了。解决在所有插入操作的地方用insert().second驱动changed循环只靠changed决定退出。再加一道保险迭代超过productions.size() * 10轮打印警告并强制退出理论上永远不会触发但万一算法有隐藏 bug 时不至于把机器跑死。5.3 消除左递归后文法“变形”算术优先级全乱了现象拿E - E T | T这类左递归文法按教材做了消除变成E - T E的形式跑测试句子时发现1 2 * 3被解析成了(12)*3。原因左递归消除只保证文法等价但它会改变推导树的结构特别是*和的优先级本来就是靠E - E T与T - T * F这种层级关系表达的改造后原来的层级关系丢了优先级自然也跟着乱。解决两个方向选一个。改回原来有左递归的文法然后走 LR(1)/SLR(1)因为这路线不要求消除左递归或者坚持 LL(1)就得按expr - term expr、term - factor term的分层重写文法而不是机械套用教材上的消除算法。动手前先花十分钟手推一条简单式子的推导树比写完再调优先级省力得多。5.4 空串 ε 被当成普通终结符现象FIRST 和 FOLLOW 算出来的集合里混着奇怪的eps、null或空白字符串填表时查不到驱动时栈永远弹不干净。原因我见过很多人把 ε 直接表示成空字符串跑进std::set以后各种判断都走样rhs.size() 1 rhs[0] EPSILON里的比较永远不成立first[sym].count(EPSILON)也永远查不到。还有的写法用\0字符读文件时会被当成字符串结束符截断。解决把EPSILON定义成一个不出现于文法符号的可见字符串我用。在处理空产生式的所有分支里只用这一个常量比较不允许在代码里出现裸写或eps。这样打印调试时也能一眼看出来不会跟空格混淆。5.5 读输入文件时开头第一个符号神秘失败现象同一段文法在控制台手输句子能通过从文件读就报第一个符号不匹配错误信息里看到的输入符号是一个奇怪的字符或者空字符串。原因用 Windows 记事本保存 UTF-8 编码文件时默认会写一个 BOM 头\ufeffifstream读进来后第一个 token 就带着这个不可见字符如果文法符号是中文或特殊字符编码不一致还会产生乱码。解决读取后用if (line[0] \xef line[1] \xbb line[2] \xbf)剥离 UTF-8 BOM或者实验时统一用十六进制编辑器确认文件头更省事的做法是文法文件和输入文件都保存为无 BOM 的 UTF-8在 VS Code 右下角编码菜单里改成“UTF-8 with BOM 取消”再保存。我一般直接在读取函数里做一次 BOM 剥离避免每次换编辑器都要重新检查。6. 怎么证明你的分析器是对的验证方法与一个实用技巧6.1 正反例句对一个分析器最重要的验证维度语法分析器从“写完”到“敢交”之间最靠谱的验证方式是准备两组输入一组是文法应当接受的合法句子另一组是明显非法的句子。合法句子的验证标准是分析能到达接受状态并打印完整推导过程非法句子则必须在你预期的那一步报错。我每次都会建一张测试表比对着跑完再贴进实验报告。用例类型输入示例算术表达式文法期望结果合法id id * id接受推导过程完整无错合法id * ( id id )接受括号嵌套正确非法id * id在*处报错指出期望的运算分量非法( id id括号不匹配输入串结束时报错6.2 用 trace 开关定位是哪一步卡住的只有正反例还不够报错到底在哪一步、为什么在那一步得有手段看。我给两种分析器都加一个bool trace开关默认关出问题时开。LL(1) 打开后打印每步用的产生式、当前栈内容和剩余输入LR 打印当前状态栈、符号栈、动作和形如s3、r2的 ACTION 表项。这个开关只影响输出不影响逻辑调试完不用删代码留着交作业还能证明你是自己实现的。最典型的定位方式从第一次产生非预期输出往前查 5 步手动按文法推一遍看是表构造的问题还是驱动循环的问题。我几乎每次都是靠这种“逐步打印”抓出填表漏项的 bug而不是靠读代码干瞪眼。6.3 一个让实验报告加分的进阶做法做完基础实验后留一天时间把“分析树”补上也不难在 LL(1) 驱动每次展开非终结符时创建一个树节点右部的符号作为子节点在 SLR 每次归约时创建父节点右部符号作为子节点。配上一段简单的递归打印函数输出括号表达式实验报告里就能多一张清晰的语法树图也能给语义分析实验省下不少时间。我自己的习惯是每一版代码跑通后把正反例的打印输出存成一个文本文件跟代码一起交——助教点开就能看到完整推导过程比在文档里贴截图更有说服力。最后提一句实在的做完 LL(1) 再回头做 SLR(1)你会发现很多“当时玄学”的问题其实都是“对数据结构理解不深”动手把这一个实验磨透比刷十道八股文管用得多希望帮到你。本文还有配套的精品资源点击获取