简介:这份资源是编译原理课程中词法分析与语法分析环节的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_map<std::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_ptr<ASTNode> left; std::unique_ptr<ASTNode> right; }; // 解析加减表达式:左结合,优先级低于乘除 std::unique_ptr<ASTNode> 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_unique<ASTNode>(); node->type = "BinaryOp"; node->value = op; node->left = std::move(left); node->right = std::move(right); left = std::move(node); // 左结合:新节点成为下一轮的左操作数 } return left; } // 解析乘除表达式:优先级高于加减 std::unique_ptr<ASTNode> 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_unique<ASTNode>(); node->type = "BinaryOp"; node->value = op; node->left = std::move(left); node->right = std::move(right); left = std::move(node); } return left; } // 解析因子:数字、标识符或括号表达式 std::unique_ptr<ASTNode> Parser::parseFactor() { if (current.type == TokenType::NUMBER) { auto node = std::make_unique<ASTNode>(); 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管理,避免手动delete;std::move转移所有权,防止悬空指针。常见错误是忘记在循环里更新left,导致只解析了第一个操作数就返回,结果1+2+3被解析成(1+2)丢掉了+3。
4. 避坑与排查:编译、运行、扩展时的五个血泪教训
4.1 现象:编译报错 “unique_ptr 不可复制”
原因:ASTNode里用了std::unique_ptr作为子节点,但某处代码试图按值传递或拷贝节点。解决:所有涉及节点传递的函数参数用std::unique_ptr<ASTNode>&&或直接std::move,返回值用std::unique_ptr而不是裸指针。如果确实需要共享,改用std::shared_ptr,但语法树是严格的树形结构,unique_ptr更合适。
4.2 现象:输入1+2*3输出结果却是(1+2)*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_ptr<ASTNode>& 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节点的值一定是纯数字。除零判断放在这里而不是词法阶段,因为词法阶段不知道上下文,只有求值时才能确定分母是否为零。
验证时,我一般准备一组测试用例:1+2*3期望 7,(1+2)*3期望 9,10/2-3期望 2,2*3+4*5期望 26。如果某个用例挂了,先看 AST 结构对不对——可以在evaluate里加一行打印节点类型和值,或者单独写一个printAST函数做前序遍历。常见问题是括号解析后忘记expect(RPAREN),导致(1+2这种不匹配的输入被静默接受,求值时才暴露成奇怪的结果。
另一个进阶方向是把 AST 转成后缀表达式输出,或者生成三地址码。这些扩展不需要改词法分析器,只需要在语法分析构造节点时多存一些信息。我习惯在每次扩展文法后,先把所有旧测试用例跑一遍,确认没有回归,再加新用例。从那以后我每次写完解析器,都强制走一遍“词法输出 → AST 打印 → 求值对比”三步验证,少一步都可能留下隐蔽的优先级 bug。希望这份拆解能帮你把编译原理的这两块硬骨头啃下来,资源里的代码可以直接编译运行,改一改就能当课程设计的底稿。
本文还有配套的精品资源,点击获取