从零手写C++词法分析器与语法分析器:递归下降构建AST实战
简介这份资源是编译原理课程中词法分析与语法分析环节的C实现项目面向计算机专业学生、编译器初学者及对语言处理感兴趣的开发者帮助其把有限自动机、上下文无关文法、抽象语法树等理论落到可运行代码上。压缩包共9个文件约937KB以cpp源码、txt文法与token说明、exe可执行程序及md说明文档为主源码与文档配合便于对照阅读与直接运行验证。目前已有737人学习下载说明其在课程实践与自学场景中具有一定参考价值。项目覆盖词法规则定义、词法单元序列生成、非法字符与数字格式错误处理以及递归下降或LL(1)语法分析、AST构建和语法错误处理等关键环节读者可据此理解编译器前端从源代码到语法树的完整流程并在此基础上扩展语言子集、补充单元测试与文档为后续学习程序分析和代码生成打下实践基础。1. 从零手写词法分析器与语法分析器一份能跑通的 C 实现拆解很多人学编译原理卡在“看得懂 LL(1) 分析表却写不出一行能跑的代码”这一步。这份 C 实现的词法分析器和语法分析器正好补上这个断层——它不是伪代码也不是课堂 PPT 里的片段而是一个能编译、能输入表达式、能输出语法树的完整工程。适合正在做编译原理课程设计的学生也适合想补一补底层解析能力、却被各种生成器工具惯坏了的开发者。词法分析负责把字符流切成 token语法分析负责把 token 流组织成语法树这两步是所有编译器、解释器、甚至配置解析器的地基。下面我按“先跑起来、再拆结构、最后调参数”的顺序把这份资源从头到尾过一遍中间会穿插我实际编译时踩过的坑。2. 词法分析器从字符流到 Token 序列的完整实现2.1 词法分析器的核心任务与状态机设计词法分析器要干的事很明确读入一串字符跳过空白识别出标识符、数字、运算符、界符并给每个 token 打上类型标签。这份实现里词法分析器被封装成一个Lexer类内部维护一个pos指针和当前行号核心是一个nextToken()函数。它没有用正则表达式库而是手写了一个有限状态机——遇到字母就进入标识符状态遇到数字就进入数字状态遇到运算符就查表返回对应类型。为什么不用正则因为手写状态机更容易嵌入错误恢复逻辑比如遇到非法字符时能准确报出列号而不是抛一个笼统的“匹配失败”。常见做法是先用std::isalpha和std::isdigit做分支判断再配合一个关键字表把if、while、return这类保留字从标识符里区分出来。// Lexer.h 片段Token 类型定义与关键字表 enum class TokenType { IDENTIFIER, NUMBER, PLUS, MINUS, STAR, SLASH, LPAREN, RPAREN, ASSIGN, SEMICOLON, IF, WHILE, RETURN, END }; struct Token { TokenType type; std::string value; int line; int column; }; // 关键字映射表把保留字从普通标识符里剥离出来 static const std::unordered_mapstd::string, TokenType keywords { {if, TokenType::IF}, {while, TokenType::WHILE}, {return, TokenType::RETURN} };这段代码里Token结构体除了类型和值还带了line和column这是后面报错定位的关键。很多教学实现只存值不存位置结果语法分析报错时只能告诉你“第几个 token 错了”没法映射回源码行。参数上keywords表用unordered_map而不是map因为词法分析是高频操作哈希查找比红黑树快一个量级虽然差别在小型输入上不明显但养成习惯没坏处。2.2 手写 nextToken 的步骤与边界处理nextToken()的执行流程可以拆成四步跳过空白和注释、读取第一个有效字符、根据字符类型进入对应分支、返回构造好的 Token。跳过空白时要注意\r\n和\n的兼容尤其是从 Windows 拷贝的测试文件行号统计容易多算一行。读取数字时这份实现只支持整数没有处理小数点和科学计数法——如果你要扩展得在数字状态里加一个“遇到.就继续读”的分支。Token Lexer::nextToken() { // 1. 跳过空白字符同时更新行号和列号 while (pos input.size() std::isspace(input[pos])) { if (input[pos] \n) { line; column 1; } else { column; } pos; } if (pos input.size()) return {TokenType::END, , line, column}; char ch input[pos]; int startCol column; // 2. 标识符或关键字以字母或下划线开头 if (std::isalpha(ch) || ch _) { std::string ident; while (pos input.size() (std::isalnum(input[pos]) || input[pos] _)) { ident input[pos]; column; } auto it keywords.find(ident); if (it ! keywords.end()) return {it-second, ident, line, startCol}; return {TokenType::IDENTIFIER, ident, line, startCol}; } // 3. 数字连续读取数字字符 if (std::isdigit(ch)) { std::string num; while (pos input.size() std::isdigit(input[pos])) { num input[pos]; column; } return {TokenType::NUMBER, num, line, startCol}; } // 4. 运算符和界符单字符直接映射 pos; column; switch (ch) { case : return {TokenType::PLUS, , line, startCol}; case -: return {TokenType::MINUS, -, line, startCol}; case *: return {TokenType::STAR, *, line, startCol}; case /: return {TokenType::SLASH, /, line, startCol}; case (: return {TokenType::LPAREN, (, line, startCol}; case ): return {TokenType::RPAREN, ), line, startCol}; case : return {TokenType::ASSIGN, , line, startCol}; case ;: return {TokenType::SEMICOLON, ;, line, startCol}; default: throw std::runtime_error(非法字符 std::string(1, ch) 位于第 std::to_string(line) 行第 std::to_string(startCol) 列); } }逻辑上这个函数每次只消费一个 token调用方循环调用直到拿到END。参数说明input是构造函数传入的源码字符串pos是当前读取位置line和column用于错误定位。注意column在跳过空白时递增但在读取多字符 token 时是在循环里递增的这样报错列号指向 token 起始位置而不是结束位置更符合直觉。常见翻车点是忘记在switch前递增pos导致死循环——我第一版就栽在这里调试了半小时才发现指针没动。3. 语法分析器递归下降构建语法树3.1 为什么选递归下降而不是 LL(1) 分析表语法分析器有两种主流实现路径一种是查 LL(1) 分析表、用栈驱动另一种是递归下降每个非终结符写一个函数。这份资源用的是递归下降原因很实际——代码可读性高调试时能直接打断点看调用栈而 LL(1) 分析表在遇到左递归时需要额外改写文法对课程设计来说反而绕远了。递归下降的核心思想是为文法里每个非终结符写一个parseXxx()函数函数内部根据当前 token 决定走哪条产生式。比如表达式文法Expr - Term (( | -) Term)*对应的函数就是先解析一个Term然后循环看下一个 token 是不是或-是就继续解析Term并构造二元节点。这种写法天然支持运算符优先级只要把优先级低的规则放在外层函数、优先级高的放在内层函数就行。3.2 AST 节点定义与 parseExpression 实现语法树的节点用ASTNode结构体表示包含节点类型、值、左右子节点。表达式解析是语法分析里最核心的部分因为它涉及优先级和结合性。这份实现把表达式分成三层parseExpression处理加减parseTerm处理乘除parseFactor处理括号和数字/标识符。// ASTNode 定义支持二元运算、数字、标识符 struct ASTNode { std::string type; // BinaryOp, Number, Identifier std::string value; // 运算符或字面量值 std::unique_ptrASTNode left; std::unique_ptrASTNode right; }; // 解析加减表达式左结合优先级低于乘除 std::unique_ptrASTNode Parser::parseExpression() { auto left parseTerm(); // 先解析一个更高优先级的项 while (current.type TokenType::PLUS || current.type TokenType::MINUS) { std::string op current.value; advance(); // 消费运算符 auto right parseTerm(); auto node std::make_uniqueASTNode(); node-type BinaryOp; node-value op; node-left std::move(left); node-right std::move(right); left std::move(node); // 左结合新节点成为下一轮的左操作数 } return left; } // 解析乘除表达式优先级高于加减 std::unique_ptrASTNode Parser::parseTerm() { auto left parseFactor(); while (current.type TokenType::STAR || current.type TokenType::SLASH) { std::string op current.value; advance(); auto right parseFactor(); auto node std::make_uniqueASTNode(); node-type BinaryOp; node-value op; node-left std::move(left); node-right std::move(right); left std::move(node); } return left; } // 解析因子数字、标识符或括号表达式 std::unique_ptrASTNode Parser::parseFactor() { if (current.type TokenType::NUMBER) { auto node std::make_uniqueASTNode(); node-type Number; node-value current.value; advance(); return node; } if (current.type TokenType::LPAREN) { advance(); auto node parseExpression(); // 递归解析括号内表达式 expect(TokenType::RPAREN); // 强制匹配右括号 return node; } throw std::runtime_error(语法错误期望数字或 (实际得到 current.value ); }这段代码里advance()负责从词法分析器取下一个 token 并更新current。parseExpression和parseTerm的结构几乎一样区别只在判断的 token 类型不同——这正是递归下降的优雅之处优先级层级直接映射到函数调用层级。参数上left用std::unique_ptr管理避免手动deletestd::move转移所有权防止悬空指针。常见错误是忘记在循环里更新left导致只解析了第一个操作数就返回结果123被解析成(12)丢掉了3。4. 避坑与排查编译、运行、扩展时的五个血泪教训4.1 现象编译报错 “unique_ptr 不可复制”原因ASTNode里用了std::unique_ptr作为子节点但某处代码试图按值传递或拷贝节点。解决所有涉及节点传递的函数参数用std::unique_ptrASTNode或直接std::move返回值用std::unique_ptr而不是裸指针。如果确实需要共享改用std::shared_ptr但语法树是严格的树形结构unique_ptr更合适。4.2 现象输入12*3输出结果却是(12)*3原因parseExpression里先调用了parseTerm但parseTerm内部又调用了parseFactor层级是对的问题出在parseExpression的循环条件写成了while (current.type PLUS || current.type MINUS || current.type STAR)把乘除也吞进来了。解决严格按文法分层parseExpression只处理加减乘除交给parseTerm不要越级判断。4.3 现象词法分析器遇到文件末尾死循环原因nextToken()在pos input.size()时返回了END但调用方advance()没有检查END就继续调用导致反复返回END而循环不退出。解决在advance()里加一个判断如果当前已经是END就不再前进或者在语法分析主循环里用while (current.type ! END)控制。4.4 现象行号统计在 Windows 换行符下多算一行原因\r\n被当成两个字符\n触发一次行号递增\r又被isspace跳过但没触发递增看似没问题但如果代码里先判断\r再判断\n就会多算。解决统一在跳过空白时只认\n作为换行标志\r直接忽略。或者读入文件后用std::getline按行处理从根上避开这个问题。4.5 现象扩展支持浮点数后1.2.3被解析成合法数字原因数字状态机里遇到.就继续读但没有限制小数点只能出现一次。解决加一个bool hasDot标志第一次遇到.时置位第二次遇到就报错或停止读取。这类边界在课程设计里经常被忽略但实际解析配置文件时一个非法数字可能导致整个解析器崩溃。5. 进阶技巧用 AST 求值验证解析正确性写完词法分析和语法分析怎么确认它真的对了最直接的办法是给 AST 加一个evaluate()函数把语法树算成一个数值然后跟手算结果对比。这个技巧我每次写完解析器都会用比盯着 token 序列看靠谱得多。// 对 AST 递归求值只支持整数四则运算 int evaluate(const std::unique_ptrASTNode node) { if (node-type Number) { return std::stoi(node-value); // 字符串转整数 } if (node-type BinaryOp) { int leftVal evaluate(node-left); int rightVal evaluate(node-right); if (node-value ) return leftVal rightVal; if (node-value -) return leftVal - rightVal; if (node-value *) return leftVal * rightVal; if (node-value /) { if (rightVal 0) throw std::runtime_error(除零错误); return leftVal / rightVal; } } throw std::runtime_error(未知节点类型 node-type); }这个函数递归遍历整棵树遇到数字节点就转成int返回遇到二元操作节点就分别求左右子树的值再按运算符计算。参数上std::stoi在遇到非数字字符串时会抛std::invalid_argument所以词法分析阶段必须保证Number节点的值一定是纯数字。除零判断放在这里而不是词法阶段因为词法阶段不知道上下文只有求值时才能确定分母是否为零。验证时我一般准备一组测试用例12*3期望 7(12)*3期望 910/2-3期望 22*34*5期望 26。如果某个用例挂了先看 AST 结构对不对——可以在evaluate里加一行打印节点类型和值或者单独写一个printAST函数做前序遍历。常见问题是括号解析后忘记expect(RPAREN)导致(12这种不匹配的输入被静默接受求值时才暴露成奇怪的结果。另一个进阶方向是把 AST 转成后缀表达式输出或者生成三地址码。这些扩展不需要改词法分析器只需要在语法分析构造节点时多存一些信息。我习惯在每次扩展文法后先把所有旧测试用例跑一遍确认没有回归再加新用例。从那以后我每次写完解析器都强制走一遍“词法输出 → AST 打印 → 求值对比”三步验证少一步都可能留下隐蔽的优先级 bug。希望这份拆解能帮你把编译原理的这两块硬骨头啃下来资源里的代码可以直接编译运行改一改就能当课程设计的底稿。本文还有配套的精品资源点击获取

相关新闻

Obsidian离线插件仓库搭建指南:内网环境插件安装与批量部署

Obsidian离线插件仓库搭建指南:内网环境插件安装与批量部署

简介:这份离线插件大全面向无法连接 Obsidian 社区、或希望在内网与离线环境下扩展笔记功能的用户,尤其适合需要主题美化、编辑增强与效率插件的进阶使用者。资源共收录 967 个文件,以 686 个 zip 插件包为主体,另含 136 个 css 主…

2026/10/13 20:01:11 阅读更多 →
SAP实施模块踩坑指南:从FI到QM高频问题与排查技巧

SAP实施模块踩坑指南:从FI到QM高频问题与排查技巧

做SAP实施久了你会发现,客户问得最多的往往不是“这个功能怎么配”,而是“为什么这个模块上线后天天报错”。SAP ERP这套系统,模块之间咬合得非常紧,财务要追溯业务、业务要关联库存、库存又要联动生产,任何一个环节的…

2026/10/13 21:53:11 阅读更多 →
假期刷题冲区域赛奖牌:结构化训练方案与考点分配策略

假期刷题冲区域赛奖牌:结构化训练方案与考点分配策略

“这个假期一起来刷题吧,刷完冲击区域赛,刷完拿不到奖随便打!”——这句口号我太熟了。每年寒暑假开始前,总有人在群里一拍桌子喊出这句话,配上一张写满训练计划的截图,然后第一周鸡血满满,第二…

2026/10/12 19:38:27 阅读更多 →

最新新闻

LaTeX 2025环境配置:texlive + TeXstudio

LaTeX 2025环境配置:texlive + TeXstudio

文章目录Latex2025环境配置一、LaTeX 简介1.1 什么是 LaTeX?1.2 为什么使用 LaTeX?1.3 如何开始使用 LaTeX?二、texlive下载安装2.1 texlive下载2.2 texlive安装2.3 常见问题2.3.1 用管理员权限打开install-tl-windows.bat 报错2.3.2 Latex安…

2026/10/14 2:20:23 阅读更多 →
从工勘图到资源库:提交 nVisual Studio 与机房设计全生命周期的闭环

从工勘图到资源库:提交 nVisual Studio 与机房设计全生命周期的闭环

从工勘图到资源库:提交 nVisual Studio 与机房设计全生命周期的闭环摘要:工勘图纸的价值在「进入可检索、可复用资源库」之后才被放大——本地文件解决现场与交付,平台解决长期资产。DCDesigner 的「提交 nVisual Studio」把工勘 JSON 一键回…

2026/10/14 2:20:23 阅读更多 →
因果推断教材实战:从AB实验到双重机器学习与策略学习

因果推断教材实战:从AB实验到双重机器学习与策略学习

简介:这份资源是斯坦福大学Stefan Wager于2024年9月更新的《Causal Inference: A Statistical Learning Approach》教科书草稿,面向数据科学、统计学、经济学与公共政策领域的学习者和研究者,帮助其系统掌握基于统计学习方法的因果推断理论与…

2026/10/14 2:20:23 阅读更多 →
游集美Day1:逛完学村走老巷,被这几家街坊常来的小店拦住了脚步

游集美Day1:逛完学村走老巷,被这几家街坊常来的小店拦住了脚步

很多人来集美,打卡完嘉庚建筑、龙舟池,拍几张照片就匆匆返程。 但集美真正动人的烟火,不在网红打卡点,藏在黄昏的社区老巷。 这条路线我按地理位置理顺,不跨区乱跑,骑行顺路,慢逛无赶路压力&…

2026/10/14 2:20:23 阅读更多 →
系统辨识实战教案:M序列设计、RLS实现与工业白化检验

系统辨识实战教案:M序列设计、RLS实现与工业白化检验

简介:本资源是一份面向自动化专业本科生与研究生的《系统辨识》课程教案,聚焦工程建模核心能力培养,解决从实际系统数据中构建可用数学模型的关键问题。教案覆盖实体与模型本质辨析、Zadeh三要素(数据/模型类/准则)理论…

2026/10/14 2:20:23 阅读更多 →
AI 辅助科研溯源闸门:输入·命令·决策·证据四类记录的失败含义与当天最小实验

AI 辅助科研溯源闸门:输入·命令·决策·证据四类记录的失败含义与当天最小实验

千笔-AIWritePaper https://www.aiwritepaper.com Agent 能很快从原始表跑出一张宇宙学风格的斜率图,不等于结果可审查。缺了输入校验和,文件可以被静默替换;命令行事后改参,YAML 里仍写着旧参数;决策条目没有理由&am…

2026/10/14 2:19:23 阅读更多 →

日新闻

AnyPS5通用适配层设计:兼容性与性能的工程取舍

AnyPS5通用适配层设计:兼容性与性能的工程取舍

1. 从"AnyPS5"这个名字说起:它到底想解决什么问题第一次看到"AnyPS5"这个标题,我脑子里冒出来的第一个念头是:这大概率是一个围绕"跨平台运行"或者"通用适配"做文章的项目。名字里的"Any"…

2026/10/14 0:00:41 阅读更多 →
Flutter跨平台开发实战:二手交易应用与HarmonyOS适配

Flutter跨平台开发实战:二手交易应用与HarmonyOS适配

1. 项目概述:Flutter跨平台二手交易应用开发作为一名长期从事移动应用开发的工程师,我最近完成了一个基于Flutter框架的二手物品交易平台项目。这个项目最吸引我的地方在于它完美结合了Flutter的跨平台优势与HarmonyOS的生态特性,实现了"…

2026/10/14 0:00:41 阅读更多 →
DFS算法解决飞机降落调度问题实战

DFS算法解决飞机降落调度问题实战

1. 项目背景与核心思路飞机降落问题是一个经典的调度优化场景,也是算法竞赛和面试中的高频题目。这个题目要求我们合理安排多架飞机的降落顺序和时间,确保所有飞机都能安全着陆而不发生冲突。在实际航空管制中,这类问题直接关系到飞行安全和机…

2026/10/14 0:00:41 阅读更多 →

周新闻

流感时间序列预测实战:ARIMA/LSTM全流程拆解与避坑指南

流感时间序列预测实战:ARIMA/LSTM全流程拆解与避坑指南

简介:基于 ARIMA、LSTM、Transformer 等模型的流感时间序列预测 Python 源码,面向计算机相关专业课程设计与期末大作业学生,以及项目实战学习者。内容覆盖预处理、平稳性检验、定阶、残差分析、多模型对比预测的完整时序建模流程,…

2026/10/13 2:54:56 阅读更多 →
影刀RPA新手教程:键盘模拟输入实战——输入文本与模拟按键的区别

影刀RPA新手教程:键盘模拟输入实战——输入文本与模拟按键的区别

影刀RPA新手教程:键盘模拟输入实战——输入文本与模拟按键的区别 做影刀RPA自动化,十个新手有八个栽在"往输入框里填东西"这件事上:要么填不进去,要么填了一半,要么直接把原来内容追加在后面。这背后的根因&…

2026/10/13 16:22:04 阅读更多 →
影刀RPA新手教程:阅文起点小说数据采集实战——书籍信息与章节内容

影刀RPA新手教程:阅文起点小说数据采集实战——书籍信息与章节内容

影刀RPA新手教程:阅文起点小说数据采集实战——书籍信息与章节内容 1. 认识影刀:什么场景该用RPA采小说数据 起点中文网的页面结构相对稳定——分类榜单、书籍详情、章节内容三块独立页面,跳转链路清晰。这种场景非常适合影刀自动化&#x…

2026/10/13 1:58:08 阅读更多 →

月新闻

我发现了一个新思路:用 Remotion + Claude Code 像写代码一样自动化生成短视频

我发现了一个新思路:用 Remotion + Claude Code 像写代码一样自动化生成短视频

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

2026/10/13 22:01:27 阅读更多 →
Windows下 Codex 中 Chrome 和 Computer Use 插件不可用问题排查及解决参考方式:TaoToken 统一 Key 配置与验证

Windows下 Codex 中 Chrome 和 Computer Use 插件不可用问题排查及解决参考方式:TaoToken 统一 Key 配置与验证

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

2026/10/13 14:57:39 阅读更多 →
黑夜航拍船只数据集训练YOLOV5模型全流程解析

黑夜航拍船只数据集训练YOLOV5模型全流程解析

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

2026/10/12 18:11:17 阅读更多 →