基于MFC的LALR(1)分析表自动构造:从文法输入到冲突可视化
简介本资源为基于MFC实现的LALR(1)分析表自动构造程序面向学习编译原理的高校学生与课程设计开发者帮助解决LR(1)项目集规范族、LALR(1)项目集及分析表构造等算法实现难题。压缩包共52个文件约63.55MB包含cpp与h源码、exe可执行文件、doc设计报告、md运行说明及png题目截图另有vcxproj、sln等工程配置与Debug编译产物便于直接运行与二次开发。程序完整实现CLOSURE(I)、Go(I,X)、FIRST集合构造并以教材例5.13为输入输出LALR(1)分析表报告与说明文档可辅助理解算法流程与调试思路。目前已有315人学习下载适合作为编译原理课程设计参考或算法验证工具。1. 从一份 MFC 工程说起LALR(1) 分析表到底能不能自动构造很多人第一次接触编译原理课设都会拿到一个类似「基于 MFC 实现的 LALR(1) 分析表自动构造程序」的题目。表面看是让你写个 Windows 窗口程序实际上真正难的是背后那套 LALR(1) 项目集规范族的构造逻辑。MFC 只是壳LALR(1) 才是核心。我见过太多人把 MFC 对话框拖得漂漂亮亮结果一算 FIRST 集就卡住或者项目集一多就死循环。这个标题真正要解决的问题是给定一组文法产生式程序能不能自动算出 LR(0) 项目集、合并同心集、构造出 ACTION 和 GOTO 表并且把冲突情况反馈给用户。适合谁正在做编译原理课程设计的学生、想用 MFC 练手但又不想只写计算器的 C 开发者以及需要一个小型语法分析器生成工具来验证自定义文法的人。MFC 在这里的角色是提供文件读写、列表控件展示和消息响应LALR(1) 的算法部分完全可以用纯 C 写两者通过文档/视图结构解耦。下面我按实际做过的路径把选型、数据结构、构造步骤和踩过的坑一次讲清楚。2. 文法输入与 FIRST/FOLLOW 集先把地基打牢2.1 产生式怎么存才不容易翻车文法输入通常有两种方式一种是在 MFC 界面上放一个多行编辑框让用户按A - B c的格式逐行输入另一种是直接读.txt或.grammar文件。我一般会选后者因为文件方便版本管理和批量测试。产生式的内部表示不要用字符串硬拼建议用结构体把左部和右部拆开右部再拆成符号数组。符号要区分终结符和非终结符通常约定大写字母开头为非终结符小写字母和符号为终结符ε用空右部表示。struct Production { int id; // 产生式编号从 0 开始 std::string lhs; // 左部非终结符 std::vectorstd::string rhs; // 右部符号序列空表示 ε }; // 解析一行 E - E T Production parseLine(const std::string line) { Production p; size_t arrow line.find(-); p.lhs trim(line.substr(0, arrow)); std::string rhsStr trim(line.substr(arrow 2)); std::istringstream iss(rhsStr); std::string sym; while (iss sym) { p.rhs.push_back(sym); } return p; }这段代码的关键点在于trim要处理首尾空格istringstream按空白切分右部。参数上产生式编号必须唯一后面构造项目集时要用它来标识归约项。如果右部为空rhs就是空 vector代表 ε 产生式。注意不要用char存符号因为像id、num这种多字符终结符很常见用std::string更稳。2.2 FIRST 集和 FOLLOW 集的迭代算法FIRST 集和 FOLLOW 集是后面构造分析表的前置条件。FIRST(A) 是从 A 推导出的所有串的首终结符集合如果 A 能推出 ε还要把 ε 加进去。FOLLOW(A) 是在某个句型中紧跟在 A 后面的终结符集合。这两个集合都用迭代法求一直循环到没有新符号加入为止。// 假设已有一个 mapstring, vectorProduction 按左部索引 void computeFirst(mapstring, setstring first, const mapstring, vectorProduction prods, const setstring nonTerms, const setstring terms) { bool changed true; while (changed) { changed false; for (auto kv : prods) { const string A kv.first; for (const auto p : kv.second) { if (p.rhs.empty()) { if (first[A].insert(ε).second) changed true; continue; } bool allNullable true; for (const string sym : p.rhs) { if (terms.count(sym)) { if (first[A].insert(sym).second) changed true; allNullable false; break; } else { for (const string f : first[sym]) { if (f ! ε first[A].insert(f).second) changed true; } if (!first[sym].count(ε)) { allNullable false; break; } } } if (allNullable) { if (first[A].insert(ε).second) changed true; } } } } }FOLLOW 集的算法类似但要注意开始符号的 FOLLOW 里要加入$或#作为输入结束符。迭代过程中对于产生式A - α B β把 FIRST(β) 中除 ε 外的符号加入 FOLLOW(B)如果 β 能推出 ε则把 FOLLOW(A) 加入 FOLLOW(B)。这里最容易出错的是 ε 的处理很多实现忘了在 β 可空时继续传播 FOLLOW(A)导致后面分析表缺项。提示FIRST 和 FOLLOW 的迭代次数不会超过非终结符数量乘以产生式数量如果循环超过这个上限还没收敛基本可以断定文法里有左递归或者数据写错了。3. LR(0) 项目集规范族与同心集合并LALR(1) 的核心步骤3.1 项目、闭包和 GOTO 函数的实现LR(0) 项目就是产生式加一个圆点位置比如E - E · T。项目集闭包的操作是如果圆点后面是非终结符 B就把所有 B 的产生式加进来圆点放在最前面。GOTO(I, X) 则是把项目集 I 中圆点后是 X 的项目移进一位再求闭包。用setItem存项目集Item 用(prodId, dotPos)表示方便去重和比较。struct Item { int prodId; int dotPos; bool operator(const Item o) const { return prodId ! o.prodId ? prodId o.prodId : dotPos o.dotPos; } }; setItem closure(setItem items, const vectorProduction prods, const setstring nonTerms) { bool changed true; while (changed) { changed false; for (const Item it : items) { const Production p prods[it.prodId]; if (it.dotPos (int)p.rhs.size()) { string sym p.rhs[it.dotPos]; if (nonTerms.count(sym)) { for (int i 0; i (int)prods.size(); i) { if (prods[i].lhs sym) { Item newItem{i, 0}; if (items.insert(newItem).second) changed true; } } } } } } return items; }这段闭包代码里items.insert的返回值是pairiterator, bool第二个 bool 表示是否真的插入了新元素用它来驱动循环。参数上prods是全局产生式表nonTerms是非终结符集合。注意闭包操作可能会重复插入所以必须用set而不是vector。3.2 同心集合并LALR(1) 和 LR(1) 的分水岭LR(1) 项目比 LR(0) 多了一个搜索符构造出来的项目集数量会爆炸。LALR(1) 的做法是先构造 LR(0) 项目集规范族然后把「同心」的项目集合并——所谓同心就是忽略搜索符后项目集的核心部分相同。合并之后可能会引入归约-归约冲突但项目集数量大幅减少这也是 LALR(1) 在实际工具中更常用的原因。合并的步骤是先给每个 LR(0) 项目集分配一个状态号然后遍历所有状态找出核心项目圆点不在最前面的项目相同的状态对把它们合并成一个新状态。合并时搜索符取并集。这里有个血泪经验合并的顺序会影响最终状态编号但不会影响分析表的功能所以不用纠结先合并哪一对只要保证所有同心集都被合并即可。// 判断两个项目集是否同心忽略搜索符后核心项目相同 bool sameCore(const setLR1Item a, const setLR1Item b) { setpairint,int coreA, coreB; for (const auto it : a) { if (it.dotPos 0) coreA.insert({it.prodId, it.dotPos}); } for (const auto it : b) { if (it.dotPos 0) coreB.insert({it.prodId, it.dotPos}); } return coreA coreB; }参数说明LR1Item在Item基础上增加setstring lookahead。核心项目只取dotPos 0的项因为圆点在开头的项目是闭包产生的不反映状态的核心特征。合并后要重新计算 GOTO 表因为原来指向不同状态的转移现在可能指向同一个合并后的状态。注意合并同心集后如果出现归约-归约冲突说明这个文法不是 LALR(1) 文法程序应该给出明确提示而不是强行生成一张有冲突的分析表。4. ACTION 表和 GOTO 表的生成从项目集到可执行分析表4.1 填表规则与冲突检测ACTION 表行是状态号列是终结符和$GOTO 表行是状态号列是非终结符。填表规则有三条如果项目A - α · a β且 GOTO(I, a) J则 ACTION[I, a] shift J如果项目A - α ·且a在搜索符集中则 ACTION[I, a] reduce A - α如果项目S - S ·且遇到$则 ACTION[I, $] accept。GOTO 表则根据 GOTO(I, A) J 填 GOTO[I, A] J。冲突检测要在填表过程中实时做。shift-reduce 冲突表现为同一个单元格既要移进又要归约reduce-reduce 冲突表现为同一个单元格有两个不同的归约产生式。遇到冲突时我一般会在界面上用红色高亮该单元格并在日志区输出冲突类型和涉及的产生式编号。// 填 ACTION 表返回冲突列表 vectorstring buildActionTable( const vectorsetLR1Item states, const mappairint,string, int transitions, const vectorProduction prods, mappairint,string, string action) { vectorstring conflicts; for (int i 0; i (int)states.size(); i) { for (const auto it : states[i]) { const Production p prods[it.prodId]; if (it.dotPos (int)p.rhs.size()) { string a p.rhs[it.dotPos]; auto key make_pair(i, a); if (transitions.count(key)) { string act s to_string(transitions.at(key)); if (action.count(key) action[key] ! act) { conflicts.push_back(状态 to_string(i) 符号 a 移进-归约冲突); } action[key] act; } } else { for (const string la : it.lookahead) { auto key make_pair(i, la); string act r to_string(it.prodId); if (action.count(key) action[key] ! act) { conflicts.push_back(状态 to_string(i) 符号 la 归约-归约冲突); } action[key] act; } } } } return conflicts; }这段代码里transitions是预先算好的 GOTO 转移表键是(状态号, 符号)值是目标状态号。action的值用字符串表示s开头是移进r开头是归约acc是接受。冲突信息收集到conflicts里返回给界面层展示。参数上要注意归约项的搜索符可能不止一个所以内层要遍历lookahead集合。4.2 用 MFC 列表控件展示分析表MFC 的CListCtrl适合展示二维表格。设置报表视图后插入列头再逐行插入状态号和各个动作。对于冲突单元格可以用SetItemText之后调用SetItemState或者自定义绘制来标红。如果表很大建议用虚拟列表LVS_OWNERDATA否则插入几千行会明显卡顿。// 假设 m_list 是 CListCtrl 成员变量 m_list.InsertColumn(0, _T(状态), LVCFMT_LEFT, 60); int col 1; for (const auto t : terminals) { m_list.InsertColumn(col, CString(t.c_str()), LVCFMT_LEFT, 80); } int row 0; for (int i 0; i (int)states.size(); i) { CString stateStr; stateStr.Format(_T(%d), i); m_list.InsertItem(row, stateStr); int c 1; for (const auto t : terminals) { auto key make_pair(i, t); CString act action.count(key) ? CString(action[key].c_str()) : _T(); m_list.SetItemText(row, c, act); } row; }这里terminals是终结符列表顺序要和列头一致。action是上一步生成的 map。注意CString和std::string之间的转换在 Unicode 工程下要用CString(str.c_str())或者CA2T宏。如果分析表列数超过屏幕宽度可以给CListCtrl加水平滚动条或者把 ACTION 和 GOTO 分成两个列表控件展示。5. 避坑与排查LALR(1) 自动构造里最容易翻车的 5 个点5.1 现象程序一运行就死循环CPU 占满原因通常是闭包函数或者 FIRST 集迭代没有正确终止。闭包函数里如果忘记用insert的返回值判断是否新增就会无限循环FIRST 集迭代如果每次都用vector存结果而不去重也会一直「发现新元素」。解决方法是所有集合都用set或unordered_set每次插入后检查second是否为 true只有 true 才把changed置为 true。5.2 现象分析表里出现大量本不该有的冲突原因可能是搜索符计算错了。LALR(1) 的搜索符传播规则是对于项目A - α · B β把 FIRST(β) 加入 B 的项目的搜索符如果 β 可空还要把当前项目的搜索符也加进去。很多人只做了第一步漏了 β 可空时的传播导致归约项的搜索符不全填表时要么缺项要么误判冲突。解决方法是写一个单独的propagateLookahead函数反复迭代直到搜索符集合不再变化。5.3 现象MFC 界面在读取大文法文件时卡死原因是在 UI 线程里直接做了完整的分析表构造。LALR(1) 构造对于几百条产生式的文法可能要跑几秒到几十秒放在OnButtonClick里会阻塞消息循环。解决方法是把构造逻辑放到工作线程用AfxBeginThread或者std::thread构造完成后通过PostMessage通知 UI 线程刷新列表。注意工作线程里不要直接操作 MFC 控件所有控件更新都要在 UI 线程做。5.4 现象合并同心集后 GOTO 表指向了错误的状态原因是合并状态后没有重建状态编号映射。原来的 GOTO 表里存的是合并前的状态号合并后这些编号可能已经不存在或者指向了别的状态。解决方法是合并完成后先建立「旧状态号 - 新状态号」的映射表然后遍历所有转移用映射表把目标状态号替换掉。如果两个旧状态映射到同一个新状态转移自然就合并了。5.5 现象程序在 Debug 下正常Release 下结果不对原因通常是未初始化变量或者std::set迭代器失效。MFC 工程在 Debug 下会给未初始化内存填0xCCRelease 下则是随机值。检查所有int、bool成员是否在构造函数里初始化了。另外如果在遍历set的过程中插入或删除元素迭代器会失效必须先把要操作的元素拷贝出来再处理。我一般会在 Release 下用_CrtSetDbgFlag开启内存检查虽然 MFC 的泄漏报告有时会指向dumpcont.cpp这种内部文件但至少能定位到自己的代码行。6. 进阶技巧用文法文件做回归测试与冲突可视化做到这里程序已经能跑通基本流程了。但真正让这个工具变得好用的一步是加一套回归测试机制。我习惯在工程目录下放一个testcases文件夹每个.grammar文件对应一个预期结果文件.expected里面记录状态数、冲突数和分析表的关键单元格。每次改完算法跑一遍批处理就能知道有没有改坏。// 批量测试遍历 testcases 目录 void runRegression(const string dir) { for (const auto entry : filesystem::directory_iterator(dir)) { if (entry.path().extension() ! .grammar) continue; Grammar g loadGrammar(entry.path().string()); LALRTable table buildLALR(g); string expectedFile entry.path().string() .expected; ifstream fin(expectedFile); int expStates, expConflicts; fin expStates expConflicts; if (table.stateCount ! expStates || table.conflicts.size() ! expConflicts) { cout FAIL: entry.path() endl; } else { cout PASS: entry.path() endl; } } }这段代码用 C17 的filesystem遍历目录读取每个文法的预期状态数和冲突数。参数上.expected文件第一行两个整数分别表示预期状态数和预期冲突数。如果实际结果不一致就输出 FAIL。这个机制在调试搜索符传播时特别有用因为改一行代码可能影响几十个文法的结果靠手工点界面根本测不过来。另一个值得做的进阶功能是冲突可视化。当检测到 shift-reduce 冲突时除了在列表里标红还可以弹出一个对话框把冲突涉及的两个项目完整显示出来包括产生式和圆点位置。这样用户能直接看到是哪个符号上移进和归约撞了方便改文法。我一般会在冲突单元格上双击时触发这个对话框用CListCtrl的NM_DBLCLK消息处理。最后说一个我自己的习惯每次构造完分析表我会用一个小型输入串跑一遍 LR 分析过程把每一步的状态栈、符号栈和剩余输入打印到日志区。这个日志在排查「为什么这个句子被拒绝」时比看分析表直观得多。分析驱动的代码大概三十行核心就是根据 ACTION 表查表移进时压栈归约时弹栈并查 GOTO 表。如果某一步查表为空就报语法错误并指出当前状态和输入符号。这个功能加上去之后整个程序才真正像一个能用的 LALR(1) 分析表自动构造工具而不是一个只能看不能跑的演示。希望帮到你。本文还有配套的精品资源点击获取
上一篇/下一篇内容由系统自动关联
返回资讯列表 →