多彩编程 多彩编程MZPH · CODE BLOG
ARTICLE DETAIL

文章详情

深耕前端与后端开发技术的一线实战笔记与踩坑复盘。

C++实现LL(1)语法分析器:从FIRST/FOLLOW集到预测分析表

C++实现LL(1)语法分析器:从FIRST/FOLLOW集到预测分析表 简介这份资源面向计算机专业学生与编译原理学习者提供一套基于C实现的LL(1)文法分析器课程设计代码解决从文法规则自动生成分析表并完成语法分析的问题。压缩包共11个文件以8个cpp源文件为核心配合1个头文件与说明文档整体约12KB涵盖文法预处理、FIRST集与FOLLOW集计算、分析表生成及主分析流程等模块结构清晰便于按功能阅读。已有406人学习下载适合作为编译原理实验参考。读者可从中获得完整的LL(1)分析器实现思路理解FIRST集、FOLLOW集与预测分析表的构造方法并借鉴C中STL容器与面向对象方式组织文法规则、处理分析冲突与错误恢复的实践写法对课程设计与编译器前端入门均有参考价值。1. 从文法到分析表LL(1) 分析器到底在解决什么问题你手里有一份文法比如E - T E、E - T E | ε这种想让它自动变成能跑的语法分析器。手写递归下降当然可以但文法一改就得重写代码维护成本高得离谱。LL(1) 分析器的价值就在于把文法当输入程序自动算出 FIRST 集、FOLLOW 集和预测分析表然后靠一张表加一个栈就能驱动分析。这套东西在编译器前端、DSL 解析、配置文件读取里都用得上尤其适合文法不大但需要频繁调整的场景。用 C 实现的好处是性能可控、内存布局清晰而且能直接嵌进现有工程。这篇文章会从数据结构设计讲到分析表构造再到用栈跑通一个完整表达式每一步都给可复现的代码和参数说明。适合有 C 基础、想搞懂语法分析器内部机制的人也适合正在做编译原理课设但不想只交个玩具的人。2. 文法表示与 FIRST/FOLLOW 集用 C 把集合算对2.1 文法怎么存产生式结构体与符号表先把文法从文本变成内存里的结构。常见做法是定义一个Production结构体左边一个非终结符右边一个符号序列。符号用字符串存终结符和非终结符靠一个set区分。这里有个容易翻车的地方空串 ε 不能直接当普通符号处理得单独标记。#include iostream #include vector #include set #include map #include string #include sstream const std::string EPSILON ε; struct Production { std::string lhs; // 左部非终结符 std::vectorstd::string rhs; // 右部符号序列空串用 EPSILON 表示 }; class Grammar { public: std::vectorProduction productions; std::setstd::string nonTerminals; std::setstd::string terminals; std::string startSymbol; void addProduction(const std::string line) { // 输入格式: E - T E std::istringstream iss(line); std::string lhs, arrow; iss lhs arrow; Production p; p.lhs lhs; nonTerminals.insert(lhs); if (startSymbol.empty()) startSymbol lhs; std::string sym; while (iss sym) { if (sym |) { productions.push_back(p); p.rhs.clear(); continue; } p.rhs.push_back(sym); } productions.push_back(p); } void finalize() { for (auto p : productions) { for (auto s : p.rhs) { if (nonTerminals.find(s) nonTerminals.end() s ! EPSILON) { terminals.insert(s); } } } } };addProduction按行解析遇到|就切分成多条产生式。finalize在所有产生式读完后统一扫描把没出现在左部的符号归为终结符。参数上唯一要注意的是 ε 的写法代码里用ε字符串如果你从文件读建议统一替换成#或epsilon避免编码问题导致匹配失败。2.2 FIRST 集计算递归加记忆化别用纯循环硬怼FIRST 集的规则不复杂对每个非终结符看它每条产生式右部第一个符号。如果是终结符直接加入如果是非终结符把它的 FIRST 集并进来如果它能推出 ε就继续看下一个符号。坑在于非终结符之间会相互依赖纯循环要反复迭代到不动点写起来容易漏。我一般用递归加visited标记配合一个mapstring, setstring缓存结果。递归深度等于非终结符依赖链长度文法不大时完全够用。std::mapstd::string, std::setstd::string firstSets; std::setstd::string computeFirst(const std::string sym, Grammar g, std::setstd::string visiting) { if (g.terminals.count(sym) || sym EPSILON) { return {sym}; } if (firstSets.count(sym)) return firstSets[sym]; if (visiting.count(sym)) return {}; // 防左递归死循环 visiting.insert(sym); std::setstd::string result; for (auto p : g.productions) { if (p.lhs ! sym) continue; if (p.rhs.empty()) { result.insert(EPSILON); continue; } bool allNullable true; for (auto s : p.rhs) { auto fs computeFirst(s, g, visiting); result.insert(fs.begin(), fs.end()); if (fs.find(EPSILON) fs.end()) { allNullable false; break; } } if (allNullable) result.insert(EPSILON); } visiting.erase(sym); firstSets[sym] result; return result; }visiting集合是后悔药防止文法有左递归时栈溢出。allNullable控制是否继续往后看只要当前符号不能推出 ε后面的符号就不该进 FIRST 集。这个逻辑写错的话FIRST 集会多出不该有的终结符后面分析表跟着全错。2.3 FOLLOW 集计算从起始符的$开始推FOLLOW 集解决的是「非终结符后面可能跟什么」。起始符号的 FOLLOW 里先放一个结束符$。然后遍历所有产生式对右部每个非终结符 B看它后面的符号 β把 FIRST(β) 去掉 ε 加进 FOLLOW(B)如果 β 能推出 ε 或者 B 在末尾就把 FOLLOW(左部) 加进 FOLLOW(B)。std::mapstd::string, std::setstd::string followSets; void computeFollow(Grammar g) { followSets[g.startSymbol].insert($); bool changed true; while (changed) { changed false; for (auto p : g.productions) { for (size_t i 0; i p.rhs.size(); i) { std::string B p.rhs[i]; if (!g.nonTerminals.count(B)) continue; std::setstd::string trailer; bool nullable true; for (size_t j i 1; j p.rhs.size(); j) { auto fs firstSets[p.rhs[j]]; for (auto x : fs) if (x ! EPSILON) trailer.insert(x); if (fs.find(EPSILON) fs.end()) { nullable false; break; } } if (nullable) { for (auto x : followSets[p.lhs]) trailer.insert(x); } for (auto x : trailer) { if (followSets[B].insert(x).second) changed true; } } } } }这里用while(changed)迭代到不动点因为 FOLLOW 集可能因为其他非终结符的更新而需要重新传播。参数上注意$是结束标记别和文法里的终结符重名。如果文法里有$这个终结符换成#或EOF。3. 预测分析表构造把集合变成一张可查的表3.1 分析表的数据结构与填充规则预测分析表是一个二维表行是非终结符列是终结符加$格子里放产生式编号。规则很直接对每条产生式A - α对 FIRST(α) 里每个终结符 a把这条产生式填进M[A][a]如果 α 能推出 ε就对 FOLLOW(A) 里每个符号 b把A - ε填进M[A][b]。std::mapstd::string, std::mapstd::string, int parseTable; void buildParseTable(Grammar g) { for (size_t idx 0; idx g.productions.size(); idx) { auto p g.productions[idx]; std::setstd::string firstAlpha; bool nullable true; for (auto s : p.rhs) { auto fs firstSets[s]; for (auto x : fs) if (x ! EPSILON) firstAlpha.insert(x); if (fs.find(EPSILON) fs.end()) { nullable false; break; } } if (p.rhs.empty()) nullable true; for (auto a : firstAlpha) { parseTable[p.lhs][a] (int)idx; } if (nullable) { for (auto b : followSets[p.lhs]) { parseTable[p.lhs][b] (int)idx; } } } }idx是产生式在productions里的下标后面分析时靠它取右部。如果同一个格子被填了两次说明文法有冲突不是 LL(1)。实际工程里我会在填充时检查parseTable[lhs][a]是否已存在存在就打印冲突信息方便定位是哪两条产生式打架。3.2 冲突检测LL(1) 到底能不能用LL(1) 的硬性条件是对每个非终结符任意两条产生式的 FIRST 集不相交如果某条能推出 ε那它的 FIRST 集和另一条的 FOLLOW 集也不能相交。检测代码就加在填充循环里if (parseTable[p.lhs].count(a)) { std::cerr 冲突: 非终结符 p.lhs 在符号 a 上有多条产生式\n; }常见冲突来源是左递归和公共左因子。左递归比如E - E T | TFIRST 集直接和自己撞。解决办法是改写成右递归E - T EE - T E | ε。公共左因子比如S - if E then S | if E then S else S需要提取左因子。这两步不做分析表根本填不出来别硬上。3.3 用表驱动分析栈 输入指针跑通表达式分析器主体是一个栈初始放$和起始符号。每次看栈顶和当前输入符号查表决定展开哪条产生式。如果是终结符就匹配并前进如果是非终结符就弹栈并把右部逆序压入。bool parse(const std::string input, Grammar g) { std::vectorstd::string stack {$, g.startSymbol}; std::vectorstd::string tokens; std::istringstream iss(input); std::string tok; while (iss tok) tokens.push_back(tok); tokens.push_back($); size_t pos 0; while (!stack.empty()) { std::string top stack.back(); std::string cur tokens[pos]; if (top $ cur $) return true; if (g.terminals.count(top) || top $) { if (top cur) { stack.pop_back(); pos; } else return false; } else { if (!parseTable[top].count(cur)) return false; int idx parseTable[top][cur]; stack.pop_back(); auto rhs g.productions[idx].rhs; for (auto it rhs.rbegin(); it ! rhs.rend(); it) { if (*it ! EPSILON) stack.push_back(*it); } } } return false; }输入按空格分词$是结束标记。压栈时逆序是因为栈是后进先出逆序压入才能保证左部先被处理。如果返回 false先检查输入串是不是按空格分好了再检查分析表对应格子是不是空的。空表示当前状态没有合法动作要么输入有误要么文法不是 LL(1)。4. 避坑与排查LL(1) 实现里最容易翻车的 5 个点4.1 现象FIRST 集算出来少了终结符分析表大片空白原因通常是递归计算时visiting集合没清干净或者allNullable逻辑写反了。比如A - B cB 能推出 ε那 c 应该进 FIRST(A)。如果代码在 B 的 FIRST 里看到 ε 就 breakc 就丢了。解决方法是确保只有当前符号的 FIRST 不含 ε 时才停止往后看含 ε 就继续。4.2 现象FOLLOW 集迭代不收敛程序卡死原因一般是产生式右部有自身递归且更新逻辑没有去重。followSets[B].insert(x).second返回 false 表示没插入新元素changed就不会置 true。如果忘了用返回值判断每次都置 true循环永远不停。检查所有insert调用确保只在真正新增元素时标记变化。4.3 现象分析表冲突但文法看起来没问题先查左递归。E - E T这种直接左递归会让 FIRST(E) 包含 FIRST(E)自己和自己冲突。改成E - T EE - T E | ε。再查公共左因子比如两条产生式右部开头相同需要提取成新非终结符。这两步是 LL(1) 的硬门槛绕不过去。4.4 现象分析时栈顶是终结符但和输入不匹配直接失败常见原因是输入分词没处理好。比如输入id id如果按字符读i、d会被拆开。必须按词法单元分词id作为一个整体。另外检查$是不是只加了一次重复加会导致提前匹配结束。建议在parse入口打印 tokens 列表肉眼确认一遍。4.5 现象ε 产生式导致栈操作异常如果产生式右部是 ε压栈时不能把ε压进去否则栈顶永远匹配不上。代码里if (*it ! EPSILON)就是干这个的。另外分析表填充时ε 产生式要填在 FOLLOW 集对应的列上不是 FIRST 集。这两处搞混分析器要么死循环要么直接报错。5. 进阶技巧用文件驱动文法并做批量验证把文法硬编码在main里只能算 demo。实际用的时候我会把文法写成文本文件每行一条产生式|分隔候选式ε用epsilon代替避免编码问题。读入后先做左递归消除和左因子提取再算集合、建表、跑测试用例。// grammar.txt 示例 // E - T E // E - T E | epsilon // T - F T // T - * F T | epsilon // F - ( E ) | id void loadGrammarFromFile(const std::string path, Grammar g) { std::ifstream fin(path); std::string line; while (std::getline(fin, line)) { if (line.empty() || line[0] /) continue; // 把 epsilon 统一替换成 ε size_t pos; while ((pos line.find(epsilon)) ! std::string::npos) { line.replace(pos, 7, EPSILON); } g.addProduction(line); } g.finalize(); }批量验证的做法是准备一组「合法输入」和「非法输入」合法输入必须全部返回 true非法输入必须全部返回 false。我一般会写一个简单的测试循环std::vectorstd::string valid {id id, id * id, ( id id ) * id}; std::vectorstd::string invalid {id , id, id id}; for (auto s : valid) { if (!parse(s, g)) std::cerr 合法输入被拒: s \n; } for (auto s : invalid) { if (parse(s, g)) std::cerr 非法输入被收: s \n; }参数上唯一要调的是分词逻辑。如果文法里的终结符包含多字符比如、分词时得按最长匹配来不能简单按空格切。我习惯在parse之前先跑一遍词法分析把输入转成 token 序列再送进去这样分析器只关心 token 类型不关心原始字符。最后说个血泪经验LL(1) 的调试成本主要在集合计算阶段分析表一旦建对后面基本不会错。所以每算完一个集合打印出来和手算结果对一遍比后面拿着错误分析表到处找 bug 省事得多。希望帮到你。本文还有配套的精品资源点击获取
返回列表