ARTICLE DETAIL

资讯详情

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

编译原理实验代码包拆解:词法分析、LL(1)、逆波兰式与LR(1)的C++实现

编译原理实验代码包拆解:词法分析、LL(1)、逆波兰式与LR(1)的C++实现 简介本资源是编译原理课程实验的完整配套资料面向计算机专业学生及需要动手实现编译前端的学习者围绕词法分析器设计、LL(1)分析法、逆波兰式生成与计算、LR(1)分析法四个核心实验展开帮助读者把课堂理论落到可运行的代码上。压缩包共35个文件约789KB以cpp源码、docx实验报告、txt与md说明文档为主另含xls分析表、png流程图等辅助材料四个实验各自配有参考资料与demo目录结构清晰便于按模块查阅。目前已有150人学习下载。读者可获得四类分析器的C实现、配套实验报告与文档说明以及FIRST/FOLLOW集、预测分析表、状态栈规约等关键环节的实现思路适合对照代码理解自顶向下与自底向上分析的差异并在此基础上修改调试、完成课程实验。1. 编译原理实验代码包拆解从词法分析到 LR(1) 的四个硬骨头很多人学编译原理教材翻到“龙书”第三章就开始发懵——正则表达式、NFA、DFA 之间的转换公式背得滚瓜烂熟真让你写一个能跑的词法分析器连怎么把字符流切成 token 都无从下手。这个实验代码包解决的正是这个断层它把编译原理课程里最核心的四个实验——词法分析器、LL(1) 分析法、逆波兰式的生成及计算、LR(1) 分析法——全部用 C 落地成可编译运行的工程每个实验独立成目录附带参考资料和 demo。适合正在上编译原理课、需要交实验报告但不想从零造轮子的学生也适合想重新捡起编译器前端基础的开发者。代码结构清晰每个实验的 readme 说明了输入输出格式拿到手就能编译跑通省去大量搭框架的时间。2. 词法分析器从正则到 token 流的 C 实现2.1 为什么先啃词法分析器编译流程的第一道关卡就是词法分析。源程序进来是一串字符词法分析器要把它切成有意义的 token 序列——关键字、标识符、常数、运算符、界符每个 token 带上类型和值交给后面的语法分析器。这个实验包里 experiment_1 对应的就是词法分析器代码结构通常是一个Token结构体存类型和值一个Lexer类负责扫描字符流内部用状态机或者正则匹配来识别不同模式。选 C 做这件事的好处是控制粒度细。你可以直接用char级别操作手动管理指针位置对理解“扫描”这个动作的本质很有帮助。很多学校实验要求用 Java 或 Python但 C 版本能让你看清字符是怎么一个接一个被吃掉的没有高级字符串库帮你兜底。2.2 核心代码结构与参数说明下面是一个典型的词法分析器骨架我根据实验包的常见实现整理出来的你可以对照 experiment_1 的源码看// token 类型枚举 enum TokenType { KEYWORD, // 关键字如 int, if, while IDENTIFIER, // 标识符变量名、函数名 NUMBER, // 数字常量 OPERATOR, // 运算符 - * / DELIMITER, // 界符 ( ) { } ; , END_OF_FILE // 文件结束 }; struct Token { TokenType type; std::string value; int line; // 记录行号方便报错 }; class Lexer { private: std::string source; // 待分析的源程序 size_t pos; // 当前扫描位置 int currentLine; // 当前行号 public: Lexer(const std::string src) : source(src), pos(0), currentLine(1) {} Token nextToken() { // 跳过空白字符同时更新行号 while (pos source.size() isspace(source[pos])) { if (source[pos] \n) currentLine; pos; } if (pos source.size()) { return {END_OF_FILE, , currentLine}; } char ch source[pos]; // 识别标识符或关键字字母开头后跟字母数字下划线 if (isalpha(ch) || ch _) { std::string buf; while (pos source.size() (isalnum(source[pos]) || source[pos] _)) { buf source[pos]; } // 查关键字表命中则返回 KEYWORD if (keywords.count(buf)) return {KEYWORD, buf, currentLine}; return {IDENTIFIER, buf, currentLine}; } // 识别数字简单处理整数 if (isdigit(ch)) { std::string buf; while (pos source.size() isdigit(source[pos])) { buf source[pos]; } return {NUMBER, buf, currentLine}; } // 识别运算符和界符单字符处理 pos; if (std::string(-*/).find(ch) ! std::string::npos) return {OPERATOR, std::string(1, ch), currentLine}; if (std::string((){};,[]).find(ch) ! std::string::npos) return {DELIMITER, std::string(1, ch), currentLine}; // 无法识别的字符报错 throw std::runtime_error(Unexpected character: std::string(1, ch)); } };这段代码的逻辑很直白每次调用nextToken()先跳过空白然后看当前字符属于哪一类。字母开头就一路吃到非字母数字下划线再去关键字表里查一下数字开头就连续吃数字运算符和界符直接单字符返回。line字段用来记录行号后面语法分析报错时能定位到具体行。参数上最需要注意的是关键字表keywords通常用std::setstd::string或std::unordered_set存初始化时把int、if、else、while、return这些塞进去。实验包里一般会有一个keywords.txt或者直接在代码里硬编码你拿到后先确认一下关键字列表是否完整缺了的话补上就行。2.3 编译运行与测试方法实验包的 experiment_1 目录下通常有demo文件夹里面放了测试用的源程序文件。编译方式看 readme常见的是# 进入 experiment_1 目录 cd experiment_1 # 编译假设主文件是 main.cpp词法分析器是 lexer.cpp g -stdc11 -o lexer main.cpp lexer.cpp # 运行传入测试文件 ./lexer demo/test1.c运行后输出应该是一行一个 token格式类似KEYWORD, intIDENTIFIER, mainDELIMITER, (这样。如果输出为空或者报错先检查测试文件路径对不对再检查源文件里有没有用到 C11 以上特性但编译选项没加-stdc11。提示有些实验包的 demo 文件是 Windows 换行符\r\n在 Linux 下跑可能会多出一个\r被当成非法字符。遇到这种情况用dos2unix转一下或者在跳过空白时把\r也加进去。3. LL(1) 分析法预测分析表的构建与栈驱动3.1 LL(1) 的适用边界与选型理由LL(1) 是自顶向下语法分析里最规矩的一种从左到右扫描输入每次只看一个符号就能唯一确定用哪条产生式展开。它的前提是文法必须满足三个条件——无左递归、无公共左因子、FIRST 集和 FOLLOW 集不冲突。教学里用它是因为逻辑清晰手工推导 FIRST 和 FOLLOW 集的过程能帮你把文法分析的基本功打扎实。但这个实验包里的 LL(1) 实现有个现实边界它只能处理实验给定的那套简单文法比如算术表达式文法或者微型语句文法。你如果拿一个真实编程语言的文法去跑大概率会撞上冲突。所以这个实验的价值在于理解预测分析表的构造流程而不是造一个通用分析器。3.2 FIRST 集、FOLLOW 集与预测分析表LL(1) 的核心数据结构是预测分析表M[A, a]其中 A 是非终结符a 是终结符或$。表的每个格子填的是产生式编号表示“当前栈顶是 A输入符号是 a 时应该用哪条产生式”。构建流程分三步第一步计算 FIRST 集。对每个文法符号 XFIRST(X) 是从 X 出发能推导出的所有可能的开头终结符集合。如果 X 能推导出空串 ε那 ε 也在 FIRST(X) 里。第二步计算 FOLLOW 集。对每个非终结符 AFOLLOW(A) 是可能紧跟在 A 后面的终结符集合。起始符号的 FOLLOW 集里先放$。第三步填表。对每条产生式 A → α对 FIRST(α) 里的每个终结符 a把 A → α 填进 M[A, a]如果 α 能推导出 ε那对 FOLLOW(A) 里的每个符号 b也把 A → α 填进 M[A, b]。实验包里的代码通常会把这三个步骤拆成独立的函数方便你对照输出检查中间结果。下面是一个填表逻辑的片段// 假设 first 和 follow 已经算好存成 mapstring, setstring // productions 是产生式列表每条产生式有左部和右部 void buildTable( const std::vectorProduction productions, const std::mapstd::string, std::setstd::string first, const std::mapstd::string, std::setstd::string follow, std::mapstd::pairstd::string, std::string, int table) { for (int i 0; i productions.size(); i) { const auto prod productions[i]; std::string lhs prod.left; std::vectorstd::string rhs prod.right; // 计算 rhs 的 FIRST 集 std::setstd::string rhsFirst computeFirstOfSequence(rhs, first); // 对 FIRST(rhs) 中每个终结符填表 for (const auto terminal : rhsFirst) { if (terminal ! ε) { table[{lhs, terminal}] i; } } // 如果 rhs 能推导出 ε对 FOLLOW(lhs) 填表 if (rhsFirst.count(ε)) { for (const auto terminal : follow.at(lhs)) { table[{lhs, terminal}] i; } } } }computeFirstOfSequence需要处理“前一个符号能推出 ε 就继续看下一个”的逻辑这是 FIRST 集计算里最容易写错的地方。实验包里如果这个函数有 bug表现就是某些格子空着分析到一半栈和输入对不上。3.3 栈驱动分析流程与调试预测分析表建好后分析过程用一个栈来驱动bool parse( const std::string input, const std::mapstd::pairstd::string, std::string, int table, const std::vectorProduction productions) { std::stackstd::string stk; stk.push($); stk.push(S); // S 是起始符号 size_t pos 0; while (!stk.empty()) { std::string top stk.top(); std::string cur (pos input.size()) ? std::string(1, input[pos]) : $; if (top $ cur $) return true; // 分析成功 if (isTerminal(top)) { if (top cur) { stk.pop(); pos; } else { return false; // 终结符不匹配 } } else { auto key std::make_pair(top, cur); if (table.find(key) table.end()) { return false; // 表中无对应产生式语法错误 } int prodIdx table.at(key); stk.pop(); // 产生式右部逆序入栈 const auto rhs productions[prodIdx].right; for (auto it rhs.rbegin(); it ! rhs.rend(); it) { if (*it ! ε) stk.push(*it); } } } return false; }调试时最常见的翻车点是产生式右部入栈顺序。栈是后进先出所以右部符号要逆序压入这样最左边的符号才会在栈顶先被处理。如果你发现分析结果总是差一步先检查这个循环是不是写成了正序。注意输入串末尾一定要补$栈底也放$两者相遇才算成功。实验包里如果输入没补$分析器会一直循环或者提前报错。4. 逆波兰式与 LR(1)从后缀表达式到自底向上分析4.1 逆波兰式的生成逻辑与栈计算逆波兰式就是后缀表达式运算符写在操作数后面。3 4 * 5转成逆波兰式是3 4 5 * 。它的好处是不需要括号和优先级规则用栈从左到右扫一遍就能算出来。生成逆波兰式的过程本质是中缀转后缀经典算法用两个栈一个存操作数或者直接输出一个存运算符。遇到操作数直接输出遇到运算符如果栈顶运算符优先级不低于当前运算符就弹出栈顶输出直到栈顶优先级更低或者栈空再把当前运算符压栈遇到左括号压栈遇到右括号弹出直到左括号。实验包里 experiment_3 对应的就是这个部分代码通常包含一个infixToPostfix函数和一个evaluatePostfix函数。下面是一个可复现的实现// 中缀转后缀 std::vectorstd::string infixToPostfix(const std::vectorstd::string tokens) { std::vectorstd::string output; std::stackstd::string opStack; std::mapstd::string, int precedence { {, 1}, {-, 1}, {*, 2}, {/, 2} }; for (const auto token : tokens) { if (isdigit(token[0])) { output.push_back(token); // 操作数直接输出 } else if (token () { opStack.push(token); } else if (token )) { while (!opStack.empty() opStack.top() ! () { output.push_back(opStack.top()); opStack.pop(); } opStack.pop(); // 弹出左括号 } else { // 运算符弹出优先级不低于当前的栈顶运算符 while (!opStack.empty() opStack.top() ! ( precedence[opStack.top()] precedence[token]) { output.push_back(opStack.top()); opStack.pop(); } opStack.push(token); } } while (!opStack.empty()) { output.push_back(opStack.top()); opStack.pop(); } return output; } // 计算后缀表达式 int evaluatePostfix(const std::vectorstd::string postfix) { std::stackint stk; for (const auto token : postfix) { if (isdigit(token[0])) { stk.push(std::stoi(token)); } else { int b stk.top(); stk.pop(); int a stk.top(); stk.pop(); if (token ) stk.push(a b); else if (token -) stk.push(a - b); else if (token *) stk.push(a * b); else if (token /) stk.push(a / b); } } return stk.top(); }precedence表里只放了四则运算如果你要支持更多运算符比如取模或者幂运算在这里加就行。注意除法是整数除法实验包里一般不做浮点处理如果要改浮点把int换成doublestoi换成stod。4.2 LR(1) 分析法的状态机构建LR(1) 比 LL(1) 强大得多能处理左递归和更复杂的文法工业级编译器大多用 LR 系列。但它的实现复杂度也高一个量级核心是构造 LR(1) 项目集规范族然后生成 ACTION 表和 GOTO 表。一个 LR(1) 项目是[A → α·β, a]其中 a 是向前看符号。项目集闭包的计算规则是如果项目[A → α·Bβ, a]在集合里那对 B 的每条产生式B → γ以及 FIRST(βa) 里的每个终结符 b都要把[B → ·γ, b]加进集合。实验包里 experiment_4 对应的就是 LR(1)代码量通常比前三个实验大不少。状态机的构建用 BFS 或者 DFS 遍历项目集每次对某个符号求转移看是否产生新状态。下面是一个项目集闭包计算的片段// LR(1) 项目结构 struct Item { int prodIdx; // 产生式编号 int dotPos; // 点的位置 std::string lookahead; // 向前看符号 bool operator(const Item other) const { if (prodIdx ! other.prodIdx) return prodIdx other.prodIdx; if (dotPos ! other.dotPos) return dotPos other.dotPos; return lookahead other.lookahead; } }; // 计算项目集闭包 std::setItem closure(const std::setItem items, const std::vectorProduction productions, const std::mapstd::string, std::setstd::string first) { std::setItem result items; bool changed true; while (changed) { changed false; for (const auto item : result) { const auto prod productions[item.prodIdx]; // 点后面还有符号且是非终结符 if (item.dotPos prod.right.size()) { std::string B prod.right[item.dotPos]; if (isNonTerminal(B)) { // 计算 FIRST(βa)β 是点后面的剩余部分 std::vectorstd::string beta(prod.right.begin() item.dotPos 1, prod.right.end()); beta.push_back(item.lookahead); std::setstd::string lookaheads computeFirstOfSequence(beta, first); // 对 B 的每条产生式加入新项目 for (int i 0; i productions.size(); i) { if (productions[i].left B) { for (const auto la : lookaheads) { if (la ! ε) { Item newItem{i, 0, la}; if (result.insert(newItem).second) { changed true; } } } } } } } } } return result; }这段代码的changed循环是必须的因为新加入的项目可能又触发新的闭包项。computeFirstOfSequence和 LL(1) 里用的是同一个逻辑可以直接复用。4.3 ACTION 表与 GOTO 表的生成状态机构建完后遍历每个状态里的项目如果项目是[A → α·aβ, b]且 a 是终结符那 ACTION 表里ACTION[state, a] shift nextState其中 nextState 是当前状态对 a 的转移目标。如果项目是[A → α·, a]那 ACTION 表里ACTION[state, a] reduce A → α。如果项目是[S → S·, $]那ACTION[state, $] accept。对每个非终结符 A如果当前状态对 A 有转移那GOTO[state, A] nextState。冲突处理是 LR(1) 实现里最头疼的部分。移进-归约冲突和归约-归约冲突都可能出现实验包里一般会打印冲突信息让你手动分析。如果冲突太多说明文法不是 LR(1) 的需要改写文法或者换用 LALR(1)。提示LR(1) 的状态数通常比 LALR(1) 多不少如果实验包里状态数爆了先检查向前看符号的计算是不是太宽泛把不该合并的状态合并了。5. 避坑与排查四个实验里最容易翻车的地方5.1 词法分析器把关键字识别成标识符现象输入int main输出里int的类型是 IDENTIFIER 而不是 KEYWORD。原因关键字表没初始化或者查表时用了错误的容器。常见的是用了std::vector存关键字但忘了排序然后用binary_search查结果查不到。解决把关键字表改成std::setstd::string或std::unordered_set插入时确认所有关键字都加进去了。如果实验包里关键字是硬编码的逐行核对一遍。5.2 LL(1) 预测分析表出现多重入口现象填表时发现某个M[A, a]被填了两次后填的覆盖了先填的。原因文法不是 LL(1) 的存在 FIRST 集冲突或者 FIRST/FOLLOW 冲突。比如两条产生式A → aB和A → aCFIRST 集都是{a}表里就冲突了。解决先检查文法有没有左递归和公共左因子。有左递归就消除左递归有公共左因子就提取左因子。如果改完还有冲突说明这个文法本身不是 LL(1) 的实验包里一般会换一套简单文法你确认一下用的是不是实验指定的那套。5.3 逆波兰式计算时栈空或结果不对现象计算3 4 时程序崩溃或者结果算出来是负数。原因操作数顺序搞反了。后缀表达式计算时先弹出的是右操作数后弹出的是左操作数。减法a - b里先弹出的是 b后弹出的是 a如果写成b - a结果就反了。解决在evaluatePostfix里确认弹出顺序第一个弹出的是右操作数第二个弹出的是左操作数。除法同理被除数后弹出。5.4 LR(1) 状态机死循环或状态数爆炸现象程序跑了几分钟没输出内存一直涨。原因闭包计算里changed循环没有正确终止或者项目集的比较运算符写错了导致重复项目被反复插入。解决检查Item的operator是不是比较了所有字段产生式编号、点位置、向前看符号。漏掉任何一个字段都会导致std::set去重失效。另外确认closure函数里新项目的dotPos是从 0 开始的不是从当前位置开始的。5.5 编译时找不到头文件或链接错误现象g报fatal error: xxx.h: No such file or directory或者undefined reference to ...。原因实验包里的文件组织方式和你的编译命令不匹配。有些实验把声明放在.h里实现放在.cpp里编译时需要把所有.cpp都列上。解决先看 readme 里有没有给出编译命令。如果没有用g -stdc11 -o output *.cpp把所有源文件一起编译。如果还有链接错误检查是不是漏了某个.cpp文件或者某个函数在头文件里声明了但没实现。6. 进阶技巧用脚本批量验证四个实验的输出四个实验分开跑没问题但如果你想一次性验证所有实验的 demo 是否通过手动一个个编译运行太慢。我一般会写一个 shell 脚本自动遍历每个 experiment 目录编译并运行 demo把输出存到日志里对比。#!/bin/bash # 批量编译并运行四个实验的 demo for exp in experiment_1 experiment_2 experiment_3 experiment_4; do echo $exp cd $exp || continue # 编译把所有 cpp 文件一起编译 g -stdc11 -o demo_runner *.cpp 2compile_error.log if [ $? -ne 0 ]; then echo 编译失败错误见 compile_error.log cat compile_error.log cd .. continue fi # 运行假设 demo 目录下有输入文件 if [ -f demo/input.txt ]; then ./demo_runner demo/input.txt output.log 21 echo 运行完成输出行数$(wc -l output.log) else echo 未找到 demo/input.txt跳过运行 fi cd .. done这个脚本的关键点是2compile_error.log把编译错误单独存文件方便排查。 demo/input.txt是标准输入重定向很多实验的 demo 程序是从 stdin 读输入的不是从命令行参数读。如果你的实验包用的是命令行参数把重定向改成./demo_runner demo/input.txt。跑完脚本后重点看每个实验的output.log里有没有异常。词法分析器的输出应该是规整的 token 列表LL(1) 和 LR(1) 应该打印分析过程或者 accept/reject 结果逆波兰式应该输出后缀表达式和计算结果。如果某个实验输出为空先检查demo/input.txt是不是空的再检查程序是不是从别的路径读输入。还有一个技巧把四个实验的公共代码抽出来。比如Token结构体、isTerminal判断、computeFirstOfSequence函数在词法分析器、LL(1)、LR(1) 里都会用到。实验包里如果每个 experiment 目录都复制了一份你可以自己建一个common目录用-I../common指定头文件路径减少重复代码。不过改之前先确认实验包的编译命令支不支持有些老师要求每个实验独立编译那就别动结构老老实实复制。从那以后我每次拿到这种多实验的代码包都先跑一遍批量脚本确认四个实验都能编译通过再细看代码。编译不过的先解决环境问题别急着读逻辑不然时间全花在配环境上了。希望帮到你。本文还有配套的精品资源点击获取
返回列表