简介:这是一份面向计算机专业学生与编译技术初学者的编译器前端实践资料,聚焦编译原理中词法分析与语法分析两大核心环节,用C++实现从源代码到抽象语法树的完整流程。资源包共9个文件,以cpp源码、txt文法与token说明、exe可执行程序及md说明文档为主,压缩包约937KB,体积轻巧便于本地编译调试。内容涵盖词法规则定义、有限自动机实现、词法错误处理,以及上下文无关文法构建、递归下降或LL(1)解析、语法错误与类型错误处理,并附有文法文件与token表辅助理解。已有736人学习下载,适合作为课程实验、大作业或自学练手项目。读者可据此掌握词法单元序列生成与AST构建思路,理解编译器前端工作机理,为后续语义分析、代码生成及编程语言理论学习打下基础。
1. 从一份 .zip 说起:词法分析器和语法分析器到底在解决什么问题
很多人第一次接触编译原理,都是从一个压缩包开始的——同学发来一个编译原理词法分析器和语法分析器的实现(C++).zip,解压后一堆.cpp和.h,打开一看全是状态机、递归下降、First 集 Follow 集,瞬间头大。这个标题背后其实就两件事:把一段源代码字符串切成有意义的记号(Token),再把记号序列组装成语法树(AST)。前者叫词法分析器(Lexer/Scanner),后者叫语法分析器(Parser)。它们合起来是编译器前端的地基,也是编译原理实验里最常被要求手写实现的两个模块。
这份东西适合谁?正在做编译原理实验的本科生、想补编译前端基础的 C++ 开发者、以及需要给 DSL 或配置文件写解析器的人。它不解决代码优化和代码生成,但把前端跑通之后,你就能理解 IDE 里为什么能高亮、能跳转、能报“缺少分号”。下面我按“先能跑、再能改、最后能扩”的顺序,把一份可复现的 C++ 实现拆开讲。
2. 词法分析器:从字符流到 Token 序列的最小闭环
2.1 为什么先写词法分析器,而不是直接上语法分析
词法分析器的职责非常单一:读入字符流,输出 Token 序列。每个 Token 至少包含类型(如ID、NUM、PLUS、EOF)和原始文本,有时还带行号列号。它不关心if后面跟的是不是括号,只负责把if认出来是关键字而不是普通标识符。先写它的好处是:输入输出都是线性的,调试成本低,而且语法分析器可以直接拿它的输出做单元测试。
常见做法是手写一个Lexer类,内部维护pos、line、col,对外暴露nextToken()。也有人用正则或std::regex,但性能差且不好控制错误位置,编译原理实验里不推荐。我一般会先定义 Token 类型枚举,再写一个Token结构体,最后实现扫描循环。
2.2 用 C++ 实现一个可运行的 Lexer:状态机与关键代码
下面是一个最小可用的词法分析器核心代码,支持标识符、整数、常见运算符和空白跳过。代码里用std::string保存源码,用std::vector<Token>保存结果。
#include <string> #include <vector> #include <cctype> #include <stdexcept> enum class TokenType { ID, NUM, PLUS, MINUS, STAR, SLASH, LPAREN, RPAREN, ASSIGN, SEMI, EOF_TOKEN, UNKNOWN }; struct Token { TokenType type; std::string text; int line; int col; }; 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(static_cast<unsigned char>(c))) { advance(); continue; } if (std::isalpha(static_cast<unsigned char>(c)) || c == '_') { tokens.push_back(readIdentifier()); } else if (std::isdigit(static_cast<unsigned char>(c))) { tokens.push_back(readNumber()); } else { tokens.push_back(readOperatorOrPunct()); } } tokens.push_back({TokenType::EOF_TOKEN, "", 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(static_cast<unsigned char>(src_[pos_])) || src_[pos_] == '_')) { text += src_[pos_]; advance(); } return {TokenType::ID, text, startLine, startCol}; } Token readNumber() { int startLine = line_, startCol = col_; std::string text; while (pos_ < src_.size() && std::isdigit(static_cast<unsigned char>(src_[pos_]))) { text += src_[pos_]; advance(); } return {TokenType::NUM, text, startLine, startCol}; } Token readOperatorOrPunct() { 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}; case '=': return {TokenType::ASSIGN, "=", startLine, startCol}; case ';': return {TokenType::SEMI, ";", startLine, startCol}; default: return {TokenType::UNKNOWN, std::string(1, c), startLine, startCol}; } } };逻辑说明:tokenize()是主循环,遇到空白就跳过,遇到字母或下划线进入readIdentifier(),遇到数字进入readNumber(),其余走readOperatorOrPunct()。每个read*函数在返回前都会把pos_推进到 Token 末尾,并记录起始行列。参数方面,src_是只读源码,pos_是当前字符下标,line_和col_用于报错定位。如果你要支持浮点数,在readNumber()里加一个小数点判断即可;要支持注释,在tokenize()里检测//或/*并跳过。
提示:
std::isspace等函数对负值 char 是未定义行为,所以统一转成unsigned char再传,这是 C++ 里很容易翻车的一个细节。
2.3 词法分析器的参数怎么调:缓冲区、行号与错误恢复
词法分析器本身没有太多“参数”,但有几个设计选择会直接影响后续语法分析的体验。第一是是否保留空白和注释。编译原理实验通常要求跳过,但如果你要做代码格式化工具,就得保留。第二是行号和列号从 1 开始还是从 0 开始,建议从 1 开始,因为报错信息里“第 1 行”比“第 0 行”更符合直觉。第三是遇到非法字符时是抛异常还是返回UNKNOWNToken。我一般返回UNKNOWN并继续,这样语法分析器能一次性报出多个错误,而不是遇到第一个就退出。
如果你用std::string保存整个源码,大文件会占内存,但编译原理实验的输入通常很小,没必要上流式读取。真要处理大文件,可以把src_换成std::istream,但那样pos_和回退逻辑会复杂很多,新手不建议。
3. 语法分析器:递归下降把 Token 序列变成语法树
3.1 递归下降 vs LR:为什么实验里首选递归下降
语法分析器有两大家族:自顶向下的递归下降和自底向上的 LR 系列。递归下降的优点是代码结构和文法产生式几乎一一对应,手写快、调试直观,适合 LL(1) 文法。LR 分析器需要构造分析表,通常用工具生成,手写成本高。编译原理实验里,老师一般会给定一个简单的表达式文法或语句文法,递归下降是最稳妥的选择。
递归下降的核心是:每个非终结符对应一个函数,函数内部根据当前 Token 决定走哪条产生式。比如表达式文法E -> T E'、E' -> + T E' | ε,就可以写成parseE()调用parseT()再调用parseEPrime()。遇到+就继续,否则返回。这里的关键是First集和Follow集,用来决定什么时候该展开、什么时候该返回。
3.2 手写递归下降解析器:AST 节点与解析函数
下面实现一个支持加减乘除和括号的表达式解析器,输出 AST。先定义 AST 节点:
#include <memory> #include <vector> struct ASTNode { virtual ~ASTNode() = default; }; struct NumNode : ASTNode { int value; explicit NumNode(int v) : value(v) {} }; struct BinOpNode : ASTNode { char op; std::unique_ptr<ASTNode> left; std::unique_ptr<ASTNode> right; BinOpNode(char o, std::unique_ptr<ASTNode> l, std::unique_ptr<ASTNode> r) : op(o), left(std::move(l)), right(std::move(r)) {} };然后是解析器类,它持有 Token 列表和当前下标:
class Parser { public: explicit Parser(std::vector<Token> tokens) : tokens_(std::move(tokens)), pos_(0) {} std::unique_ptr<ASTNode> parse() { return parseExpr(); } private: std::vector<Token> tokens_; size_t pos_; const Token& peek() const { return tokens_[pos_]; } Token consume() { return tokens_[pos_++]; } bool match(TokenType t) { if (peek().type == t) { pos_++; return true; } return false; } std::unique_ptr<ASTNode> parseExpr() { auto left = parseTerm(); while (peek().type == TokenType::PLUS || peek().type == TokenType::MINUS) { char op = (peek().type == TokenType::PLUS) ? '+' : '-'; consume(); auto right = parseTerm(); left = std::make_unique<BinOpNode>(op, std::move(left), std::move(right)); } return left; } std::unique_ptr<ASTNode> parseTerm() { auto left = parseFactor(); while (peek().type == TokenType::STAR || peek().type == TokenType::SLASH) { char op = (peek().type == TokenType::STAR) ? '*' : '/'; consume(); auto right = parseFactor(); left = std::make_unique<BinOpNode>(op, std::move(left), std::move(right)); } return left; } std::unique_ptr<ASTNode> parseFactor() { if (peek().type == TokenType::NUM) { int val = std::stoi(consume().text); return std::make_unique<NumNode>(val); } if (match(TokenType::LPAREN)) { auto node = parseExpr(); if (!match(TokenType::RPAREN)) { throw std::runtime_error("Expected ')' at line " + std::to_string(peek().line)); } return node; } throw std::runtime_error("Unexpected token '" + peek().text + "' at line " + std::to_string(peek().line)); } };逻辑说明:parseExpr()处理加减,parseTerm()处理乘除,parseFactor()处理数字和括号。每个函数内部用while循环处理左结合运算符,这样1+2+3会解析成((1+2)+3)。peek()只看不消费,consume()消费并前进,match()在类型匹配时消费。参数方面,tokens_是词法分析器的输出,pos_是当前 Token 下标。错误处理直接抛std::runtime_error,带上行号,方便定位。
注意:递归下降对左递归文法会无限递归,所以文法必须消除左递归。比如
E -> E + T要改写成E -> T E',E' -> + T E' | ε。这是新手最容易踩的坑之一。
3.3 把 Lexer 和 Parser 串起来:一个 main 函数跑通全流程
有了 Lexer 和 Parser,写一个main就能跑通:
#include <iostream> #include <sstream> int main() { std::string input = "1 + 2 * (3 - 4)"; Lexer lexer(input); auto tokens = lexer.tokenize(); Parser parser(std::move(tokens)); try { auto ast = parser.parse(); std::cout << "Parse OK" << std::endl; } catch (const std::exception& e) { std::cerr << "Parse error: " << e.what() << std::endl; } return 0; }编译命令用g++ -std=c++17 -o parser main.cpp,在 VS Code 里配置 C/C++ 环境时,把tasks.json的args加上-std=c++17即可。如果你用 Visual Studio,注意std::make_unique需要 C++14 以上,项目属性里把“C++ 语言标准”调到/std:c++17。跑通之后,你可以把input换成从文件读入,或者加一个 REPL 循环,逐行解析。
4. 避坑与排查:词法语法分析器实现中最容易翻车的 5 个点
4.1 现象:解析1+2*3结果不对,乘除优先级丢失
原因:parseExpr()里直接调用了parseFactor(),跳过了parseTerm(),导致乘除和加减在同一层。解决:严格按文法分层,parseExpr只处理加减,parseTerm只处理乘除,parseFactor只处理数字和括号。每层只消费自己负责的运算符。
4.2 现象:遇到(后报“Unexpected token”,但括号明明匹配
原因:parseFactor()里match(LPAREN)消费了左括号,但解析完表达式后没有检查右括号,或者parseExpr()返回后peek()已经指向了EOF。解决:在parseFactor()里显式检查RPAREN,不匹配就抛错并带上当前 Token 的行号。另外确认tokenize()没有把括号识别成UNKNOWN。
4.3 现象:词法分析器把123abc识别成一个 Token
原因:readNumber()只读数字,遇到字母就停,但readIdentifier()又允许数字开头,导致123abc被拆成NUM(123)和ID(abc),或者反过来。解决:在tokenize()里先判断首字符,数字开头就只走readNumber(),字母或下划线开头才走readIdentifier()。readIdentifier()内部允许数字,但首字符必须是字母或下划线。
4.4 现象:行号列号全错,报错定位到第 1 行
原因:advance()里换行时只更新了line_,没有重置col_,或者readIdentifier()里先advance()再记录起始位置。解决:在read*函数开头先保存startLine和startCol,再进入循环。advance()里遇到\n时line_++、col_ = 1,否则col_++。这样每个 Token 的行列都是起始位置。
4.5 现象:递归下降解析深层嵌套表达式时栈溢出
原因:parseExpr -> parseTerm -> parseFactor -> parseExpr的递归深度等于括号嵌套深度,几千层嵌套就会爆栈。解决:编译原理实验的输入通常不会这么深,但如果要处理用户输入,可以加一个深度计数器,超过阈值就报“表达式嵌套过深”。或者改用迭代式解析(如 shunting-yard 算法)来避免递归。
5. 从能跑到好用:给解析器加符号表和错误恢复
5.1 符号表:让变量声明和引用对得上
表达式解析器只能处理数字,一旦加入变量,就需要符号表。符号表本质是一个从标识符到类型/值的映射。常见做法是用std::unordered_map<std::string, Symbol>,Symbol里存类型、作用域层级、是否已初始化。在parseFactor()里遇到ID时,查符号表,找不到就报“未声明的变量”。声明语句可以在parseStatement()里处理,比如int x = 1;先解析类型关键字,再把x插入符号表。
struct Symbol { std::string name; std::string type; int scopeLevel; bool initialized; }; class SymbolTable { public: void declare(const std::string& name, const std::string& type) { table_[name] = {name, type, currentScope_, false}; } bool exists(const std::string& name) const { return table_.find(name) != table_.end(); } void enterScope() { currentScope_++; } void exitScope() { currentScope_--; } private: std::unordered_map<std::string, Symbol> table_; int currentScope_ = 0; };参数说明:currentScope_从 0 开始,每进入一个{}块就enterScope(),退出就exitScope()。declare()在插入前可以检查当前作用域是否已存在同名符号,避免重复声明。exists()用于引用检查。这个结构足够支撑编译原理实验里的变量声明和引用。
5.2 错误恢复:一次报多个错,而不是遇到第一个就退出
递归下降默认遇到错误就抛异常,整个解析终止。但实际编译器会尽量继续解析,报出更多错误。一个简单做法是在parseStatement()层加同步点:当捕获到异常时,跳过 Token 直到遇到SEMI或EOF,然后继续解析下一条语句。这样一次编译能报出多个语法错误。
void parseProgram() { while (peek().type != TokenType::EOF_TOKEN) { try { parseStatement(); } catch (const std::exception& e) { std::cerr << "Error: " << e.what() << std::endl; while (peek().type != TokenType::SEMI && peek().type != TokenType::EOF_TOKEN) { consume(); } if (peek().type == TokenType::SEMI) consume(); } } }逻辑说明:parseProgram()循环解析语句,每条语句独立 try-catch。出错后消费 Token 直到分号或文件结束,然后继续下一条。这样即使中间有错,后面的语句仍能被检查。参数方面,同步点选SEMI是因为分号是语句边界,选EOF是防止死循环。
5.3 验证方法:用测试用例覆盖边界
写完解析器后,别只跑一个1+2。准备一组测试用例:空输入、只有空白、1+、(1+2、1+2)、1++2、1+2*3、(1+2)*3、1/0(语法正确但运行时会除零,解析器不应报错)。每个用例检查是否抛异常、异常信息是否带行号、AST 结构是否符合预期。可以用assert写一个简单的测试函数,每次改完代码跑一遍。这个习惯能帮你省下大量“改一处崩三处”的时间。
5.4 一个具体技巧:用 AST 打印验证优先级
解析结果对不对,光看“Parse OK”不够。写一个printAST()递归函数,把 AST 打印成带括号的表达式。比如1+2*3应该打印成(1+(2*3)),(1+2)*3打印成((1+2)*3)。如果打印结果和预期一致,说明优先级和结合性都对了。这个技巧我每次写解析器都会用,比单步调试快得多。
void printAST(const ASTNode* node) { if (auto num = dynamic_cast<const NumNode*>(node)) { std::cout << num->value; } else if (auto bin = dynamic_cast<const BinOpNode*>(node)) { std::cout << "("; printAST(bin->left.get()); std::cout << bin->op; printAST(bin->right.get()); std::cout << ")"; } }调用printAST(ast.get())就能看到完整括号化表达式。如果输出是(1+(2*3)),说明乘除优先级高于加减;如果是((1+2)*3),说明括号生效。这个函数不需要修改解析器,纯靠 AST 结构验证,是排查优先级问题的后悔药。
我自己的习惯是:每加一个文法产生式,先写测试用例,再写解析函数,最后用printAST确认结构。这样即使文法变复杂,也能快速定位是哪一层出了问题。希望帮到你。
本文还有配套的精品资源,点击获取