简介:本资源面向编译原理课程学习者与课程设计实践者,提供一套基于MFC框架实现的LALR(1)分析表自动构造程序,帮助理解并完成从LR(1)项目集规范族到LALR(1)分析表的完整构造流程。压缩包共52个文件,约63.55MB,包含cpp与h源码、可执行exe、设计报告doc、运行说明md、工程配置vcxproj与sln,以及编译中间产物和资源文件,覆盖源码、文档与可运行程序三类内容。程序实现了CLOSURE(I)、Go(I,X)、FIRST集合构造,并支持以教材例5.13为输入输出LALR(1)分析表。目前已有315人学习下载,适合需要参考完整课程设计实现、对照报告梳理算法步骤或直接运行验证结果的读者,可节省从零搭建MFC工程与调试分析表构造逻辑的时间。
1. 从一份 MFC 压缩包说起:LALR(1) 分析表到底能不能自动构造
很多人第一次接触编译原理课程设计,拿到的题目就是“基于 MFC 实现的 LALR(1) 分析表自动构造程序”。压缩包解压后是一套 Visual Studio 工程,界面用对话框搭起来,点一下按钮就能把文法规则变成一张 ACTION/GOTO 表。听起来像个黑匣子,但它解决的其实是一个很具体的问题:给定一组上下文无关文法产生式,自动算出 LALR(1) 项目集规范族,再据此填出语法分析表,让后续的移进-归约分析器有表可查。
这件事的价值在于,手算 LALR(1) 分析表极其痛苦。一个中等规模的文法,项目集动辄几十个,闭包运算和向前看符号的传播稍不留神就出错。用 MFC 做外壳,好处是 Windows 桌面端交互直观,输入文法、查看项目集、导出分析表都能在一个窗口里完成,适合教学演示和课程验收。适合谁读?正在做编译原理课设的学生、需要给团队搭一个可视化文法调试工具的工程师,以及想用 MFC 练手桌面软件开发的人。接下来不聊空理论,直接拆这套程序从文法输入到分析表输出的完整落地路径。
2. 文法输入与 FIRST/FOLLOW 集:自动构造的地基怎么打
2.1 为什么 LALR(1) 绕不开 FIRST 和 FOLLOW
LALR(1) 的核心是在 LR(1) 的基础上合并同心项目集,而合并的依据就是向前看符号。向前看符号的计算又依赖 FIRST 集和 FOLLOW 集。很多同学一上来就想直接写项目集闭包,结果发现闭包里新项目的搜索符算不出来,根子就在 FIRST 没算对。
常见做法是先把文法读进来,统一产生式格式,然后迭代计算 FIRST 集直到不再变化。FOLLOW 集则在 FIRST 的基础上,结合产生式右部逐个符号推导。这里有个容易忽略的点:如果开始符号的 FOLLOW 集没有初始化$,整个分析表的接受动作就填不出来。
我一般会把文法存储成这样的结构:左部一个非终结符,右部是一个符号序列,终结符和非终结符用大小写或前缀区分。MFC 里可以用CStringArray或者std::vector<CString>来存,但更稳妥的是自定义结构体,方便后续遍历。
2.2 用代码把文法读进来并算 FIRST 集
下面这段是核心逻辑的简化版,放在 MFC 的按钮响应函数里就能跑。假设文法已经按行读入m_grammar,每行格式是E->E+T这种。
// 文法产生式结构 struct Production { CString left; // 左部非终结符 std::vector<CString> right; // 右部符号序列 }; // 计算 FIRST 集 void ComputeFirst(std::vector<Production>& grammar, std::map<CString, std::set<CString>>& first, std::set<CString>& nonTerminals, std::set<CString>& terminals) { bool changed = true; while (changed) { changed = false; for (auto& prod : grammar) { CString A = prod.left; // 如果右部第一个符号是终结符,直接加入 FIRST(A) if (terminals.count(prod.right[0])) { if (first[A].insert(prod.right[0]).second) changed = true; } else { // 右部第一个是非终结符,把它的 FIRST 集(不含空串)并进来 CString B = prod.right[0]; for (auto& sym : first[B]) { if (sym != _T("ε")) { if (first[A].insert(sym).second) changed = true; } } // 如果 B 能推出空串,继续看下一个符号 size_t idx = 0; while (idx < prod.right.size() && nonTerminals.count(prod.right[idx]) && first[prod.right[idx]].count(_T("ε"))) { if (idx + 1 < prod.right.size()) { CString next = prod.right[idx + 1]; if (terminals.count(next)) { if (first[A].insert(next).second) changed = true; break; } else { for (auto& sym : first[next]) { if (sym != _T("ε")) { if (first[A].insert(sym).second) changed = true; } } } } else { // 所有符号都能推出空串,则 A 也能推出空串 if (first[A].insert(_T("ε")).second) changed = true; } idx++; } } } } }逻辑说明:外层while(changed)保证迭代到不动点。对每条产生式,先看右部第一个符号。如果是终结符,直接进 FIRST;如果是非终结符,把它的 FIRST 去掉空串后并入,然后检查它是否能推出空串,能则继续往后看。参数方面,grammar是全部产生式,first是输出映射,nonTerminals和terminals需要提前从文法里扫描出来。注意空串用ε表示,MFC 的CString比较要用_T()宏包裹字符串字面量。
FOLLOW 集的计算类似,但要多一步:把产生式右部每个非终结符后面的 FIRST 集并进来,如果后面所有符号都能推出空串,再把左部的 FOLLOW 并进来。开始符号的 FOLLOW 要预先插入$。
2.3 项目集规范族的构造与同心集合并
LR(1) 项目是[产生式, 点位置, 向前看符号]。闭包运算时,如果点后面是非终结符 B,就把 B 的所有产生式加进来,点在最左,向前看符号用 FIRST(βa) 计算,其中 β 是点后面的符号串,a 是当前项目的向前看符号。GOTO 函数则是把点右移一位后求闭包。
LALR(1) 的关键一步是合并同心项目集:如果两个项目集的核相同(忽略向前看符号),就把它们的向前看符号并起来。合并后可能出现归约-归约冲突,这是 LALR(1) 的固有局限,程序里要能检测并提示。
在 MFC 里,项目集可以用std::set或CArray存,每个项目用结构体表示。状态转移用map<pair<int, CString>, int>记录,从状态 i 遇到符号 X 转到状态 j。构造过程用 BFS 逐层展开,直到没有新状态产生。
提示:合并同心集时,一定要先完成所有 LR(1) 项目集的构造再合并,不要边构造边合并,否则 GOTO 表会错乱。
3. 分析表填充与冲突处理:ACTION/GOTO 表怎么落进 MFC 界面
3.1 ACTION 表和 GOTO 表的填充规则
有了项目集规范族和状态转移,填表就是按规则走。对每个状态 I:
- 如果
[A -> α·aβ, b]在 I 中,且 a 是终结符,GOTO(I, a) = J,则ACTION[I, a] = shift J。 - 如果
[A -> α·, a]在 I 中,且 A 不是开始符号,则ACTION[I, a] = reduce A -> α。 - 如果
[S' -> S·, $]在 I 中,则ACTION[I, $] = accept。 - 对非终结符 A,如果 GOTO(I, A) = J,则
GOTO[I, A] = J。
冲突处理是重点。移进-归约冲突时,默认移进优先,但要在界面上标红提示。归约-归约冲突则说明文法不是 LALR(1),需要用户修改文法。
3.2 在 MFC 对话框里展示分析表
MFC 的CListCtrl报告模式最适合展示二维表。列头是终结符和$,行头是状态编号。填充时先插入列,再逐行插入状态和对应的动作。
// 假设 m_listAction 是 CListCtrl 控件变量 void FillActionTable(CListCtrl& list, const std::vector<std::map<CString, CString>>& action, const std::set<CString>& terminals) { list.DeleteAllItems(); // 插入列:状态 + 所有终结符 + $ list.InsertColumn(0, _T("状态"), LVCFMT_LEFT, 60); int col = 1; for (auto& t : terminals) { list.InsertColumn(col++, t, LVCFMT_LEFT, 80); } list.InsertColumn(col, _T("$"), LVCFMT_LEFT, 60); // 插入行 for (size_t i = 0; i < action.size(); ++i) { CString stateStr; stateStr.Format(_T("%d"), (int)i); list.InsertItem((int)i, stateStr); int sub = 1; for (auto& t : terminals) { auto it = action[i].find(t); CString val = (it != action[i].end()) ? it->second : _T(""); list.SetItemText((int)i, sub++, val); } auto it = action[i].find(_T("$")); CString val = (it != action[i].end()) ? it->second : _T(""); list.SetItemText((int)i, sub, val); } }逻辑说明:先清空列表,然后按终结符集合插入列。行数据从action映射里取,没有动作的格子留空。参数action是vector<map<CString, CString>>,每个元素对应一个状态的动作映射,值形如s5或r3。注意CListCtrl的SetItemText索引从 0 开始,列 0 是状态,所以终结符从列 1 开始填。
3.3 冲突检测与界面反馈
冲突检测要在填表时同步做。用一个map<pair<int, CString>, vector<CString>>记录每个格子里的所有动作,如果某个格子超过一个动作,就是冲突。移进-归约冲突可以自动选移进,但要在列表里把该单元格标黄;归约-归约冲突标红并弹窗提示。
MFC 里设置单元格颜色需要自绘CListCtrl,或者用NM_CUSTOMDRAW消息。简单做法是弹出一个消息框列出冲突位置,让用户先改文法。课程设计里这样已经够用,不必过度追求界面美化。
注意:
CListCtrl插入大量行时性能会下降,如果状态数超过 200,建议用虚拟列表或者分页显示。
4. 避坑与排查:LALR(1) 自动构造里最容易翻车的 5 个地方
4.1 现象:FIRST 集算出来少了终结符,导致闭包搜索符为空
原因:迭代计算时只遍历了一遍文法,没有循环到不动点。或者处理空串产生式时,没有正确地把后续符号的 FIRST 集并进来。
解决:把 FIRST 计算包在while(changed)里,每次插入新符号就置changed = true。空串产生式要单独处理,确保ε被正确识别。
4.2 现象:项目集数量爆炸,程序卡死
原因:闭包运算时没有去重,同一个项目被反复加入。或者 GOTO 函数没有用已访问集合剪枝。
解决:项目集用std::set存储,插入前先查重。状态转移用map记录,每次生成新状态前先查是否已存在。BFS 队列里只放未处理过的状态。
4.3 现象:合并同心集后出现大量归约-归约冲突
原因:文法本身不是 LALR(1) 的,或者合并时把不该合并的项目集合并了。同心集合并的前提是“核相同”,核是指点不在最左的项目。如果核不同,不能合并。
解决:先确认文法是否可以用 LALR(1) 处理。如果冲突太多,考虑改用 SLR 或 LR(1)。合并时严格比较核,不要比较整个项目集。
4.4 现象:MFC 界面输入文法后点击按钮没反应
原因:按钮响应函数里读文法的逻辑有误,比如GetWindowText拿到的字符串没有按行分割,或者分割后没有去掉空格。
解决:用CString::Tokenize按换行符分割,再对每行Trim去空格。产生式箭头->要统一,避免用户输入→或=>。可以在输入框旁边加一个“示例文法”按钮,减少手动输入错误。
4.5 现象:分析表填完后,用测试串跑分析器结果不对
原因:ACTION 表里的 reduce 动作编号和产生式编号没对上,或者 GOTO 表的状态编号偏移了。
解决:产生式从 0 开始编号,reduce 动作里存编号而不是产生式字符串。GOTO 表的状态编号和项目集编号保持一致。测试时先用一个简单文法,比如E -> E + T | T,手动核对每一步。
5. 进阶技巧:把分析表导出成可复用的数据结构
5.1 导出为 CSV 或 JSON 供外部分析器使用
MFC 程序里构造好的分析表,如果只能在界面里看,价值有限。常见做法是加一个“导出”按钮,把 ACTION 和 GOTO 表写成 CSV 或 JSON。CSV 用CStdioFile写,每行一个状态,列用逗号分隔。JSON 稍微麻烦点,但可以手写序列化,因为结构固定。
// 导出 ACTION 表为 CSV void ExportActionCSV(const CString& path, const std::vector<std::map<CString, CString>>& action, const std::set<CString>& terminals) { CStdioFile file; if (!file.Open(path, CFile::modeCreate | CFile::modeWrite)) return; // 写表头 CString header = _T("state"); for (auto& t : terminals) header += _T(",") + t; header += _T(",$\n"); file.WriteString(header); // 写数据行 for (size_t i = 0; i < action.size(); ++i) { CString line; line.Format(_T("%d"), (int)i); for (auto& t : terminals) { auto it = action[i].find(t); line += _T(",") + ((it != action[i].end()) ? it->second : _T("")); } auto it = action[i].find(_T("$")); line += _T(",") + ((it != action[i].end()) ? it->second : _T("")); line += _T("\n"); file.WriteString(line); } file.Close(); }逻辑说明:先写表头,列顺序和界面一致。然后逐状态写行,每个格子从action映射取值,没有则留空。参数path是导出路径,terminals决定列顺序。注意CStdioFile写中文路径时要用CFile::modeCreate和CFile::modeWrite,并且文件编码建议用 UTF-8 带 BOM,方便 Excel 打开。
5.2 用导出数据做自动化回归测试
导出 CSV 后,可以写一个 Python 脚本读取分析表,对一组测试串跑 LR 分析,验证移进-归约序列是否正确。这样每次改文法,不用重新点界面,直接跑脚本就能回归。
import csv def load_action_table(path): action = {} with open(path, 'r', encoding='utf-8-sig') as f: reader = csv.reader(f) header = next(reader) terminals = header[1:] for row in reader: state = int(row[0]) action[state] = {} for i, t in enumerate(terminals): if row[i+1]: action[state][t] = row[i+1] return action def parse(action, tokens): stack = [0] pos = 0 while True: state = stack[-1] token = tokens[pos] if pos < len(tokens) else '$' act = action.get(state, {}).get(token) if act is None: return False if act.startswith('s'): stack.append(int(act[1:])) pos += 1 elif act.startswith('r'): # 这里需要产生式表,简化处理,只演示框架 return True elif act == 'acc': return True return False逻辑说明:load_action_table把 CSV 读成嵌套字典,parse是简化的分析器框架。实际使用时需要补上产生式表和归约时的栈弹出逻辑。参数tokens是词法分析后的终结符列表,末尾要加$。这个脚本可以放在 CI 里,每次提交文法文件就自动跑一遍。
5.3 我踩过的坑和现在的习惯
最早做这个课设时,我把项目集直接存在CArray里,结果合并同心集时比较操作写错,导致状态数对不上,调了一整晚。后来改成std::set配合自定义比较器,问题消失。另一个血泪经验是:MFC 的CString在std::map里做键时,比较用的是operator<,但CString的比较受本地化影响,建议统一转成std::string再存。
现在我的习惯是,先把核心算法写成纯 C++ 的控制台程序,跑通所有测试用例,再套 MFC 界面。这样算法和界面解耦,出问题容易定位。界面只负责输入输出,不掺和逻辑。如果你也在做类似的分析表自动构造,建议先别急着拖控件,把 FIRST/FOLLOW 和项目集构造用命令行验证一遍,后面会省很多后悔药。希望帮到你。
本文还有配套的精品资源,点击获取