ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

基于MFC的LALR(1)分析表自动构造程序实现与避坑指南

基于MFC的LALR(1)分析表自动构造程序实现与避坑指南 简介本资源面向编译原理课程学习者与课程设计实践者提供一套基于MFC框架实现的LALR(1)分析表自动构造程序帮助理解并动手完成从LR(1)项目集规范族到LALR(1)分析表的完整构造流程。压缩包共52个文件约63.55MB包含cpp与h源码、MFC工程文件、可执行exe、设计报告doc、运行说明md及资源文件等源码与文档齐备便于直接运行验证与二次修改。程序实现了CLOSURE(I)、Go(I,X)、FIRST集构造并支持对任意给定文法构造LR(1)项目集规范族进而生成LALR(1)项目集规范族与分析表以教材例5.13为输入可输出对应分析表。已有315人学习下载适合需要完成编译原理课程设计、理解自底向上语法分析算法或参考MFC界面实现的学习者可借助报告与源码快速掌握构造思路并完成实验验证。1. 从一份 MFC 工程说起LALR(1) 分析表到底能不能自动构造很多人第一次接触编译原理课程设计都会碰到同一个题目写一个能根据文法自动生成 LALR(1) 分析表的程序。手工推导 FIRST 集、FOLLOW 集、LR(0) 项目集规范族、SLR 冲突消解、再到 LALR 的同心项目集合并一套流程走下来纸上能写满好几页但一旦文法规则超过十条手工推导基本就翻车了。这份「基于 MFC 实现的 LALR(1) 分析表自动构造程序」要解决的正是这件事把文法规则作为输入程序自动完成项目集构造、闭包计算、GOTO 转移、动作表与转移表填充最终输出一张可以直接驱动语法分析器的 ACTION/GOTO 表。它适合两类人一类是正在做编译原理课程设计、需要交一份能跑起来的 MFC 对话框程序的学生另一类是想在自己的工具链里嵌入一个轻量语法分析表生成器、又不想引入 yacc/bison 这类外部依赖的工程师。MFC 在这里的角色是界面外壳和文件读写容器核心算法仍然是集合运算和状态机构造。换句话说MFC 负责让你点按钮、看表格、导出结果LALR(1) 算法负责把文法变成表。两者拆开看都不复杂合在一起才是这个标题真正要落地的东西。2. LALR(1) 自动构造的核心原理与数据结构选型2.1 从 LR(0) 项目集到 LALR(1) 同心合并LALR(1) 的本质是在 LR(0) 项目集规范族的基础上给每个项目附加一个向前看符号lookahead然后把同心项目集合并。所谓同心就是两个项目集的核kernel完全相同只是向前看符号不同。合并之后状态数大幅减少同时保留了比 SLR 更精确的冲突消解能力。具体流程分四步走。第一步增广文法加入 S - S 作为起始产生式。第二步构造 LR(0) 项目集规范族用闭包closure和 GOTO 函数生成所有状态。第三步计算每个非终结符的 FIRST 集以及每个项目的向前看符号集合。第四步合并同心项目集重新计算 ACTION 和 GOTO 表遇到移进-归约冲突时用向前看符号决定移进还是归约遇到归约-归约冲突则报错。这里的关键数据结构有三个项目用 (产生式编号, 点位置, 向前看符号集合) 表示状态用项目集合表示状态转移用 mappairint,char, int 存储。MFC 环境下CString 和 CArray 可以完成字符串和动态数组管理但集合运算建议用 std::set 和 std::map避免 CArray 在大量插入删除时的性能问题。2.2 用 MFC 对话框承载文法输入与表格输出界面部分不需要花哨。一个 CListCtrl 显示文法规则一个 CListCtrl 显示 ACTION/GOTO 表两个按钮分别触发「构造分析表」和「导出 CSV」。文法输入用 CEdit 多行文本框每行一条产生式格式约定为左部 - 右部1 | 右部2空产生式用ε表示。// 文法规则解析把 CEdit 中的文本拆成产生式列表 struct Production { CString left; std::vectorCString right; int id; }; std::vectorProduction ParseGrammar(const CString text) { std::vectorProduction prods; int lineStart 0; int prodId 0; while (lineStart text.GetLength()) { int lineEnd text.Find(_T(\n), lineStart); if (lineEnd -1) lineEnd text.GetLength(); CString line text.Mid(lineStart, lineEnd - lineStart); line.Trim(); if (!line.IsEmpty()) { int arrow line.Find(_T(-)); if (arrow 0) { CString left line.Left(arrow); left.Trim(); CString rightPart line.Mid(arrow 2); rightPart.Trim(); // 按 | 拆分候选式 int pipeStart 0; while (pipeStart rightPart.GetLength()) { int pipeEnd rightPart.Find(_T(|), pipeStart); if (pipeEnd -1) pipeEnd rightPart.GetLength(); CString alt rightPart.Mid(pipeStart, pipeEnd - pipeStart); alt.Trim(); Production p; p.left left; p.id prodId; // 按空格拆分右部符号 int sp 0; while (sp alt.GetLength()) { while (sp alt.GetLength() alt[sp] _T( )) sp; int spEnd sp; while (spEnd alt.GetLength() alt[spEnd] ! _T( )) spEnd; if (spEnd sp) { p.right.push_back(alt.Mid(sp, spEnd - sp)); } sp spEnd; } if (p.right.empty()) p.right.push_back(_T(ε)); prods.push_back(p); pipeStart pipeEnd 1; } } } lineStart lineEnd 1; } return prods; }这段代码的逻辑很直接逐行读取按-切分左右部再按|拆分候选式最后按空格拆分右部符号。参数上需要注意两点一是ε的表示必须统一否则闭包计算时会把空串当成普通终结符处理二是产生式编号必须连续且唯一后续项目集和 ACTION 表都依赖这个编号做归约动作的索引。2.3 闭包与 GOTO 的迭代实现闭包计算是 LALR(1) 里最容易写错的部分。给定一个项目集合 I闭包规则是如果项目 A - α·Bβ 在 I 中且 B - γ 是产生式那么把 B - ·γ 加入 I重复直到不再有新项目加入。用队列做迭代比递归更安全避免深层文法导致栈溢出。// 计算项目集的闭包 void Closure(std::setItem items, const std::vectorProduction prods, const std::mapCString, std::vectorint prodMap) { std::queueItem work; for (const auto it : items) work.push(it); while (!work.empty()) { Item cur work.front(); work.pop(); // 点后面是非终结符时展开其所有产生式 if (cur.dotPos (int)cur.prod-right.size()) { CString sym cur.prod-right[cur.dotPos]; auto iter prodMap.find(sym); if (iter ! prodMap.end()) { for (int pid : iter-second) { Item newItem; newItem.prod prods[pid]; newItem.dotPos 0; newItem.lookahead cur.lookahead; // LALR 传播向前看符号 if (items.insert(newItem).second) { work.push(newItem); } } } } } }参数说明items是当前项目集prods是全部产生式prodMap是「非终结符 - 产生式编号列表」的索引。注意lookahead的传播方式LALR(1) 和 LR(1) 的区别就在这里LR(1) 每个项目独立携带向前看符号LALR(1) 在合并同心项目集时把向前看符号求并集。如果这里写成直接赋值而不是并集构造出的表会在某些文法上多出不必要的归约动作导致分析器行为异常。3. 在 Visual Studio 里把 MFC 工程跑起来环境、编译与最小验证3.1 MFC 组件安装与工程创建Visual Studio 默认安装不一定带 MFC。打开 Visual Studio Installer找到「使用 C 的桌面开发」工作负载在右侧「安装详细信息」里勾选「适用于最新 v143 生成工具的 C MFCx86 和 x64」。如果是在离线环境需要提前下载对应的离线包安装时选择「单个组件」页签搜索 MFC 手动勾选。创建工程时选择「MFC 应用」应用程序类型选「基于对话框」项目名称建议用英文避免后续资源文件路径出现中文导致编译警告。创建完成后在资源视图里打开 IDD_XXX_DIALOG拖入两个 List Control、一个 Edit Control 和两个 Button。List Control 的属性里把 View 设为 Report并在 OnInitDialog 里插入列头。// OnInitDialog 中初始化两个列表的列头 BOOL CMyDlg::OnInitDialog() { CDialogEx::OnInitDialog(); m_listGrammar.InsertColumn(0, _T(编号), LVCFMT_LEFT, 50); m_listGrammar.InsertColumn(1, _T(产生式), LVCFMT_LEFT, 300); m_listTable.InsertColumn(0, _T(状态), LVCFMT_LEFT, 60); m_listTable.InsertColumn(1, _T(动作), LVCFMT_LEFT, 200); m_listTable.InsertColumn(2, _T(转移), LVCFMT_LEFT, 200); return TRUE; }这段代码只做界面初始化参数是列宽和列标题。实际项目中列数会根据终结符和非终结符数量动态变化建议在构造分析表完成后再动态插入列而不是在 OnInitDialog 里写死。3.2 构造按钮的完整调用链「构造分析表」按钮的响应函数是整个程序的主入口。调用链是读取 Edit 文本 - 解析文法 - 增广文法 - 构造 LR(0) 项目集 - 计算 FIRST 集 - 传播向前看符号 - 合并同心项目集 - 填充 ACTION/GOTO 表 - 刷新 List Control。void CMyDlg::OnBnClickedBtnBuild() { CString text; m_editGrammar.GetWindowText(text); auto prods ParseGrammar(text); if (prods.empty()) { AfxMessageBox(_T(文法为空或格式错误)); return; } // 增广文法S - S Production aug; aug.left _T(S); aug.right.push_back(prods[0].left); aug.id (int)prods.size(); prods.insert(prods.begin(), aug); LALRBuilder builder(prods); builder.BuildFirstSets(); builder.BuildLR0Collection(); builder.PropagateLookaheads(); builder.MergeStates(); builder.FillTables(); RefreshTableList(builder.GetActionTable(), builder.GetGotoTable()); }参数说明prods是解析后的产生式列表增广产生式的编号放在最前面保证起始状态从 0 开始。LALRBuilder是自定义的构造器类内部维护项目集族、FIRST 集、向前看符号传播表。RefreshTableList负责把 ACTION/GOTO 表映射到 List Control 的行列。3.3 用表达式文法做最小验证验证程序是否正确不需要一上来就写完整 C 语言文法。用下面这个经典表达式文法就够了E - E T | T T - T * F | F F - ( E ) | id输入后点击构造预期得到 12 个左右的状态ACTION 表中id列在初始状态应为移进(列也应为移进$列在 E 的接受状态应为 accept。如果状态数明显偏多或偏少优先检查闭包计算里点位置的推进逻辑以及 GOTO 函数是否对每个符号都做了转移。提示验证时先把 ACTION 表导出成 CSV用文本对比工具和手工推导的结果逐格核对。手工推导虽然慢但它是唯一能定位到具体状态和具体符号的验证方式。4. 避坑与排查LALR(1) 自动构造里最容易翻车的五件事4.1 闭包展开时向前看符号丢失现象构造出的 ACTION 表在某些状态上缺少归约动作语法分析器遇到合法输入却报错。原因闭包计算中新加入的项目没有继承当前项目的向前看符号或者继承时用了赋值而不是并集。LALR(1) 的向前看符号传播是一个迭代到不动点的过程单次遍历不够。解决在闭包函数里对每个新项目执行newItem.lookahead.insert(cur.lookahead.begin(), cur.lookahead.end())并且把闭包计算放在向前看符号传播的循环内部直到项目集不再变化为止。4.2 同心项目集合并后状态编号错乱现象合并状态后GOTO 表里出现指向不存在状态编号的转移程序刷新表格时数组越界。原因合并时删除了部分状态但没有同步更新所有转移目标里的状态编号。常见做法是维护一张旧编号到新编号的映射表合并完成后统一重映射。解决先完成所有合并记录oldToNew映射再遍历所有项目集的转移关系把目标状态编号替换为新编号。重映射必须在填充 ACTION/GOTO 表之前完成。4.3 MFC 字符串与 std::string 混用导致乱码现象文法规则里包含中文注释或全角符号时解析出的产生式左部出现乱码后续集合运算全部失效。原因MFC 默认使用 CString宽字符或 ANSI 取决于工程配置而算法部分常用 std::string。两者直接互转时没有指定编码导致符号表键值不匹配。解决统一在算法层使用 std::wstring或者在工程属性里把字符集设为「使用 Unicode 字符集」解析时用CT2W和CW2T做显式转换。文法符号建议只允许 ASCII 字符从源头避免编码问题。4.4 移进-归约冲突的默认处理掩盖了文法问题现象程序能跑完并输出表但用这个表做语法分析时某些输入被错误接受或错误拒绝。原因遇到移进-归约冲突时代码默认选择移进没有输出冲突警告。对于 LALR(1) 文法冲突应该被报告出来由使用者决定是否通过优先级和结合性声明来消解。解决在 FillTables 阶段收集所有冲突用 AfxMessageBox 或日志窗口列出冲突所在的状态、符号和涉及的产生式。不要静默处理否则调试时完全没有线索。4.5 大量产生式时 List Control 刷新卡死现象文法规则超过 50 条后点击构造按钮界面无响应几秒后才刷新出表格。原因每次 InsertItem 都触发重绘且没有使用 SetRedraw(FALSE) 和 SetRedraw(TRUE) 包裹批量插入。解决刷新前调用m_listTable.SetRedraw(FALSE)插入完成后调用m_listTable.SetRedraw(TRUE)和Invalidate()。如果数据量更大考虑用虚拟列表LVS_OWNERDATA只在实际绘制时提供数据。5. 把分析表接上语法分析器从建表到真正跑通一个输入串构造出 ACTION/GOTO 表只是前半程真正让这套东西有价值的是用它驱动一个语法分析器对输入串做归约和移进最终判断是否接受。这一步能验证表的正确性也能让你看清 LALR(1) 和 LL(1) 在分析流程上的本质差别。分析器的主循环用一个状态栈和一个符号栈。初始时状态栈压入 0符号栈压入$。每一步读取当前状态和输入符号查 ACTION 表如果是移进把符号和下一状态分别压栈输入指针后移如果是归约按产生式右部长度弹出相应数量的状态和符号查 GOTO 表得到新状态压栈如果是 accept输入串被接受如果是 error报错并给出当前状态和符号。// LALR(1) 语法分析主循环 bool ParseInput(const std::vectorint input, const std::mapstd::pairint,CString, Action actionTable, const std::mapstd::pairint,CString, int gotoTable, const std::vectorProduction prods) { std::vectorint stateStack; std::vectorCString symStack; stateStack.push_back(0); symStack.push_back(_T($)); int pos 0; while (true) { int curState stateStack.back(); CString curSym (pos (int)input.size()) ? GetSymbol(input[pos]) : _T($); auto key std::make_pair(curState, curSym); auto it actionTable.find(key); if (it actionTable.end()) { TRACE(_T(Error: no action for state %d, symbol %s\n), curState, curSym); return false; } Action act it-second; if (act.type ActionType::Shift) { stateStack.push_back(act.target); symStack.push_back(curSym); pos; } else if (act.type ActionType::Reduce) { int len (int)prods[act.target].right.size(); for (int i 0; i len; i) { stateStack.pop_back(); symStack.pop_back(); } CString leftSym prods[act.target].left; int newState gotoTable[std::make_pair(stateStack.back(), leftSym)]; stateStack.push_back(newState); symStack.push_back(leftSym); } else if (act.type ActionType::Accept) { return true; } else { return false; } } }这段代码里input是词法分析器输出的 token 序列actionTable和gotoTable就是前面构造出来的两张表。参数上最需要注意的是归约时弹栈的长度产生式右部有几个符号就弹几个状态和符号空产生式弹零个。如果这里多弹或少弹状态栈和符号栈会错位后续查表全部失败。验证时用id id * id这个输入串预期分析器能一路归约到接受状态。如果中途报错回到 ACTION 表里检查出错状态和符号对应的表项通常能定位到是闭包漏了项目还是向前看符号传播不完整。一个我自己的习惯每次改完构造算法先用三个小文法跑一遍——表达式文法、括号匹配文法、以及一个故意包含冲突的文法。前两个验证正确性第三个验证冲突报告是否正常输出。这个习惯帮我省掉了大量在复杂文法上盲目调试的时间。希望帮到你。本文还有配套的精品资源点击获取
返回列表