news 2026/10/13 4:44:49

编译原理词法分析与语法分析器C++实现:从零构建到工程实践

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
编译原理词法分析与语法分析器C++实现:从零构建到工程实践

简介:这份资源是编译原理课程配套的编译器前端实践项目,面向计算机专业学生、对编译器与解释器实现感兴趣的开发者,以及希望夯实C++工程能力的编程学习者。项目以C++实现词法分析器与语法分析器,覆盖关键字、标识符、常数、运算符等词法规则定义,基于有限自动机完成词法单元序列转换,并借助上下文无关文法与递归下降或LL(1)方法构建抽象语法树,同时处理非法字符、格式错误数字及语法错误等异常情况。压缩包共9个文件,包含2个cpp源码、2个可执行程序、4个txt文法与token表说明及1个md文档,整体约937KB,便于直接运行验证与对照阅读。目前已有736人学习下载。读者可从中获得完整的词法规则与文法定义、可编译运行的C++实现代码、token表与源程序样例,以及从词法分析到语法分析的完整前端流程参考,适合作为课程实验、编译原理入门实践与后续语义分析扩展的基础素材。

1. 从一份 .zip 说起:编译原理词法分析器和语法分析器到底在做什么

很多人第一次拿到「编译原理词法分析器和语法分析器的实现(C++).zip」这类压缩包,是在课程实验周。解压之后通常能看到几个 .cpp、.h 文件,一个 Makefile 或者 CMakeLists.txt,可能还有测试用例和一份实验报告模板。但真正打开代码之前,得先想清楚一件事:词法分析器和语法分析器不是两个孤立的程序,它们是一条流水线上的前后两道工序。词法分析器把字符流切成有意义的记号(Token),语法分析器再把 Token 序列组装成语法树。C++ 实现的好处是性能可控、内存布局清晰,适合处理大规模输入;代价是手动管理内存和状态机,稍不留神就翻车。这篇文章面向正在做编译原理实验、或者想自己从零写一个可用的词法/语法分析器的 C++ 开发者,从最小可运行版本开始,逐步补上符号表、错误恢复和测试验证,最后给出几个能直接抄的工程习惯。

2. 词法分析器:从字符流到 Token 序列的最小实现

2.1 为什么先写词法分析器,而不是直接上语法分析

编译原理实验里最常见的翻车顺序是:先写语法分析,写到一半发现 Token 类型不够用,回头改词法分析器,结果语法分析的状态机全乱了。正确的做法是先把词法分析器的接口定死:输入是一个字符串或文件流,输出是一个std::vector<Token>,每个 Token 包含类型、原始文本、行号和列号。行号和列号不是可选项,后面语法分析报错时没有位置信息,调试基本靠猜。

词法分析器的核心是一个状态机。对于大多数教学语言(比如类 C 语言、类 Pascal 语言),状态不会超过二十个。常见做法是用switch嵌套while,而不是正则表达式库。原因很简单:正则库引入额外依赖,而且性能不可控;手写状态机虽然代码长一点,但每一步都能打断点。

2.2 用 C++ 写一个可扩展的词法分析器骨架

下面是一个最小可运行版本,支持标识符、整数、浮点数、四则运算符和括号。代码可以直接编译运行。

#include <iostream> #include <string> #include <vector> #include <cctype> enum class TokenType { Identifier, Number, Plus, Minus, Star, Slash, LParen, RParen, EndOfFile, Unknown }; struct Token { TokenType type; std::string text; int line; int column; }; class Lexer { public: Lexer(const std::string& src) : src_(src), pos_(0), line_(1), col_(1) {} std::vector<Token> tokenize() { std::vector<Token> tokens; while (pos_ < src_.size()) { char c = src_[pos_]; if (std::isspace(c)) { advance(); continue; } if (std::isalpha(c) || c == '_') { tokens.push_back(readIdentifier()); } else if (std::isdigit(c)) { tokens.push_back(readNumber()); } else { tokens.push_back(readOperatorOrParen()); } } tokens.push_back({TokenType::EndOfFile, "", line_, col_}); return tokens; } private: std::string src_; size_t pos_; int line_; int col_; void advance() { if (src_[pos_] == '\n') { line_++; col_ = 1; } else { col_++; } pos_++; } Token readIdentifier() { int startLine = line_, startCol = col_; std::string text; while (pos_ < src_.size() && (std::isalnum(src_[pos_]) || src_[pos_] == '_')) { text += src_[pos_]; advance(); } return {TokenType::Identifier, text, startLine, startCol}; } Token readNumber() { int startLine = line_, startCol = col_; std::string text; bool hasDot = false; while (pos_ < src_.size() && (std::isdigit(src_[pos_]) || src_[pos_] == '.')) { if (src_[pos_] == '.') { if (hasDot) break; // 第二个小数点不合法,留给错误处理 hasDot = true; } text += src_[pos_]; advance(); } return {TokenType::Number, text, startLine, startCol}; } Token readOperatorOrParen() { int startLine = line_, startCol = col_; char c = src_[pos_]; advance(); switch (c) { case '+': return {TokenType::Plus, "+", startLine, startCol}; case '-': return {TokenType::Minus, "-", startLine, startCol}; case '*': return {TokenType::Star, "*", startLine, startCol}; case '/': return {TokenType::Slash, "/", startLine, startCol}; case '(': return {TokenType::LParen, "(", startLine, startCol}; case ')': return {TokenType::RParen, ")", startLine, startCol}; default: return {TokenType::Unknown, std::string(1, c), startLine, startCol}; } } }; int main() { std::string input = "x1 = 3.14 + (y2 - 1)"; Lexer lexer(input); auto tokens = lexer.tokenize(); for (const auto& t : tokens) { std::cout << "Token(type=" << static_cast<int>(t.type) << ", text='" << t.text << "', line=" << t.line << ", col=" << t.column << ")\n"; } return 0; }

逻辑说明:advance()负责推进位置并维护行列号;readIdentifier()和readNumber()分别处理标识符和数字,数字里用hasDot防止3.14.15这种输入被吞成一个 Token;readOperatorOrParen()用switch处理单字符运算符。参数方面,line_和col_从 1 开始计数,方便和编辑器行号对齐;EndOfFileToken 是语法分析器的终止信号,不能省。

2.3 词法分析器里最容易忽略的三个参数

第一个是缓冲区大小。如果直接读整个文件到std::string,对于几十 MB 的输入没问题;但如果要处理流式输入,就得改成固定大小缓冲区加滑动窗口。第二个是最大标识符长度。C++ 标准建议至少支持 1024 个字符,但实际实验里超过 255 个字符的标识符基本是恶意输入,可以截断并报错。第三个是注释处理。单行注释//和多行注释/* */必须在词法阶段吃掉,否则语法分析器会收到一堆无意义的/和*。常见做法是在tokenize()主循环里加一个skipComment()分支,遇到//就跳到行尾,遇到/*就跳到*/,同时更新行列号。

3. 语法分析器:递归下降还是 LR,选型决定后面三天的调试量

3.1 递归下降的适用边界与 C++ 实现要点

递归下降是手写语法分析器里最直观的方案:每个非终结符对应一个函数,函数体里按产生式右部依次调用其他函数或匹配 Token。它的优点是调试方便,断点打在哪一目了然;缺点是遇到左递归文法会无限递归,必须提前消除左递归。对于编译原理实验里常见的表达式文法,消除左递归之后写起来很快。

下面是一个支持加减乘除和括号的递归下降分析器,接在上一节的词法分析器后面。

class Parser { public: Parser(std::vector<Token> tokens) : tokens_(std::move(tokens)), pos_(0) {} // Expr -> Term (('+' | '-') Term)* double parseExpr() { double left = parseTerm(); while (match(TokenType::Plus) || match(TokenType::Minus)) { TokenType op = tokens_[pos_ - 1].type; double right = parseTerm(); if (op == TokenType::Plus) left += right; else left -= right; } return left; } private: std::vector<Token> tokens_; size_t pos_; bool match(TokenType type) { if (pos_ < tokens_.size() && tokens_[pos_].type == type) { pos_++; return true; } return false; } Token expect(TokenType type) { if (pos_ < tokens_.size() && tokens_[pos_].type == type) { return tokens_[pos_++]; } throw std::runtime_error("Unexpected token at line " + std::to_string(tokens_[pos_].line)); } // Term -> Factor (('*' | '/') Factor)* double parseTerm() { double left = parseFactor(); while (match(TokenType::Star) || match(TokenType::Slash)) { TokenType op = tokens_[pos_ - 1].type; double right = parseFactor(); if (op == TokenType::Star) left *= right; else left /= right; } return left; } // Factor -> Number | '(' Expr ')' double parseFactor() { if (match(TokenType::Number)) { return std::stod(tokens_[pos_ - 1].text); } if (match(TokenType::LParen)) { double val = parseExpr(); expect(TokenType::RParen); return val; } throw std::runtime_error("Expected number or '(' at line " + std::to_string(tokens_[pos_].line)); } };

逻辑说明:parseExpr处理加减,parseTerm处理乘除,parseFactor处理数字和括号,三层结构对应运算符优先级。match是“看当前 Token 是否匹配,匹配就前进”,expect是“必须匹配,否则抛异常”。参数方面,pos_是当前 Token 下标,所有函数都通过它读写;异常里带上行号,方便定位错误。

3.2 LR 分析器在 C++ 里的落地成本

如果文法不是 LL(1),或者需要处理更复杂的语言特性(比如 C 语言的声明与表达式歧义),递归下降就得靠回溯或者改写文法,代码会迅速膨胀。这时候 LR 分析器更合适。但 LR 分析器需要先构造 ACTION 表和 GOTO 表,手工构造几乎不可能,通常用工具生成。在 C++ 实验环境里,常见做法是用 Python 脚本生成分析表,导出成 C++ 数组,再写一个通用的移进-归约驱动器。

移进-归约驱动器的核心是一个状态栈和一个符号栈。每次循环读栈顶状态和当前 Token,查 ACTION 表:如果是移进,把 Token 和下一个状态压栈;如果是归约,按产生式弹出相应数量的符号和状态,查 GOTO 表压入新状态;如果是接受,返回语法树根节点。这个驱动器代码量不大,但分析表的正确性完全依赖生成工具,调试时如果发现归约顺序不对,基本是文法写错了,而不是驱动器的问题。

3.3 语法树节点设计与内存管理

递归下降版本可以直接在函数返回值里算数,但真实编译器需要构造语法树。C++ 里语法树节点常见做法是用std::unique_ptr管理子节点,父节点持有子节点的所有权。节点基类定义accept(Visitor&)方法,具体节点继承并实现。这样后面写语义分析、代码生成时可以用访问者模式遍历,不用改节点定义。

struct ASTNode { virtual ~ASTNode() = default; }; struct NumberNode : ASTNode { double value; explicit NumberNode(double v) : value(v) {} }; struct BinaryNode : ASTNode { char op; std::unique_ptr<ASTNode> left; std::unique_ptr<ASTNode> right; BinaryNode(char o, std::unique_ptr<ASTNode> l, std::unique_ptr<ASTNode> r) : op(o), left(std::move(l)), right(std::move(r)) {} };

参数说明:std::unique_ptr保证节点析构时自动释放子树,不需要手写递归 delete;BinaryNode的构造函数用移动语义接管左右子树,避免拷贝。如果实验要求里禁止用智能指针,那就得在节点析构函数里手动 delete 左右子节点,并且注意不要重复释放。

4. 符号表与错误恢复:让分析器从“能跑”变成“能用”

4.1 符号表的数据结构选择与作用域链

词法分析和语法分析只关心语法结构,但实验里经常要求同时输出变量声明和引用信息,这就需要符号表。符号表最简单的实现是std::unordered_map<std::string, Symbol>,但作用域嵌套时单层 map 不够用。常见做法是用栈式符号表:进入一个作用域压入一个新 map,退出时弹出。查找时从栈顶往下找,找到第一个匹配就返回。

class SymbolTable { public: void enterScope() { scopes_.emplace_back(); } void exitScope() { scopes_.pop_back(); } bool declare(const std::string& name, const std::string& type) { auto& current = scopes_.back(); if (current.count(name)) return false; // 重复声明 current[name] = type; return true; } std::string lookup(const std::string& name) const { for (auto it = scopes_.rbegin(); it != scopes_.rend(); ++it) { auto found = it->find(name); if (found != it->end()) return found->second; } return ""; // 未声明 } private: std::vector<std::unordered_map<std::string, std::string>> scopes_; };

逻辑说明:scopes_是 vector 当栈用,enterScope和exitScope成对出现。declare只在当前作用域检查重复,允许内层作用域遮蔽外层同名变量。lookup从最内层往外找,符合大多数语言的名称解析规则。参数方面,type用字符串存,实验够用;如果要做类型检查,得换成枚举或类型对象。

4.2 语法错误恢复的三种策略

语法分析器遇到错误时直接抛异常退出,对于实验演示够用,但真实场景需要继续分析后面的代码,尽可能多报几个错。常见策略有三种:恐慌模式、短语级恢复和错误产生式。恐慌模式是丢弃 Token 直到遇到同步集合(比如分号、右花括号),然后继续;短语级恢复是尝试插入或删除一个 Token 让文法继续;错误产生式是在文法里显式写出常见错误模式,匹配到就报特定错误。

在递归下降里,恐慌模式最容易实现:在parseExpr等函数里捕获异常,跳到下一个分号或右括号,然后返回一个占位节点。注意不要无限循环——如果当前 Token 就是同步符号,必须强制前进一个 Token。

4.3 用测试用例驱动分析器开发

写分析器最怕的是“看起来能跑,一换输入就崩”。建议先写一组测试用例,覆盖正常表达式、缺少右括号、多余运算符、空输入、超长标识符。每个用例给出期望的 Token 序列或期望的异常信息。C++ 里可以用简单的assert或者 Catch2 单测框架。如果实验环境不允许引入第三方库,就写一个runTest()函数,手动比对输出。

void runTest(const std::string& input, const std::string& expected) { Lexer lexer(input); auto tokens = lexer.tokenize(); std::string actual; for (const auto& t : tokens) actual += t.text + "|"; if (actual != expected) { std::cerr << "FAIL: input='" << input << "'\n" << " expected: " << expected << "\n" << " actual: " << actual << "\n"; } }

参数说明:expected用|分隔 Token 文本,方便肉眼比对;EndOfFile的文本为空,所以末尾会多一个|,写期望值时注意。这个测试函数不依赖任何框架,直接编译进主程序即可。

5. 避坑与排查:编译原理实验里最常见的五个翻车现场

5.1 现象:词法分析器把3.14.15当成一个数字,语法分析器解析出错误结果

原因:readNumber()里没有检查第二个小数点,while条件只判断了isdigit和.,导致连续小数点被吞。解决:在循环里加hasDot标志,遇到第二个.时 break,让后续字符走Unknown分支或者报错。更稳妥的做法是数字状态机里区分“整数部分”“小数点”“小数部分”三个状态。

5.2 现象:递归下降分析器在输入((1+2)时栈溢出崩溃

原因:parseFactor遇到(后递归调用parseExpr,但缺少右括号时expect(RParen)抛异常,异常沿调用栈向上传播,如果上层没有捕获,程序直接终止。解决:在parseExpr入口加 try-catch,捕获后跳到同步符号(比如行尾或右括号),返回一个NumberNode(0)占位。注意 catch 里要检查pos_是否前进,否则会死循环。

5.3 现象:符号表在退出作用域后仍然能查到内层变量

原因:exitScope()只弹出了 map,但lookup遍历的是scopes_的副本或者引用失效。解决:确保lookup用const引用遍历,并且exitScope确实调用了pop_back()。如果用了迭代器缓存,弹出后迭代器失效,必须重新获取。

5.4 现象:CMake 编译通过,但链接时报undefined reference to 'Lexer::tokenize()'

原因:头文件里声明了tokenize(),但 .cpp 文件里函数签名不一致,比如漏了const或者参数类型不同。解决:用nm查看目标文件符号,对比声明和定义的签名。C++ 里std::string和const std::string&是不同的符号,必须完全一致。

5.5 现象:VS Code 里能跳转,但命令行编译报找不到头文件

原因:VS Code 的 C++ 插件用c_cpp_properties.json里的includePath做智能提示,但实际编译用的是tasks.json里的命令,两者配置不同步。解决:把includePath和编译命令里的-I参数对齐,或者直接用 CMake 生成compile_commands.json,让插件和编译共用一套配置。

6. 进阶技巧:把分析器接上真实输入并验证正确性

6.1 从文件读入并输出带行号的错误信息

实验里的输入通常是一个 .txt 文件。用std::ifstream读入整个文件到std::string,然后交给 Lexer。错误信息里带上行号和列号,格式建议是[line:col] error: message,方便和编辑器的问题面板对接。

std::string readFile(const std::string& path) { std::ifstream ifs(path); if (!ifs) throw std::runtime_error("Cannot open file: " + path); return std::string((std::istreambuf_iterator<char>(ifs)), std::istreambuf_iterator<char>()); }

参数说明:std::istreambuf_iterator比>>更快,且不会跳过空白字符;如果文件很大,可以改成逐行读取,但要注意跨行 Token(比如多行注释)的处理。

6.2 用 AST 打印验证语法结构

递归下降版本可以在每个parseXxx函数返回后打印节点信息,但更清晰的做法是单独写一个printAST函数,按缩进输出树形结构。下面是一个简单实现:

void printAST(const ASTNode* node, int indent = 0) { std::string pad(indent * 2, ' '); if (auto num = dynamic_cast<const NumberNode*>(node)) { std::cout << pad << "Number(" << num->value << ")\n"; } else if (auto bin = dynamic_cast<const BinaryNode*>(node)) { std::cout << pad << "Binary(" << bin->op << ")\n"; printAST(bin->left.get(), indent + 1); printAST(bin->right.get(), indent + 1); } }

逻辑说明:dynamic_cast在调试阶段够用,性能不是瓶颈;如果节点类型很多,可以改成访问者模式。参数方面,indent控制缩进层级,每层两个空格,输出结果可以直接和手画的语法树对比。

6.3 一个我常用的验证习惯

每次改完词法或语法分析器,先跑三个输入:一个正常表达式、一个缺右括号的表达式、一个空文件。正常表达式验证主流程,缺右括号验证错误恢复,空文件验证边界条件。这三个过了,再跑课程提供的测试用例。如果测试用例里有用例失败,先看行号,再看 Token 序列,最后看语法树。大部分问题出在词法阶段——Token 类型不对,语法分析器再正确也白搭。

希望帮到你。

本文还有配套的精品资源,点击获取

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/10/13 4:44:15

电商履约链路全解析:从支付到签收的后端技术拆解

做电商开发这些年&#xff0c;我经常被问到一个问题&#xff1a;用户在前面点一下“购买”&#xff0c;商品到底是怎么一步步送到手上的&#xff1f;很多人以为这就是个物流问题&#xff0c;快递员从仓库拿货、送件、签收&#xff0c;完事。实际上&#xff0c;从支付成功到用户…

作者头像 李华
网站建设 2026/10/13 4:43:59

JavaCV FFmpeg原生库加载失败:AV_PIX_FMT_GBRAP14报错排查与解决

搞JavaCV做音视频处理的老哥&#xff0c;多半都被这么个报错砸到过&#xff1a;项目里头明明把FFmpeg相关的依赖都加进去了&#xff0c;结果一启动jvm&#xff0c;直接甩出UnsatisfiedLinkError: org.bytedeco.ffmpeg.global.avutil.AV_PIX_FMT_GBRAP14()I。别急着怀疑JDK坏了&…

作者头像 李华
网站建设 2026/10/13 4:43:35

实数域与复数域统一标注:分形迭代参考图及四维全息算法解析

做分形迭代这行也有几年了&#xff0c;我一直觉得实数域和复数域像两座隔海相望的孤岛。实数这一侧大家习惯画分岔图、看倍周期级联&#xff0c;复数那一侧则盯着复平面上的逃逸时间着色图&#xff0c;各有各的漂亮结果&#xff0c;却几乎没有人把两边放进同一张图里&#xff0…

作者头像 李华
网站建设 2026/10/13 4:43:30

C/C++捕鱼服务端源码拆解:网络层、对象池与二次开发避坑指南

简介&#xff1a;这份新版李逵劈鱼源代码面向C/C开发者与棋牌游戏服务端学习者&#xff0c;提供一套可直接研读的捕鱼游戏服务端实现&#xff0c;帮助理解网络游戏后端从通信到业务逻辑的完整链路。资源包共26个文件&#xff0c;约79KB&#xff0c;以cpp与h源文件为主&#xff…

作者头像 李华
网站建设 2026/10/13 4:43:18

Akamai风控对抗:用ML生成唯一有效cookie的实战指南

简介&#xff1a;这份资源面向需要研究 Akamai 反爬与 Cookie 生成机制的 JavaScript 开发者与安全学习者&#xff0c;核心是用机器学习思路生成唯一且有效的 Cookie&#xff0c;可应用于电商、金融等对会话安全要求较高的场景。压缩包共 5 个文件&#xff0c;约 2KB&#xff0…

作者头像 李华
网站建设 2026/10/13 4:43:04

OpenPose caffemodel 模型加载与后处理参数调优实战指南

简介&#xff1a;这份资源是OpenPose官方模型库的完整打包&#xff0c;面向计算机视觉开发者、姿态估计方向的研究生以及需要快速搭建人体关键点检测demo的工程师。包内共12个文件&#xff0c;以6个prototxt网络结构定义、5个caffemodel权重文件和1个xml分类器为主&#xff0c;…

作者头像 李华