简介:这是一套基于MFC界面开发的LALR(1)分析表自动构造程序资源包,专门用于编译原理课程设计,面向需要完成语法分析阶段实验的高校学生。程序能够对任意给定的文法自动构造LR(1)项目集规范族,再通过合并同心集得到LALR(1)项目集规范族,最终输出完整的LALR(1)分析表,整个过程严格按照教材中描述的方法实现,可省去大量手工推导。资源包共含五十二个文件,整体大小约六十三点五五兆,主要文件类型有Word设计报告、Markdown运行说明、C++源码、可直接执行的exe程序以及MFC工程配置文件,源码中重点实现了集合的求取、闭包运算和状态转移等核心算法,工程文件配置完整,用Visual Studio即可直接打开调试,目录结构清晰,方便按模块查找和使用。以教材中的经典例作为输入,程序能正确输出对应的分析表,配套的设计报告从设计内容、算法流程到测试结果都有详细阐述,运行说明也给出了具体的环境配置和操作步骤,方便读者快速上手,也便于二次开发和修改。目前已有三百一十五人学习下载,对正在赶做编译原理课程设计或想深入理解语法分析器构造的读者来说,是一份很完整的参考方案。
1. 为什么把 LALR(1) 分析表自动构造做成 MFC 工具:比纯命令行更值得花时间
基于MFC实现的LALR(1) 分析表自动构造程序,说白了就是给你一份文法文件,它自动算出一张用于移进-归约的 ACTION 表和 GOTO 表。编译原理课设里最劝退的正是这步——十几条产生式手工推不到一半就漏状态,几十条根本推不动。这个方向把最枯燥的自动推导做成了工具,适合正在写编译原理作业的学生,也想自己重写一遍语法分析器的工程师。我的判断可能和大多数人的预期相反:这个项目真正难的不是 LALR(1) 合并算法本身,而是把合并落到 MFC 程序里的工程细节——字符编码、状态编号映射、表格刷新。算法能讲明白的不少,一次跑通无冲突的,我真见过的不多。
2. 构造原理先立住:从 LR(0) 项目集到 LALR(1) 合并,四步推算一张表
LALR(1) 的表构造,绝不能直接拿一张纸画状态转移图。我一般先把过程拆成四段:先构造 LR(1) 项目集族,再找同心项目集合,随后做合并,最后填 ACTION/GOTO 表。很多人从第一步就跳进代码,等调试时再回头看理论,等于没看。你一旦理解四步之间谁依赖谁,后面写代码就是填空题。
2.1 为什么选 LALR(1) 而不是 SLR(1) 或 LR(1):三张表放在一起比
先摆一个结论:自动构造程序选 LALR(1),因为它卡在 LR(1) 的识别能力和 SLR(1) 的规模正中间。SLR(1) 用 FOLLOW 集统一判断归约时机,遇到A→α·且当前输入符号在 FOLLOW(A) 里就直接归约,根本不看当前状态下的上下文,遇到本质上是 LR(1) 文法的文法时,状态一到就开始出冲突。LR(1) 在项目里带上前看符号,冲突判断精确得多,但代价是状态数随文法规模膨胀——一个几十条产生式的文法,LR(1) 可能上千个状态,表大到没法看。LALR(1) 把 LR(1) 中“心相同”的集合合并,去掉了冗余的前看符号差异,状态数和 SLR(1) 同级,识别能力损失极小,代价只是合并环节可能引入新的归约-归约冲突——这是后话。
做自动构造程序,多数人会走“LR(1) → 合并 → LALR(1)”这条通用路线。为什么不在 LR(0) 的基础上去做 SLR(1)?因为要做的是自动构造程序而不是某个特定文法的手工表,你要在程序里支持尽可能多的文法而不增加用户感知的状态数,LALR(1) 是最稳的落点。
| 类型 | 归约时机判定 | 状态规模 | 自动构造常用程度 |
|---|---|---|---|
| SLR(1) | 粗略,依赖 FOLLOW 集 | 小 | 一般 |
| LR(1) | 精确,依赖前看符号 | 大,可到数千个 | 少用,演示为主 |
| LALR(1) | 精确,合并后仍保留前看符号 | 小 | 最常用 |
2.2 CLOSURE 闭包函数:核心里的核心,FIRST 集在这里决定向前看符号
在 LALR(1) 的定义里,项目是三元组:产生式、圆点位置、向前看符号。圆点代表“已经分析了产生式右侧到哪个位置”。闭包运算从一个初始项目[E'→·E, #]开始,不断补全。遇到[A→α·Bβ, a],就要对每个以 B 为左部的产生式B→γ,加入[B→·γ, b],其中 b∈FIRST(βa)。这里有两个能写错的地方:β 为空串时 FIRST(βa) 就是 {a};β 非空时要用 FIRST(β) 的第一个终结符,而不是 FIRST(βa) 全量并列。我见过不少实现把 FIRST(βa) 当成 FIRST(β) ∪ {a},一个符之差整个表就歪了。
set<Item> closure(const set<Item>& I, const Grammar& g) { set<Item> result = I; queue<Item> work; for (const auto& it : I) work.push(it); while (!work.empty()) { Item cur = work.front(); work.pop(); if (cur.dot == (int)cur.rhs.size()) continue; // 圆点已在末尾,是归约项 Symbol X = cur.rhs[cur.dot]; if (X.isTerminal()) continue; // 终结符不需要展开闭包 set<Terminal> look = firstOf(cur.rhs, cur.dot + 1, cur.look, g); for (const Production& p : g.prodsOf(X)) for (Terminal t : look) { Item newItem(p.id, 0, t); if (result.insert(newItem).second) work.push(newItem); } } return result; }逻辑说明:队列在项目集不停补入由闭包条件扩展出的新项目,直到没有新项目加入为止。第一遍把传入集合入队;每次取队首看圆点是否在右侧末尾;圆点后面若是终结符则无事可做;若是非终结符 X,查 FIRST(βa),给每个以 X 为左部的产生式生成一个圆点在 0 位置、带前看符号 t 的新项目。这里set的 insert 返回值同时承担了去重和新发现两件事,比手工用布尔数组标记状态干净。闭包结果不断膨胀到不动点,才是真正完整的项目集。
参数说明:cur.dot记录圆点在第几个符号后面;cur.rhs直接以产生式右侧符号串为引用,省去查产生式表;cur.look是该项的前看符号。FIRST 集的计算是程序性能的关键,文法规模大时建议预处理成二维表firstOf[非终结符][位置],不要现场递归重算,否则闭包里重复展开会指数级拖慢。
2.3 GOTO 转移函数与同心合并:LALR(1) 砍掉状态数的那一刀
GOTO 函数负责从一个项目集经过一个文法符号转移到下一个项目集。实现上很直白:把项目集里圆点后恰好等于 X 的项目,圆点后移一位,再对结果做 closure。问题在合并这步——LR(1) 项目集会膨胀到几百上千个,其中很多集合去掉前看符号后完全相同,它们就是同心集合。自动构造程序要做的是把这些集合合并成同一个状态,前看符号取并集。合并后状态数和 SLR(1) 差不多,但归约决策用的前看符号精确得多。
map<ItemCore, int> coreToState; vector<State> states; for (int i = 0; i < (int)states.size(); ++i) { ItemCore core = stripLookahead(states[i].items); auto found = coreToState.find(core); if (found == coreToState.end()) coreToState[core] = i; else { int target = found->second; states[target].mergeLookahead(states[i]); states[i].redirectedTo = target; // 标记待重映射 } }逻辑说明:合并的策略分两步。先拿到所有 LR(1) 项目集,把每个集合去掉前看符号;用这个“核心”去查 map,如果核心出现过,就把后出现的整个集合的前看符号并入首个状态,并标记重定向;如果没出现过,记录为新的状态编号。最终还要把全体 GOTO 表的转移目标统一替换为重定向后的编号,这是最容易漏的一步。很多初写者只合并了项目集,没改 GOTO 表的目标状态号,结果输出表一查就错。
参数说明:ItemCore就是把 Item 里的前看符号拿掉后剩下的产生式 id 加圆点位置的排序容器;mergeLookahead要做并集,并集操作必须稳定排序,否则后续比较全乱。合并的顺序影响编号,但不影响表正确性;为了可读性我习惯输出时统一按原 LR(1) 编号顺序整理,这样对照调试时能看出哪个状态被并走了。
2.4 填 ACTION/GOTO 表:四条规则决定一张工作表最终正确与否
到这一步,状态集和转移边都有了,剩下的纯粹是填表。ACTION 表按列区分终结符,GOTO 表按列区分非终结符,一张二维索引正好。不移进、不归约、不接受的格子留空,运行期就是报错。填表规则总结起来就四条:圆点后是终结符就移进;圆点已在末尾且不是增广产生式就归约;圆点已在末尾且是增广产生式就接受;以上都不满足就留空。
for (int s = 0; s < (int)states.size(); ++s) for (const Item& it : states[s].items) { if (it.dot < (int)it.rhs.size() && it.rhs[it.dot].isTerminal()) { int next = states[s].go[it.rhs[it.dot]]; if (next >= 0) setAction(s, it.rhs[it.dot], SHIFT, next); } else if (it.dot == (int)it.rhs.size() && it.prodId != 0) { setAction(s, it.look, REDUCE, it.prodId); } else if (it.dot == (int)it.rhs.size() && it.prodId == 0) { setAction(s, TERM_END, ACCEPT, 0); } }逻辑说明:三个分支分别对应移进项目、归约项目和接受项目。圆点后是终结符就查 GOTO,填 SHIFT;圆点已在末尾且不是增广产生式就填 REDUCE,归约编号正是产生式 id;增广产生式的归约项就是接受动作。如果同一个格子被填进两个不同动作,就是经典的两类冲突:SHIFT/REDUCE 冲突或 REDUCE/REDUCE 冲突,程序到这里应当警告用户而不是静默覆盖。
参数说明:TERM_END是文法的结束符,我习惯约定为#;文法读取阶段就把所有符号登记成 int 编号,终结符编号越小越好,非终结符从 100 开始,这样后续 map 排序和表格列展示都稳定。分析表用vector<vector<ActionCell>>而不是map<pair,int>,因为状态数在设计时就可预估,连续内存跑起来也快。
3. MFC 工程侧怎么接:文档视图、CString 和 CListCtrl 的组合
把算法代码挪进 MFC 工程,最怕的是“算法已经通,卡在 MFC 壳上”。这一章把工程结构的接法讲透:先想明白为什么选 MFC,再把核心数据结构和数据流设计好,最后落到 CListCtrl 表格回显。
3.1 为什么 LALR(1) 自动构造程序还选 MFC
很多人第一反应是“都什么年代了还用 MFC”。但回到这个场景:你手上是 VS,目标是给文法分析工具的表格展示,MFC 的单文档加视图架构完全够用,不需要折腾第三方 SDK,工程向导生成一堆架子直接用。做内部工具和课设,MFC 的 CListCtrl、CFileDialog、CStatusBar 都是现成控件,表格回显比控制台 printf 直观得多。相比 Qt 那套信号槽,MFC 的封装层次正好合适——不需要了解太多底层,也能把表格、按钮、对话框拼起来。
还有一层现实原因:编译原理课设经常和“必须用 MFC”或“沿用师兄的 VS 工程”绑定。你如果在现有 VS MFC 工程上增加功能,比如加一个按钮弹出对话框显示实时数据图表,CListCtrl 和 CStatusBar 都在手边,不用跨框架调来调去。反过来,如果从零写命令行版本,功能虽然也能完成,但“自动构造”的成果没法直观给人看,交作业时也少了说服力。
3.2 核心数据结构先立起来:产生式、项目、分析表的 C++ 表示
坑过几次之后,我学乖了:不先设计数据结构就写闭包函数,必返工。先把这几个容器立起来:产生式表存 vector,非终结符集合存set<string>,终结符集合存set<string>,项目不用pair<int,int>而是单独结构体,分析表用vector<vector<ActionCell>>。
struct Production { int id; string lhs; // 左部符号 vector<string> rhs; // 右部符号串 }; struct Item { int prodId; // 产生式编号 int dot; // 圆点位置 string look; // 前看符号 bool operator<(const Item& o) const { if (prodId != o.prodId) return prodId < o.prodId; if (dot != o.dot) return dot < o.dot; return look < o.look; } };逻辑说明:Item 必须定义严格弱序,因为后面 set 去重、求同心核心、排序输出都靠它。look用 string 而不是 int,是为了调试时一眼能看出,等文法确认稳定再考虑 int 优化。分析表动作我用枚举加一个 int 参数——要么是转移目标状态号,要么是产生式编号,叫 ActionCell 而不是裸 int,能少一半低级错误。
参数说明:产生式 id 不要把增广产生式混进去,我给增广产生式单独编号 0,原产生式从 1 开始,接受动作判断就依赖这个约定。终结符#在读取文法时显式追加,留给用户写容易漏,程序里帮忙补上。符号表用map<string,int>统一登记,终结符编号在前、非终结符编号在后,后续所有 map 遍历顺序就固定了。
3.3 数据流:从编辑框读文法到表格回显的全程链路
打开一个文法文件,逐行读进来,转成上面的 Grammar 结构;接着在后台线程跑第 2 章那套推导;拿到 ACTION/GOTO 表后,回到 UI 线程刷新表格。一个容易被忽略的细节是:MFC 的 CListCtrl 刷新必须在 UI 线程,而推导耗时在文法大时肉眼可见,所以我用AfxBeginThread跑推导,跑完用PostMessage通知主线程刷新。
UINT BuildTableProc(LPVOID pParam) { CBuilder* b = (CBuilder*)pParam; b->Build(); // 闭包+合并+填表 ::PostMessage(b->m_hWnd, WM_TABLE_READY, 0, (LPARAM)b); return 0; }逻辑说明:后台线程只调用无 UI 的构造逻辑;构造完成后通过自定义消息WM_TABLE_READY把构造器指针送回主线程,主线程在消息处理函数里把表格填入 CListCtrl。这样做有两层好处:推导期间界面不冻结,也避开 MFC 控件在非 UI 线程操作会触发断言的老坑。
参数说明:m_hWnd是主窗口句柄;消息编号在ON_MESSAGE映射表里注册,用WM_APP起的自定义编号,别跟系统消息抢。进度反馈是在构造循环里定期更新状态栏,状态栏显示“正在计算第 N 个状态,共 M 个”,这个需求正好是 MFC 状态栏显示信息的典型用法——SetStatusText 只在 UI 线程有效,所以进度消息同样走 PostMessage 而不是直接调用。我在状态栏右侧还会加一个当前动作提示,比如“正在合并同心状态”,方便判断程序卡在哪一步。
表格回显用 CListCtrl 时,先调用SetRedraw(FALSE),灌完数据再SetRedraw(TRUE),否则几百行插入会一卡一顿。列布局是按状态号、各终结符列、各非终结符列分组排,横向多就加横向滚动条,行高不变。这些都做完,整个链路才真正闭环:文件进,表格出,能直接拿来自测。
4. 可复现实现:读文法、算闭包、合并状态、填表四模块的代码骨架
有了前述数据结构和数据流,就可以进入落地环节。这章给出四个模块的最小代码骨架,是我日常照着写就能用的那种,不是官方实现,但足够跑通教学版。
4.1 读入文法文件:产生式切分与终结符/非终结符归类
文法文件用纯文本,每行一条产生式,左部和右部用->分隔,右侧符号用空白分隔。读文件的函数要同时干两件事:把每行拆成一个 Production,并把符号收集进终结符/非终结符集合。判别规则很朴素:在某个产生式左部出现过的符号是非终结符,其余是终结符。
bool loadGrammar(const CString& path, Grammar& g) { CStdioFile f; if (!f.Open(path, CFile::modeRead)) return false; CString line; int id = 1; while (f.ReadString(line)) { line.TrimRight(); if (line.IsEmpty() || line[0] == _T('#')) continue; // 跳过注释行 CString lhs = line.Left(line.Find(_T("->"))); lhs.TrimLeft(); lhs.TrimRight(); CString rhs = line.Mid(line.Find(_T("->")) + 2); Production p{ id++, (string)lhs, {} }; for (auto& tok : splitTokens(rhs)) { p.rhs.push_back(tok); if (tok != lhs) g.symbols.insert(tok); } g.nonTerminals.insert(lhs); g.prods.push_back(p); } g.prods.push_back(Production{0, S_AUG, {g.prods[0].lhs}}); g.symbols.insert(END_SYM); return true; }逻辑说明:读取后,把所有出现在左部的符号登记为非终结符;未出现在任何左部的登记为终结符;#作为结束符自动补进符号集合。增广产生式放到最后并以 0 号 ID 标记,接受动作判断就靠它。过滤以#开头的注释行是经验之谈,文法文件里加注释能省很多调试时的沟通成本。
参数说明:CStdioFile::ReadString逐行读,天然处理\r\n;splitTokens把一行右侧按空白切分,注意 MFC 的 CString 与 std::string 互转要按工程字符集显式转,统一用窄字符存文法内容能绕过一半乱码问题。左部符号在splitTokens里如果出现同名符号,比如E -> E+T,这个 E 是终结符还是非终结符?按“左部出现过的就是非终结符”规则,它就是非终结符,代码里tok != lhs的判断只是为了不把同一名符号重复插入 symbol 表,不影响归类。
4.2 实现 CLOSURE 与 GOTO:队列循环和状态缓存
闭包函数我在第 2 章写过,这里补上配套的 GOTO 和状态生成主循环。构造状态机的常见做法是:从初始项目集开始,维护一个“已生成状态”的队列,每次取一个状态,对每个文法符号求 GOTO;如果得到的项目集没出现过,就给它分配新的状态编号并入队。
while (!frontier.empty()) { int s = frontier.front(); frontier.pop(); for (const string& sym : g.allSymbols) { set<Item> nx = gotoSet(states[s].items, sym, g); if (nx.empty()) continue; int t; auto it = stateIndex.find(nx); if (it == stateIndex.end()) { t = (int)states.size(); states.push_back(State(nx)); stateIndex[nx] = t; frontier.push(t); } else t = it->second; states[s].go[sym] = t; } }逻辑说明:stateIndex用项目集作为键的 map,靠 Item 的严格弱序支撑;每生成一个新项目集就入队,直到队列空表示状态机收敛。sym循环里把终结符和非终结符一视同仁地处理,GOTO 表统一填,填 ACTION 表时只消费终结符的转移边。这个主循环是整个自动构造程序的发动机,前面所有函数都是给这里供数据的。
参数说明:如果文法规模大,stateIndex的键是set<Item>,每次比较成本高,可改成先对项目集排序再哈希;教学版不用优化也能跑上百条产生式。states[s].go[sym]的类型我建议是map<string,int>而不是 vector,因为符号集合在读取阶段才确定,用 map 省去动态分配索引表的麻烦。
4.3 合并同心状态:并集合并与编号重映射
执行 LALR(1) 合并时,我的实现分两个阶段:先扫一遍所有状态,把同心的集合指到一起;再遍历每个状态的转移边,把目标编号重映射成合并后的编号。这里关键点在于重映射一定要在填表之前做,否则生成的 GOTO 表会指向被合并掉的旧状态。
for (auto& s : states) { for (auto& kv : s.go) { int target = kv.second; if (redirect[target] != target) kv.second = redirect[target]; } } for (int i = 0; i < (int)states.size(); ++i) if (redirect[i] != i) states[i].dead = true;逻辑说明:redirect数组在第一次扫描时填充:初始时每个状态指向自己;发现同心状态后,把后出现的状态的 redirect 设为首个同心状态的编号;第二次扫描重定向所有转移边;最后标记废弃状态,填表时跳过。这里最容易漏的是:重定向必须针对所有状态做,包括那些本身没参加合并的状态——它们也可能有指向被合并状态的边。
参数说明:合并后如果两个归约项目的前看符号集合有交集,就会产生归约-归约冲突,这是 LALR(1) 特有的坑,后面避坑章细说。dead标记的状态不参与 ACTION/GOTO 输出,但保留在调试信息里,方便对照 LR(1) 原始状态数看合并效果。我习惯在输出表里加一行注释:原始 LR(1) 状态数 N,合并后状态数 M,一眼能看出合并收效多少。
4.4 填 ACTION/GOTO 表并回显:表格列设计与控件绑定
表格列我按“状态号 + 终结符列(ACTION) + 非终结符列(GOTO)”三段排,状态号固定宽度,符号列通过InsertColumn动态生成。数据填充用InsertItem加SetItemText,大批插入前关重绘,插入结束后打开并Invalidate。
void CMainFrame::OnTableReady(WPARAM, LPARAM lp) { CBuilder* b = (CBuilder*)lp; m_list.SetRedraw(FALSE); m_list.DeleteAllItems(); for (int r = 0; r < (int)b->states.size(); ++r) { if (b->states[r].dead) continue; CString row; row.Format(_T("%d"), r); int idx = m_list.InsertItem(r, row); for (int c = 0; c < b->actionCols; ++c) m_list.SetItemText(idx, c + 1, b->ActionText(r, c)); } m_list.SetRedraw(TRUE); m_list.Invalidate(); }逻辑说明:ActionText把动作单元格转成S12、R3、ACC、空串这类可读文本。为什么用文本而不是裸数字:排错时 S/R/ACC 比裸数字直观十倍。列集合在构造前就已经由终结符/非终结符集合定了,所以表格列在OnInitDialog里先建好,这里只填行。如果完全没关重绘就去灌几千行,轻则闪烁,重则界面假死。
参数说明:InsertItem的第一个参数是行索引,这里用 r 而不是 m_list.GetItemCount(),因为删空后行号从 0 连续排;SetItemText从第 1 列起填符号列,第 0 列是状态号。关闭重绘的SetRedraw(FALSE)要有对应的SetRedraw(TRUE)成对出现,漏了第二步整个表格会一直显示空白,这是 MFC 表格的老戏码。
5. 常见问题与排查:LALR(1) 自动构造的 5 个坑,我全踩过
这个方向血泪经验不少。下面五条按“现象 → 原因 → 解决”写,每一条都是我在 MFC 工程里排查到半夜的教训。
5.1 合并同心项目后多出归约-归约冲突,LR(1) 明明没冲突
现象:同一文法用 LR(1) 构造没有任何冲突,合并成 LALR(1) 后报表报“归约-归约冲突”。原因:两个同心状态原本分别维护各自的前看符号集合,合并后前看符号取并集,一个归约项可能和另一个归约项的前看符号交叠。这是 LALR(1) 相对 LR(1) 唯一会“变差”的点,也是自动构造程序最容易被用户质疑的地方。解决:先定位冲突状态,打印该状态下所有归约项的 look 集合;如果并集重叠,就得接受文法本身不是 LALR(1) 文法这一结论,或者改回 LR(1) 模式输出。程序要在冲突处显式报错,不能默默覆盖。
提示:排查冲突时先看是不是 LALR(1) 合并引入的,再看文法本身。把合并前的 LR(1) 状态表导出来对比,能省一半时间。
5.2 FIRST 集漏处理 ε,分析表悄悄缺项
现象:某条产生式右侧是A→BC,其中 C 可空,闭包结果少了某些前看符号,ACTION 表里某些状态没有预期的 REDUCE 项。原因:在 FIRST(βa) 计算中,β 推导出空串时,你只取了 FIRST(β) 而丢了 a;正确逻辑是先把 FIRST(β) 里所有非 ε 的终结符取进来,若 β 整体可空则追加 a 本身。解决:写一个firstOf(seq, start, look)函数,对seq[start..]做“扫到不可空前看符号为止”的累积;给空串一个专用占位符号,在所有集合里显式推进。这个 bug 最阴的地方是:小文法测不出来,文法一旦带可空的非终结符就缺项。
5.3 MFC 下 CString 与 std::string 混用乱码,首行还可能被 BOM 吃掉
现象:文法文件用 UTF-8 存,程序读出来在编辑框显示正常,进到推导环节后符号全变乱码。原因:工程默认 Unicode,CString 是宽字符,而 std::string 是窄字符,(string)lhs这种强转在 Unicode 配置下把宽字符指针直接当成窄字符指针,等于把编码信息丢了。同族问题还有 UTF-8 带 BOM 的文件:EF BB BF 三个字节被ReadString当成字符读进来,拼在第一个符号前面。解决:统一文法内部表示用 std::string,文件读入后用CT2A显式转一次;打开文件后先读三个字节,如果匹配 BOM 就跳过。不要依赖默认转换,字符集开关一换就翻车。
5.4 CListCtrl 批量插入卡死,界面像假死
现象:大文法生成上千行表,InsertItem循环跑到一半界面像死了,滚轮拖不动。原因:每插一行 CListCtrl 就重绘一次,插入动作被重绘拖垮。解决:循环前SetRedraw(FALSE),结束后SetRedraw(TRUE)并Invalidate。另外还建议数据量大时开虚拟模式LVS_OWNERDATA,只提供数据访问接口,显示由控件按需拉取,插入时间从 O(n) 降到接近常数。这个优化做完,几千行表秒开,观感完全不一样。
5.5 合并状态后 GOTO 指向旧编号,表对不上状态
现象:合并后 GOTO 表里某些格子指向的状态号在最终状态列表里不存在,运行时查表直接越界。原因:合并时生成了 redirect 重映射,但填表阶段直接用旧的states[s].go里的编号,忘了重定向。解决:合并后统一跑一遍“for each state, for each go edge: go[target]=redirect[go[target]]”,并把 dead 标记排除。这个操作放在“填表之前”而不是“填表之后”,顺序倒了同样出问题。我因为这个问题返工过一次,后来在代码里把这个重定向步骤写成了独立函数,每次合并完强制调用。
6. 验证的一手技巧:用移进-归约回放确认表没错,再决定要不要加自动冲突消解
表构造完,我不急着上大文法,先拿一个最小文法走一遍回放。文法:增广产生式E'→E,E→E+T|T,T→i。对输入i+i#,手工逐步骤对照 ACTION 表和 GOTO 表走一遍回放,状态栈、符号栈、输入串和动作列出来,出一步错立刻能定位是哪张表填错了。
| 步骤 | 状态栈 | 符号栈 | 剩余输入 | 动作 |
|---|---|---|---|---|
| 1 | 0 | # | i+i# | S: 移进 i |
| 2 | 0 5 | # i | +i# | R: T→i |
| 3 | 0 3 | # T | +i# | GOTO: 转向 3 |
| 4 | 0 3 | # T | +i# | R: E→T |
| 5 | 0 1 | # E | +i# | S: 移进 + |
| 6 | 0 1 7 | # E + | i# | S: 移进 i |
| 7 | 0 1 7 5 | # E + i | # | R: T→i |
回放规则很机械:查 ACTION 表当前位置和当前输入符号,移进就压栈、读入下一个符号;归约就按产生式右侧长度弹栈,再查 GOTO 把左部符号压回去;直到遇到 ACC。每一步如果查不到动作,说明表里缺项,直接回到对应填表代码找。这比肉眼检查几百个表格单元格高效得多。回放脚本可以做成程序内的自检模式,读文法后自动生成对应 LR(1) 表和回放记录;手工回放只适合最小文法,稍大就该写断言:对于多条合法输入,逐步骤断言动作非空且最终 ACC。
表稳定后,值得加的方向有几个:一是冲突消解策略选项,比如默认移进优先或产生式优先级,课设里显式配置优先级规则,能处理一部分非 LALR(1) 文法,用户不用改文法;二是错误恢复,在表里遇到空动作时跳过若干输入符号继续分析,这是语法分析器好用与否的分水岭;三是 MFC 侧加一个步骤演示对话框,按一下走一步状态栈,把表格生成过程配成动画式展示,比直接给最终表更能说服人——这也是在现有 VS MFC 工程上增加按钮弹出对话框显示实时数据的典型场景,状态栏配合显示当前状态栈深度,效果直观。我自己现在的习惯是:新接一个编译相关项目,先写回放自检验证,再谈界面美化。表不对,界面再漂亮都是虚的。希望这套从原理到避坑的路径能帮你在 LALR(1) 自动构造这个方向上少走几个通宵的弯路。
本文还有配套的精品资源,点击获取