ARTICLE DETAIL

资讯详情

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

C++手写LL(1)语法树生成器:从First集到预测分析表全实现

C++手写LL(1)语法树生成器:从First集到预测分析表全实现 1. 项目概述用C手写一个完整的LL(1)语法树生成器《编译原理》这门课说它是计算机专业的天花板课程之一一点也不夸张。很多人被那份期末试卷折磨得痛不欲生但真正到了课程设计环节需要你自己动手把理论落地的时候才猛然发现——哦原来First集和Follow集是这么用的原来预测分析表不只是书上一个抽象的表格原来语法树真的是能画出来的。我做这个项目的时间节点正好是学科难度最大、时间又最紧的期末周。当时的需求很明确写一个程序输入一段LL(1)文法能够自动计算出First集和Follow集构造出预测分析表然后拿到一个输入串不仅能判断它是否符合文法还得把分析过程中生成的语法树以直观的形式输出或保存下来。语言指定C不能偷懒用Python糊弄。这个题目覆盖了编译原理前半学期的几乎全部核心内容做完之后我对整个自顶向下分析体系的理解比考前刷三遍课本都扎实。这篇文章就把我的完整实现思路、代码架构、踩过的坑全部记录下来。不管是正在备考编译原理期末、正在做课程设计还是单纯想搞懂LL(1)分析内部原理的同学这篇文章都能给你一个可以直接参考的完整方案。我保证不会只给你一堆晦涩的理论复述而是从“我要写代码了”这个角度讲清楚每一步到底该怎么做为什么要这么做。2. LL(1)分析的整体设计搞清楚我们在构建什么2.1 为什么选择LL(1)一种简单的自顶向下分析策略LL(1)这个名字本身就包含了信息第一个L表示从左向右扫描输入串第二个L表示产生最左推导括号里的1表示每一步只需要向前看一个输入符号就能做出决定。为了让你更直观地理解它在做什么我打个比方LL(1)分析就像一个经验丰富的点菜服务员在跟你推荐菜品他手里有一本菜单文法产生式你每说出一个菜名输入符号他就立刻能决定应该上哪道菜选择哪个产生式完全不用回头问你第二遍。这种“看一眼就能做决定”的能力就是LL(1)文法的核心优势。它的实现原理其实不复杂我们为每个非终结符、每个终结符的组合预先计算好一个预测分析表。表里每一格都写清楚了当栈顶是非终结符A、而你当前读到的输入符号是a时程序该用哪个产生式来展开。有了这张表分析过程就是一个纯粹机械化的模拟逻辑非常清晰代码实现也就水到渠成。2.2 文法的限定条件为什么需要消除左递归和提取左因子不是随便什么文法都能用LL(1)来分析的。要使用LL(1)方法文法必须满足两个前提条件第一不能包含左递归。比如S - Sa | b这种产生式分析的时候会出现无限循环因为每次遇到S程序都会试图再次用S去推导永远也无法推进。消除左递归有一套成熟的算法我们可以把它改写成等价的右递归形式。第二每个非终结符的候选产生式它们的First集必须两两不相交。这一点如果不满足程序在看到输入符号时就不知道该选哪个产生式了也就是我们常说的回溯问题。这时候就需要提取左因子把所有以相同符号开头的候选式合并成一条再用一个新的非终结符来区分不同的走向。如果你的文法已经满足了这两个条件那恭喜你后面的路会顺畅很多。如果还没满足那就得先做消左递归和提左因子这两个预处理。一般课程设计给的文法都会规避这个问题但作为完整的项目我还是在代码里预留了预处理模块方便处理更复杂的文法。2.3 模块划分与数据流从文法到语法树的完整链条整个项目的代码结构我是按照编译原理教材里的经典流程来分层的。每层各司其职层与层之间通过明确的数据结构衔接这样调试的时候不需要从头看到尾定位问题非常方便。文法输入模块从文件或控制台读入产生式集合解析出终结符、非终结符、产生式左部和右部First集计算模块对每个非终结符计算First集要考虑空串产生的传递影响Follow集计算模块基于First集对每个非终结符计算Follow集预测分析表构造模块利用First集和Follow集构造二维表M[N][T]文法分析模块模拟预测分析过程读入输入串利用分析表做出决策语法树生成模块在分析过程中同步构建语法树节点分析结束后输出语法树输出模块支持控制台文本输出和分析树的可视化导出2.4 技术选型C的STL容器如何辅助实现代码全部用C标准库实现没有依赖任何第三方库。核心容器用了map和set配合vector和string就能覆盖所有需求。set用于存储First集和Follow集因为集合里不允许有重复元素而且输出时自带排序方便和教材上的标准答案做对比。map用于存储预测分析表键值对是非终结符和终结符的组合。vector用于实现分析栈和存储产生式的右部序列。还有一个容易被忽略的设计细节我用unordered_map来存储每个非终结符对应的产生式列表。这样查询效率是O(1)比线性遍历vector快很多。文法产生式的数量一般不会太多但好的习惯得从这种小地方养成。3. 核心数据结构设计让文法在代码里“活”起来3.1 文法的表示方式非终结符、终结符与产生式的存储在设计数据结构之前我先把文法里的符号做了一个分类非终结符用大写字母表示也可以带尖括号比如expr终结符用小写字母或者运算符、括号等字符表示。一个产生式展开后有四个关键信息左部一个非终结符、右部符号串、该产生式的编号、右部是否为空即是否产生了空串ε。我定义了如下结构体来管理产生式struct Production { char left; // 产生式左部非终结符 vectorstring right; // 右部符号串支持多符号 string raw; // 原始产生式字符串用于输出 bool isEmpty; // 是否为空产生式 Production(char l, vectorstring r) : left(l), right(std::move(r)) { isEmpty right.empty() || (right.size() 1 right[0] ε); } };文法类Grammar里保存了全部产生式、非终结符集合、终结符集合以及每个非终结符对应的产生式索引方便后续查找。3.2 语法树节点的结构设计父子关系与符号类型语法树的构建是很多同学觉得难的地方原因是分析过程和树结构之间缺乏自然映射。其实关键在于在每次“展开非终结符”的操作中为该非终结符创建一个节点然后让这个节点的子节点就是右部符号串所对应的节点。我设计的语法树节点包含以下字段struct TreeNode { string value; // 节点内容可能是终结符的值也可能是非终结符 bool isTerminal; // 是否为叶子节点终结符 vectorTreeNode* children; // 子节点列表 TreeNode(string v, bool t) : value(std::move(v)), isTerminal(t) {} };这里有一个很重要的概念区分终端符对应的是叶子节点非终结符对应的是内部节点。在构建过程中如果一个非终结符被展开我们会创建新节点并把它挂到父节点下面。如果一个终结符被匹配上我们就创建一个叶子节点。3.3 预测分析表的设计二维映射的巧妙实现预测分析表本质上是一个映射关系M[非终结符A][终结符a] - 产生式编号。C里最自然的表示方式是用嵌套mapmapchar, mapchar, int table; // M[A][a] production_id也可以扁平化成mappairchar, char, int但嵌套map在按行打印、按列查询时更方便。我在代码里用了嵌套map在处理“分析表中是否存在某个条目”时只需要先查外层map再查内层map逻辑很清晰。每次查询的时候还会涉及到一个边界情况如果M[A][a]不存在说明这里的输入串不符合文法应当报错。所以查询函数要区分“存在但是空条目”和“条目不存在”这两种情况。这个细节在笔试里不会考但在实际写代码时很容易踩坑。3.4 符号栈的设计分析栈与语法树栈的双栈联动分析过程需要维护两个栈符号栈和节点栈。符号栈就是我们平时说的分析栈栈里压入的是终结符和非终结符初始状态通常是#和文法开始符号。每一步程序根据栈顶符号和当前输入符号查预测分析表决定下一步动作。节点栈则保存对应的语法树节点指针。每次往符号栈里压一个符号就往节点栈里压一个对应的节点指针。每次从符号栈弹出栈顶符号节点栈也同步弹出。这两个栈同步操作是构建语法树的关键技巧。这里可以类比搭积木的过程符号栈告诉你下一步该往哪里拼接节点栈则记录了拼接完成的积木块。二者总是形影不离才能保证最后一棵完整的树能拼出来。4. First集与Follow集的计算LL(1)分析表的地基4.1 计算First集的算法流程从直接推出到空串传递First集的定义是从一个符号出发所有可能推导出的终结符的集合。计算方法有两条规则如果X是终结符那么First(X) {X}如果X是非终结符查它的所有产生式右部第一个符号能推导出的终结符都应该加入First(X)如果右部第一个符号能推导出空串则继续看第二个符号以此类推实际计算的时候我采用了“迭代直到集合不再变化”的策略。大概思路如下while (changed) { changed false; for (auto prod : grammar.productions) { setchar current firstSet[prod.left]; int i 0; while (i prod.right.size()) { string symbol prod.right[i]; if (isUpper(symbol[0])) { // 非终结符 current.insert(firstSet[symbol[0]].begin(), firstSet[symbol[0]].end()); if (!firstSet[symbol[0]].count(ε)) break; } else { // 终结符 current.insert(symbol[0]); break; } i; } if (i prod.right.size()) current.insert(ε); if (current ! firstSet[prod.left]) { firstSet[prod.left] current; changed true; } } }这段代码的关键在于内层while循环它处理了“右部前几个符号都能推出空串”的传递情况。4.2 计算Follow集的算法细节什么时候该加入空串Follow集的定义是在所有句型中紧跟在非终结符A后面的终结符集合。注意Follow集不含空串。计算规则有三条如果A是开始符号那么#或$加入Follow(A)如果有产生式 B - αAβ那么First(β)中除ε以外的所有符号加入Follow(A)如果有产生式 B - αA 或者 B - αAβ 且β可以推导出空串那么Follow(B)中的所有符号加入Follow(A)第二条规则特别容易漏。我在实现的时候对每个产生式做了一次完整扫描从左到右遍历右部符号如果遇到非终结符A就把它后面紧邻的符号串的First集去掉ε加入Follow(A)。如果A是右部的最后一个符号则把左部的Follow集加入Follow(A)。如果A后面跟了多个符号但中间有ε传递情况也一样需要一直往后“穿透”。整个计算过程同样采用迭代收敛的方式因为Follow集之间可能存在传递依赖比如S - A,A - B,B - C这样嵌套深的情况一次扫描可能无法把所有元素收敛到位。用循环直到集合不再变化是最稳妥的办法。4.3 构造预测分析表一个带条件的双层循环有了First集和Follow集构造预测分析表就是一个机械的操作对每一条产生式 A - α对First(α)中的每个终结符a除ε外把该产生式填入 M[A][a]如果α可以推导出空串则对Follow(A)中的每个终结符b把该产生式填入 M[A][b]如果同一个格子中出现了多条产生式就说明文法是二义性的或者不具备LL(1)条件这时候程序应该给出明确提示。下面是我实现的核心循环片段for (int id 0; id grammar.prods.size(); id) { Production prod grammar.prods[id]; setchar firstOfRight computeFirstOfString(prod.right); for (char terminal : firstOfRight) { if (terminal ε) continue; if (table[prod.left].count(terminal)) { cerr 冲突 文法不是LL(1)文法 endl; exit(1); } table[prod.left][terminal] id; } if (firstOfRight.count(ε)) { for (char terminal : followSet[prod.left]) { if (table[prod.left].count(terminal)) { cerr 冲突 文法不是LL(1)文法 endl; exit(1); } table[prod.left][terminal] id; } } }这里有一个容易犯的错误填表时用的产生式编号应该是原始产生式的编号不要在后面排重的时候改变了对应关系。我在写的时候因为这个问题吃过亏后面排查了很久才发现是编号错位了。4.4 处理空产生式的特殊逻辑为什么ε不能直接进表空产生式也就是形如A - ε的产生式在预测分析表中不会直接出现以ε为列名的表项。它的作用是通过Follow集体现出来的只有当当前输入符号属于Follow(A)时我们才选择用A产生空串。这一点很容易理解如果A推导为空串那等于说A不消耗任何输入符号那我们就得去匹配A后面的东西而这个“后面的东西”就是Follow(A)里的符号。比如文法E - T EE - T E | ε遇到E时如果输入符号是就选第一条产生式如果输入符号是)或#就选第二条空产生式。这种“取决于下一个输入符号”的决策逻辑正是LL(1)分析中“1”的含义。5. 语法树的自动生成从分析栈到树结构的映射5.1 同步栈的设计思路把每一步推导都记录下来在LL(1)分析过程中分析的每一步都是对最左非终结符的替换。符号栈的变化过程反映了推导的脉络而语法树正是这个推导过程的可视化。我在分析过程中维护了一个节点栈nodeStack栈中每个元素是TreeNode*类型与符号栈一一对应。当程序需要用产生式 A - α 展开时会做这几件事从符号栈弹出栈顶的非终结符A从节点栈弹出对应的节点N内容为A为α中的每个符号创建子节点将创建的子节点按顺序挂到N的children列表将α的符号序列逆序压回符号栈同时将对应的子节点序列逆序压回节点栈为什么是逆序压栈呢因为栈是先进后出的。如果我们想最先处理α的第一个符号那就得让它最后被压进去所以整体倒过来压。5.2 展开操作的实现创建节点与维护父子关系下面的代码展示了展开操作的核心逻辑TreeNode* expand(stackchar symbolStack, stackTreeNode* nodeStack, Production prod) { // 弹出非终结符 char nonTerminal symbolStack.top(); symbolStack.pop(); TreeNode* currentNode nodeStack.top(); nodeStack.pop(); // 为右部每个符号创建子节点 vectorTreeNode* children; for (string sym : prod.right) { if (sym ε) continue; // 空产生式不产生子节点 TreeNode* child new TreeNode(sym, isTerminal(sym[0])); children.push_back(child); } currentNode-children children; // 逆序将子节点压入节点栈 for (auto it children.rbegin(); it ! children.rend(); it) { nodeStack.push(*it); symbolStack.push((*it)-value[0]); } return currentNode; }这段代码的逻辑虽然简短但对应了整个推导展开的过程。正确处理“空产生式不产生子节点”是避免语法树里出现空白节点的重要一步。5.3 匹配终结符的细节叶子节点如何加入树中当栈顶和输入指针指向的符号都是终结符并且它们相等时就执行匹配操作。此时符号栈弹出该终结符输入指针前移一位节点栈弹出的叶子节点不需要做任何额外处理——因为它已经是整棵树的一个叶子了。有一点需要特别注意匹配的终结符不应该再创建新节点因为它对应的节点在之前展开父非终结符时就已经创建了。如果再次创建就会造成重复节点树里出现两个一模一样的叶子。这个细节是很多人写错的地方。我当时调试了很久发现语法树里多出了很多重复的终结符节点最后定位到问题就出在这里。处理的方法很简单匹配时只用节点栈里pop出来的那个节点它已经挂到了父节点上不需要再做额外操作。5.4 完整分析流程一个带状态机的模拟循环整个分析过程可以看作一个状态机循环执行直到接受或报错bool analyze(string input) { stackchar symbolStack; stackTreeNode* nodeStack; symbolStack.push(#); symbolStack.push(startSymbol); nodeStack.push(new TreeNode(#, true)); nodeStack.push(new TreeNode(string(1, startSymbol), false)); int idx 0; input #; while (true) { char top symbolStack.top(); char cur input[idx]; if (top # cur #) { cout 分析成功 endl; return true; } if (isTerminal(top)) { if (top cur) { symbolStack.pop(); nodeStack.pop(); idx; } else { cerr 终结符匹配失败: top vs cur endl; return false; } } else { // 非终结符查分析表 if (table[top].count(cur)) { int prodId table[top][cur]; expand(symbolStack, nodeStack, grammar.prods[prodId]); } else if (table[top].count(ε)) { expand(symbolStack, nodeStack, grammar.prods[table[top][ε]]); } else { cerr 查表失败无法处理: top 遇到 cur endl; return false; } } } }这里需要注意的是代码中我加入了table[top].count(ε)的判断这对应了当输入符号不在该非终结符的任何普通条目中时尝试用空产生式进行匹配的逻辑分支。6. 完整运行效果以经典算术表达式文法为例6.1 测试文法准备一个教科书级别的案例我用来测试的文法是编译原理课程中最经典的一个算术表达式文法。它已经是消除左递归和提取左因子后的形式非常适合用来验证LL(1)分析的每个环节E - T E E - T E | ε T - F T T - * F T | ε F - ( E ) | id这里我稍作了简化处理将终结符id视为一个整体。输入串id id * id是一个标准的测试用例它应该能被程序接受并生成完整的语法树。这个文法还隐含了一个重要特性乘法表达式id * id中*和id之后会被归约到T的处理逻辑中所以*操作符的优先级高于分析结果也应该反映出这种优先级关系。6.2 输出展示语法树的几种呈现方式程序运行结束后语法树可以以多种形式输出第一种是文本形式的层级展示这种最简单也最直接E ├── T │ ├── F │ │ └── id │ └── T │ └── ε ├── E │ ├── │ ├── T │ │ ├── F │ │ │ └── id │ │ └── T │ │ ├── * │ │ ├── F │ │ │ └── id │ │ └── T │ │ └── ε │ └── T │ └── ε第二种是以括号表达式的形式输出。这种形式的输出可以直接与原始的输入串对应上方便对照验证语法树的正确性。比如输入的id id * id转换成括号表达式后就是(id (id * id))。第三种是输出为Graphviz格式的dot文件这样可以用可视化工具渲染出树形图。这个方法比较高级但考试或课程设计答辩的时候展示效果很好。可以在输出模块中加入这个选项。6.3 性能优化与扩展从单字符到多字符Token的演进教材上通常用单字符做演示比如终结符就是一个字母。但真实的编程语言里Token往往是多字符的比如关键字int、标识符foo、数字123等。如果只支持单字符程序的实用性就会大打折扣。我进一步做了一个扩展在词法分析阶段先将输入的id id * id转换成一个Token序列每个Token保存字符串值和类型。语法分析时输入指针每次读取一个完整的Token而不是一个字符。分析表的列也用Token类型来区分而不是终结符字符。这样整个系统就能处理多字符标识符、数字常量等更接近真实场景的情况。Token{typeID, valuea} Token{typeOPERATOR, value} Token{typeID, valueb} Token{typeOPERATOR, value*} Token{typeID, valuec}这种方式本质上没有改变LL(1)分析器的核心逻辑只是把“符号”的粒度从字符变成了Token。对于想在课程设计里进一步加分的同学来说这个扩展是一件性价比很高的事。7. 常见问题与调试技巧实录7.1 分析表冲突的排查策略文法不是LL(1)时怎么办如果你运行程序时遇到了“冲突”的报错说明你的文法不安全并不满足LL(1)条件。需要依次检查是否存在左递归。有左递归的文法必然不是LL(1)文法需要先消除是否存在公共左因子。有左因子的文法会产生预测冲突需要提取是否存在二义性。像S - i S | i S e S这样的二义性文法显然无法使用LL(1)排查冲突的一个实用技巧是在报错前打印出发生冲突的那个产生式编号和对应的表项。这样你就能知道是哪两条产生式在打架再去看产生式本身的问题会高效很多。7.2 程序死循环的定位方法别让分析栈陷入无限展开死循环是LL(1)分析器最常见的问题之一。症状表现为程序运行很久不结束或者内存占用不断增加。通常有这几种情况文法中的左递归没有被消除。A - A B这样的产生式会让分析器反复把A压回栈顶空产生式使用不当。如果某个非终结符的Follow集包含它自己可能导致无限空推导分析表构造错误。某个格子里的产生式右部包含了与左部相同的非终结符且没有消耗任何输入符号排查死循环的有效方式是每执行一步就把符号栈的内容打印出来观察栈是否在某个状态附近循环振荡。我在调试时加了maxSteps参数超过初始设定的步数后强制终止并打印当时的栈状态这能帮你迅速定位到是哪一步出了问题。7.3 语法树节点重复的坑理清节点所有权是谁的学习构建语法树时一个很容易犯的错误是为终结符也重复创建了节点。展开非终结符时创建了一批叶子节点等匹配终结符时又创建了一次结果树里出现两套相同的叶子。正确的做法是节点只由展开操作创建匹配操作只负责“消耗”节点。节点栈中pop出的叶子节点已经挂在了它的父节点上匹配结束直接丢弃指针即可不需要再创建新节点。另外注意内存管理的问题。C不像Java有垃圾回收机制所有通过new创建的节点都需要在程序结束时释放。如果分析成功这棵树需要保留可以在析构函数里递归释放所有子节点。如果分析失败也要清理已创建的节点避免内存泄漏。用shared_ptr可以简化这一过程但课程设计阶段用裸指针加手动释放也完全可行只要确保每条路径都执行释放即可。7.4 CFG数值符号和中文符号的处理别在细节上翻车很多同学的文法里会用到中文逗号、中文括号这会导致程序无法识别终结符。建议在输入预处理阶段统一做符号规范化处理将所有全角符号转成半角符号并去掉空格和制表符。另外一个细节是变浇符号和分隔符的处理。文法中-可以用-或::或→表示程序解析时应该统一识别。我在实现时做了一个预处理函数把-统一映射为|来区分不同候选式。这样虽然只是很小的处理逻辑但能省掉很多头大的debug时间。8. 个人经验总结与后续优化方向写这个项目时我最深刻的体会是编译原理看似抽象难懂但当你真正把它变成一段段可以运行的代码时很多概念就不那么高深了。比如分析表书上的定义看起来像天书但当你亲手把First集、Follow集的数据填进map里再看着程序根据这张表一步步接受正确的句子时那种成就感是刷多少道题都体会不到的。如果后续想继续扩展这个项目我有几个推荐的方向加上错误恢复机制。目前的程序遇到错误就直接报错退出真实的编译器需要从错误中恢复以便继续诊断后续的错误支持更复杂的文法。比如一般的上下文无关文法可以先进行消左递归预处理再进行LL(1)分析把语法树输入中间代码生成阶段。很多学校的大作业会让你实现从语法分析到中间代码生成的完整编译器前端这个LL(1)语法树可以作为下游模块的输入加上图形化界面。比如用Qt或者Web技术做一个可视化交互工具输入文法就能实时看到First集、Follow集和分析过程动画这对教学演示非常有帮助最后再分享一个小技巧调试LL(1)分析器时一定要准备几个典型测试用例。一个成功用例、一个在词法上合法但在语法上非法的用例、一个在预测分析表上找不到对应项的用例。这三类用例覆盖了分析器可能走的绝大多数路径用它们来验证程序的正确性能帮助你在最短时间内找出bug。写编译器的过程就是这样——世界上的源程序千千万万条但正确判断它们的规律其实就藏在那一张小小的分析表里。
返回列表