☰
C++手写词法分析器与语法分析器:从Token流到语法树的工程实现
2026/10/10 17:29:34 网站建设 项目流程

简介:面向编译原理课程设计与自学的C++词法分析器与语法分析器实现包,适合计算机专业学生、对编译器运行机制感兴趣的开发者,以及语言处理方向研究者。资源完整演示了从源代码到词法单元序列、再到抽象语法树的编译器前端流程,包含有限自动机、上下文无关文法、递归下降/LL(1)解析等核心知识点。压缩包共9个文件,约937KB,其中2个cpp为源程序,词法分析.cpp和语法分析.cpp分别对应两个分析器;4个txt文件提供文法规则、源程序及token表输出,便于对照验证;另有2个exe可执行程序和1个README说明文档,支持直接运行体验。目前已有736人学习下载,适合需要课程设计参考或动手实践编译原理概念的学习者。通过阅读源码、查看token表与文法定义,能更直观地理解分析器状态转换与语法树构建过程,也可作为进一步扩展新型语法特性的起点。

1. 编译原理词法分析器和语法分析器的C++实现:一份能直接跑通的工程

编译原理的词法分析器(Lexer)和语法分析器(Parser)是C++课程设计和考研复试里最常被要求现场写出来的两块。这份资源给的是一个不依赖Lex/Yacc、纯C++写的小型编译器前端:字符流进来,经过词法分析变成Token流,再经过递归下降的语法分析构建出语法树,全程可以单步调试,也方便你在上面继续加中间代码生成。它解决的不是“看懂定义”,而是“真的跑通”——适合正在做编译原理实验、准备保研机试,或者毕设里需要一个小型前端的人。接下来我把实现思路、参数设定和我实际踩过的坑拉一遍。

2. 词法分析器怎么落地:从状态转换图到Token流

2.1 为什么是手写状态机而不是Lex生成

很多教材花大篇幅讲正则表达式到NFA、NFA到DFA的子集构造,这是理解自动机理论的必经路。但实际做课程设计时,用Lex生成器反而限制你展示实现细节,答辩时被追问“状态转移图怎么画的”容易答不实。我一般建议手写词法分析器:先把每个Token类别的状态转移图画在纸上,再直接编码成游标读取加判断的形式。

这份资源走的就是手写路线,好处是代码可控、错误定位直观。它把每个Token的识别逻辑收敛在getNextToken()一个入口里,外部用不到任何生成器产物,编译环境只要有一个标准C++编译器就行。

2.2 核心结构:Token定义、符号表与游标模型

词法分析器的高层设计分三块:Token结构、符号表、游标推进器。先看Token定义和Lexer头文件:

#ifndef LEXER_H #define LEXER_H #include <string> #include <vector> enum TokenType { TOKEN_IDENTIFIER, TOKEN_NUMBER, TOKEN_KEYWORD, TOKEN_OPERATOR, TOKEN_STRING, TOKEN_EOF, TOKEN_ERROR }; struct Token { TokenType type; std::string value; int line; int column; }; class Lexer { public: explicit Lexer(const std::string& source); Token getNextToken(); private: std::string src; size_t pos; int line; int column; char peek(size_t offset = 0) const; void advance(); bool isKeyword(const std::string& word) const; }; #endif

Token里除了类型和值,还记录行列号,这是后面语法分析报错能定位到具体位置的根基。line从1开始,column从1开始,出错信息直接拼出来就行。

词法分析器的主循环在getNextToken()里,我用的是peek加advance的游标模型。peek(offset)只观察不移动,advance()才真正推进并更新行号列号。这个模型比直接下标访问更不容易在双字符运算符上翻车:

#include "lexer.h" #include <cctype> #include <unordered_set> static const std::unordered_set<std::string> keywords = { "int", "float", "if", "else", "while", "return" }; Lexer::Lexer(const std::string& source) : src(source), pos(0), line(1), column(1) {} char Lexer::peek(size_t offset) const { size_t idx = pos + offset; if (idx >= src.size()) return '\0'; return src[idx]; } void Lexer::advance() { if (pos < src.size()) { if (src[pos] == '\n') { line++; column = 1; } else { column++; } pos++; } } bool Lexer::isKeyword(const std::string& word) const { return keywords.find(word) != keywords.end(); } Token Lexer::getNextToken() { while (peek() == ' ' || peek() == '\t' || peek() == '\n') advance(); if (peek() == '\0') return {TOKEN_EOF, "", line, column}; int startLine = line, startCol = column; if (isalpha(peek()) || peek() == '_') { std::string word; while (isalnum(peek()) || peek() == '_') { word.push_back(peek()); advance(); } if (isKeyword(word)) return {TOKEN_KEYWORD, word, startLine, startCol}; return {TOKEN_IDENTIFIER, word, startLine, startCol}; } if (isdigit(peek())) { std::string num; while (isdigit(peek())) { num.push_back(peek()); advance(); } if (peek() == '.' && isdigit(peek(1))) { num.push_back('.'); advance(); while (isdigit(peek())) { num.push_back(peek()); advance(); } } return {TOKEN_NUMBER, num, startLine, startCol}; } std::string op(1, peek()); advance(); return {TOKEN_OPERATOR, op, startLine, startCol}; }

先跳空白再判断结束,这是个固定的顺序,不能反过来——如果先判'\0',字符串末尾的空白和换行会直接让分析器漏掉真正的结束位置。关键字判定放在标识符识别之后,先拼出完整词再查表,避免把intx截成int加x两个Token。

数字部分只处理了整数和小数,没有做科学计数法。如果你的实验要求支持1e-5这种写法,需要在isdigit循环之后加一个分支,判断peek() == 'e' || peek() == 'E',并且后面必须跟数字或正负号加数字。运算符部分当前按单字符处理,想支持==、>=这类双字符运算符,在return {TOKEN_OPERATOR, op, ...}之前加一个peek(1)的判断分支即可,注意合并后要调用两次advance()。

2.3 调试词法结果:把Token流dump出来对拍

写完词法分析器,第一件事不是接语法分析器,而是把Token流完整打印出来。我在主程序里挂了一个dump模式,输出格式是行:列 type value,一行一个Token:

1:1 KEYWORD int 1:5 IDENTIFIER a 1:7 OPERATOR = 1:9 NUMBER 10 1:12 OPERATOR + 1:14 NUMBER 20

这个格式看着基础,但对拍非常好用。我习惯把测试文件里的预期Token序列写进一个txt,然后和程序输出做diff。词法分析器有没有把注释吃掉、字符串有没有截断、运算符有没有拆错,diff一眼就出来。没有这一步,后面语法分析报错时你根本分不清是词法错的还是语法错的。

3. 语法分析器:递归下降与LL(1)预测分析的实现

3.1 为什么选递归下降而不是LR表驱动

LR分析器能力强,理论上能处理的文法更多,但状态栈和ACTION/GOTO表对初学者就是黑匣子,出了一次错很难从状态转移trace出原因。递归下降的劣势是左递归文法会死循环,但教学实验的文法通常都改写成LL(1)了,这个劣势基本不存在。

这份资源用的是递归下降加一个Token预读(lookahead)。它的本质是自顶向下分析:从起始符号开始,按产生式展开,每遇到一个终结符就和当前Token匹配。代码结构对应文法层次,一眼能看出表达式优先级,答辩时也容易讲清楚。

3.2 文法、First集与Follow集

语法分析器支持的文法是一个最简表达式文法,优先级从低到高:

Expression -> Term (('+' | '-') Term)* Term -> Factor (('*' | '/') Factor)* Factor -> NUMBER | IDENTIFIER | '(' Expression ')'

*表示零次或多次循环,这个写法本身就是消除左递归之后的LL(1)版本。手工算First集和Follow集,结果如下:

非终结符FIRSTFOLLOW
ExpressionNUMBER, IDENTIFIER, ($, )
TermNUMBER, IDENTIFIER, ($, ), +, -
FactorNUMBER, IDENTIFIER, ($, ), +, -, *, /

注意这里的$是输入结束符,对应词法分析器返回的TOKEN_EOF。两个非终结符的Follow集有重叠,所以分析表里多个产生式可以并存,但同一格子没有冲突,这个文法确实是LL(1)的。

3.3 语法树构造与错误恢复

Parser的头文件里定义了ASTNode,用shared_ptr管理子树,避免手动delete。节点只有left和right两个子节点,这种二叉树结构对表达式文法够用:

#ifndef PARSER_H #define PARSER_H #include "lexer.h" #include <memory> #include <string> struct ASTNode { std::string type; std::string value; std::shared_ptr<ASTNode> left; std::shared_ptr<ASTNode> right; }; class Parser { public: explicit Parser(Lexer& lexer); std::shared_ptr<ASTNode> parse(); private: Lexer& lexer; Token lookahead; void advance(); void match(const std::string& value, const std::string& hint); std::shared_ptr<ASTNode> parseExpression(); std::shared_ptr<ASTNode> parseTerm(); std::shared_ptr<ASTNode> parseFactor(); [[noreturn]] void error(const std::string& msg) const; }; #endif

lookahead始终保存当前要匹配的Token,advance()从词法分析器拿下一个。match是唯一的消费入口,值不匹配就抛错误,抛错信息里带行列号。

递归下降的核心在三个parse函数里,它们互相调用的层次直接反映优先级:

#include "parser.h" #include <iostream> Parser::Parser(Lexer& lex) : lexer(lex) { lookahead = lexer.getNextToken(); } void Parser::advance() { lookahead = lexer.getNextToken(); } void Parser::match(const std::string& value, const std::string& hint) { if (lookahead.value != value) { error("期望 " + hint + ",实际是 " + lookahead.value + ",行 " + std::to_string(lookahead.line)); } advance(); } std::shared_ptr<ASTNode> Parser::parse() { auto tree = parseExpression(); if (lookahead.type != TOKEN_EOF) { error("表达式结束后仍有未消费的Token"); } return tree; } std::shared_ptr<ASTNode> Parser::parseExpression() { auto node = parseTerm(); while (lookahead.value == "+" || lookahead.value == "-") { auto op = std::make_shared<ASTNode>(); op->type = "op"; op->value = lookahead.value; advance(); op->left = node; op->right = parseTerm(); node = op; } return node; } std::shared_ptr<ASTNode> Parser::parseTerm() { auto node = parseFactor(); while (lookahead.value == "*" || lookahead.value == "/") { auto op = std::make_shared<ASTNode>(); op->type = "op"; op->value = lookahead.value; advance(); op->left = node; op->right = parseFactor(); node = op; } return node; } std::shared_ptr<ASTNode> Parser::parseFactor() { if (lookahead.type == TOKEN_NUMBER || lookahead.type == TOKEN_IDENTIFIER) { auto leaf = std::make_shared<ASTNode>(); leaf->type = (lookahead.type == TOKEN_NUMBER) ? "num" : "id"; leaf->value = lookahead.value; advance(); return leaf; } if (lookahead.value == "(") { advance(); auto node = parseExpression(); match(")", "右括号"); return node; } error("无法解析因子,遇到 " + lookahead.value); }

左结合性靠while循环实现。1 + 2 + 3进来时,第一次parseTerm拿到1,循环里构建+(1, 2),第二次循环把新节点挂到原节点的right,最终得到+(+(1,2), 3),也就是(1+2)+3。如果把循环改成直接递归调用parseExpression,就会变成1+(2+3),这是右结合,加减法还看不出大问题,遇到减号就会算出错误结果。

错误恢复这里没有做复杂的同步集合,而是直接终止报错。这个选择对课程设计是合理的——分析器不是编译器成品,实验要求通常是“能识别合法输入并在非法输入上给出准确报错”,而不是“跳过错误继续分析下一句”。如果你想支持多条语句的继续分析,常见做法是维护一个syncSet,遇到错误后不停advance(),直到当前Token属于;或}等同步Token,再回到上层循环。

4. 把工程串起来:主流程、VSCode编译与测试样例设计

4.1 文件组织与依赖关系

解压后的工程目录是我很喜欢的五文件结构,头文件和源文件分离,依赖方向是main.cpp依赖parser.h,parser.cpp依赖lexer.h,没有循环引用。整个工程就是标准的C++项目,不需要第三方库:

lexer.h Token定义、Lexer类声明 lexer.cpp getNextToken实现 parser.h ASTNode定义、Parser类声明 parser.cpp 递归下降解析器实现 main.cpp 入口,读文件、驱动分析

这种组织的直接好处是:想单独测试词法分析器,写一个只和Lexer对接的测试文件就行;想换成LR分析器,Parser类的公共接口不变,词法那边完全不用动。

4.2 主程序调用流程

main.cpp负责三件事:读入源文件、构造词法分析器、交给语法分析器。读文件我用二进制方式加ostringstream整体倒入,而不是一行一行读,省去处理跨行字符串的麻烦:

#include "lexer.h" #include "parser.h" #include <fstream> #include <iostream> #include <sstream> int main(int argc, char* argv[]) { if (argc < 2) { std::cerr << "用法: parser <源码文件>" << std::endl; return 1; } std::ifstream in(argv[1], std::ios::binary); if (!in) { std::cerr << "无法打开文件: " << argv[1] << std::endl; return 1; } std::ostringstream ss; ss << in.rdbuf(); std::string source = ss.str(); Lexer lexer(source); Parser parser(lexer); auto tree = parser.parse(); std::cout << "语法分析通过,根节点类型: " << tree->type << std::endl; return 0; }

如果是在VSCode里开发,配C/C++环境时的关键在于tasks.json的编译参数要写全所有源文件。很多人只把main.cpp放进args,结果g++报一堆undefined reference,就是这个原因:

{ "version": "2.0.0", "tasks": [ { "label": "build", "type": "shell", "command": "g++", "args": [ "-std=c++17", "src/main.cpp", "src/lexer.cpp", "src/parser.cpp", "-o", "bin/parser" ], "options": { "cwd": "${workspaceFolder}" } } ] }

-std=c++17保证shared_ptr和[[noreturn]]这些特性在MSVC和GCC下行为一致。如果你在Windows上用MSVC编译器,记得把代码页切到UTF-8或将源文件全部存成UTF-8无BOM,否则中文注释会变成编译告警甚至乱码。

4.3 测试样例怎么设计

我建议至少准备三组测试文件:合法简单表达式、合法复合表达式、非法表达式。合法复合用例测优先级和括号:

int a = 10 + 20 * (3 - 1);

预期是先算3-1,再算20*2,最后加10,根节点是+。非法用例故意漏掉右括号:

int a = (1 + 2;

运行后程序应该在;位置报“期望 右括号,实际是 ;”。报错行列号和源文件里实际位置一致,才算这个分析器合格。注意int a = ...;这种带初始化语句的完整写法,当前文法只处理等号右侧表达式,左侧和声明部分由顶层代码处理。

5. 避坑与常见问题:解压后跑不起来的五个典型坑

1. 中文注释让词法分析器卡死或乱码

现象:源码文件里有中文注释,词法分析器输出乱码,或者在isalpha(peek())上直接崩溃。

原因:char在Windows下默认有符号,UTF-8中文首字节是负数,传给isalpha、isalnum这类标准库函数时属于未定义行为。按字节读文件的方式不会自动跳过多字节字符。

解决:把源码文件统一存成UTF-8无BOM,词法分析器显式处理注释。在getNextToken()开头加一段:遇到/时检查peek(1),如果是/就跳到行尾,如果是*就跳到*/,跳的过程中用advance()推进,不要碰isalpha。我每次做实验都强制自己先写注释跳过逻辑,否则后面所有测试都白搭。

2. 词法死循环,分析器卡在同一个字符上

现象:程序不报错也不结束,CPU占用拉满,调试发现pos没有变化。

原因:某个分支只构造了Token没有调用advance(),或者在peek() == '\0'这个判断之前多了一次空闲的peek()调用,没有对应的advance()。

解决:在getNextToken()里设一条铁律:每个return分支前,pos必然比进入函数时大。我一般会在函数末尾加一个调试断言打印旧pos和新pos,跑一次哑数据,哪个分支没推进马上暴露。另外运算符分支只advance()一次,如果识别成双字符运算符,必须连续调用两次advance()。

3. 未知字符进了运算符分支,状态机没有ERROR态

现象:输入#或@这些文法语汇之外的字符,程序不报错,反而把它们当成运算符继续分析,最后语法分析器报出莫名其妙的位置。

原因:手写状态机没有兜底分支,未知字符被当成默认Token吞掉了。

解决:在getNextToken()里加一个TOKEN_ERROR类型,遇到不在合法起始字符集合内的字符直接返回错误Token。主程序统计到TOKEN_ERROR或TOKEN_EOF之前出现过错误时,退出码返回非0。这一步能挡住后面90%的排错时间。

4. 减号右结合,a-b-c被解析成a-(b-c)

现象:语法树dump出来,10-3-2的树根是减号,右子树还是减号节点。

原因:parseExpression写成递归调用而不是while循环。递归写法在每次遇到减号时递归调用parseExpression,导致右结合。这是递归下降最常见的翻车点。

解决:按上面第3章的写法,parseExpression里用while循环收集+和-,每次把已有节点挂到新op节点的left。验证方法很简单:打印(10-3-2)的AST,如果根节点的right不是减号节点,说明左结合正确。从那以后我每次改完Parser都先跑一遍减法用例。

5. lookahead预读的Token在报错时被吞掉

现象:错误信息指向的位置和真实错误隔了一行,或者报错时已经跳过一个关键Token。

原因:match失败时先advance()再抛错误,预读的Token被消费掉了;错误处理里又尝试回退,但Lexer没有提供pushback能力,只能干瞪眼。

解决:match里的逻辑一定是先检查后推进。错误信息用当前lookahead的行列号拼出来,不要再动游标。如果做了错误恢复跳过,确保跳过的逻辑不会反复消费同一个Token。还有个小细节,Windows下双击运行exe报缺少VCRUNTIME140.dll时,别急着改代码,这是没装VC++ Redistributable运行库,和工程本身没关系。

6. 进阶用法:把语法树输出成四元式,验证分析器正确性

6.1 把AST打印成缩进树

语法分析器跑通后,我还建议做一步:树结构可视化。一个简单的缩进打印就能看出结合性和优先级:

void dumpTree(std::shared_ptr<ASTNode> node, int depth) { if (!node) return; for (int i = 0; i < depth; ++i) std::cout << " "; std::cout << node->type << " " << node->value << std::endl; dumpTree(node->left, depth + 1); dumpTree(node->right, depth + 1); }

对10 + 20 * (3 - 1)执行后,输出能清楚看到根是op +,右子树是op *,而*的右子树是op -。这比任何调试器都直观,也是答辩时讲左结合最有力的证据。

6.2 从AST生成四元式

更进阶的验证方式是生成四元式,因为它要求你正确遍历二叉树并分配临时变量。叶子节点直接返回自己的值,op节点先递归左右子树,再把结果拼成一条四元式:

// 返回该子树结果的临时变量名 std::string genIR(std::shared_ptr<ASTNode> node, int& temp) { if (!node) return ""; if (node->type == "num" || node->type == "id") { return node->value; } std::string left = genIR(node->left, temp); std::string right = genIR(node->right, temp); std::string t = "t" + std::to_string(temp++); std::cout << t << " = " << left << " " << node->value << " " << right << std::endl; return t; }

temp就是临时变量计数器,每生成一条四元式就自增一次。我习惯把四元式输出和人工手算的结果对拍,10 + 20 * (3 - 1)应该得到三条:先算t0 = 3 - 1,再算t1 = 20 * t0,最后t2 = 10 + t1。如果顺序不对,说明AST层级的构建有偏差,往回查文法比查代码更快。从那以后我每次做完词法和语法分析器,都强制自己走一遍“Token流dump → AST缩进树 → 四元式输出”三段对拍,这个习惯帮我挡掉了好几次状态机回归的翻车——每次改动完,哪怕只是加一个关键字,也要全流程重跑一遍。希望帮到你。

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

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询