ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

编译原理实战:C++手写词法分析器与语法分析器

编译原理实战:C++手写词法分析器与语法分析器 简介这份资源是面向计算机专业学生与编译器爱好者的编译原理前端实践项目用C实现词法分析器与语法分析器帮助读者把有限自动机、上下文无关文法、抽象语法树等理论落到可运行代码上。压缩包共9个文件约937KB包含2个cpp源码、2个可执行程序、4个txt说明与1个md文档分别对应词法分析、语法分析实现及文法、token表、源程序等配套材料便于对照阅读与直接运行验证。资源围绕词法规则定义、词法单元序列生成、非法字符与数字格式错误处理以及递归下降或LL(1)解析、语法错误与类型错误处理等核心任务展开并附有文法文件与说明文档方便读者理解设计决策与测试思路。目前已有736人学习下载适合作为课程实验、编译器入门练手或进一步学习程序分析与代码生成的起点。1. 从一份 .zip 说起词法分析器和语法分析器到底在做什么很多人第一次打开“编译原理词法分析器和语法分析器的实现C.zip”这类压缩包时心里想的其实是同一件事这玩意儿能不能跑起来跑起来之后我能不能看懂它到底在干嘛。我当年做编译原理实验也是这样老师丢下一句“实现词法分析器和语法分析器”剩下的全靠自己啃。真正动手之后才发现词法分析器负责把一串字符切成有意义的记号语法分析器负责判断这些记号能不能拼成一棵合法的语法树。这两个东西合起来就是编译器前端最核心的两块骨头。这份标题对应的场景非常具体你手里有一个 C 工程里面大概率包含词法分析模块、语法分析模块、测试用例和构建脚本。它解决的问题是——把一段类 C 语言或自定义语言的源代码先转成 token 流再根据文法规则构建抽象语法树。适合谁正在上编译原理课、需要交实验报告的学生以及想通过手写一遍来真正理解编译器前端原理的 C 学习者。如果你只想调库那这不是你的菜但如果你想搞明白“为什么我的语法分析器一遇到嵌套括号就崩”那这份东西值得你花时间。2. 词法分析器从字符流到 token 流的最小实现2.1 为什么手写词法分析器比用正则库更值得很多同学第一反应是用std::regex或者手写一堆if-else来切 token。能跑但一旦语言规则稍微复杂一点比如要支持浮点数、科学计数法、注释、字符串转义正则的维护成本会指数级上升。手写词法分析器的核心优势在于状态可控、错误定位精确、性能可预测。常见做法是维护一个位置指针每次从当前字符出发根据首字符决定进入哪个分支然后一直吃到不满足条件为止。我一般会把 token 定义成一个结构体包含类型、原始文本、行号和列号。行号和列号在报错时非常关键没有它们语法分析器报错时你只能告诉用户“第 3 个 token 有问题”而不是“第 5 行第 12 列缺少分号”。// token.h #pragma once #include string enum class TokenType { IDENTIFIER, // 标识符 NUMBER, // 数字字面量 STRING, // 字符串字面量 OPERATOR, // 运算符 PUNCTUATION, // 标点符号 KEYWORD, // 关键字 END_OF_FILE, // 文件结束 UNKNOWN // 未知字符 }; struct Token { TokenType type; std::string lexeme; // 原始文本 int line; // 行号从 1 开始 int column; // 列号从 1 开始 };这段代码定义了一个最小可用的 token 结构。lexeme保存原始文本方便后续语法分析器直接取用line和column用于错误报告。参数上没有什么可调的但要注意TokenType的顺序不影响逻辑但建议把END_OF_FILE放在UNKNOWN前面方便遍历时判断终止条件。2.2 用状态机扫描标识符、数字和运算符词法分析器的核心是一个循环每次跳过空白字符然后根据当前字符决定走哪条路。标识符以字母或下划线开头后面跟字母、数字或下划线数字以数字开头可能包含小数点或指数部分运算符和标点通常是一到两个字符比如、!、、、、||。// lexer.cpp #include token.h #include cctype #include stdexcept class Lexer { public: Lexer(const std::string source) : src(source), pos(0), line(1), col(1) {} Token nextToken() { skipWhitespaceAndComments(); if (pos src.size()) { return {TokenType::END_OF_FILE, , line, col}; } char c src[pos]; if (std::isalpha(c) || c _) { return scanIdentifierOrKeyword(); } if (std::isdigit(c)) { return scanNumber(); } if (c ) { return scanString(); } return scanOperatorOrPunctuation(); } private: std::string src; size_t pos; int line; int col; void skipWhitespaceAndComments() { while (pos src.size()) { char c src[pos]; if (c || c \t || c \r) { advance(); } else if (c \n) { line; col 1; pos; } else if (c / pos 1 src.size() src[pos 1] /) { // 单行注释跳到行尾 while (pos src.size() src[pos] ! \n) advance(); } else if (c / pos 1 src.size() src[pos 1] *) { // 块注释跳到 */ advance(); advance(); while (pos 1 src.size() !(src[pos] * src[pos 1] /)) { if (src[pos] \n) { line; col 1; } advance(); } if (pos 1 src.size()) { advance(); advance(); } } else { break; } } } void advance() { pos; col; } Token scanIdentifierOrKeyword() { int startLine line, startCol col; std::string text; while (pos src.size() (std::isalnum(src[pos]) || src[pos] _)) { text src[pos]; advance(); } // 简单关键字表实际项目可以扩展 if (text int || text float || text if || text else || text while || text return) { return {TokenType::KEYWORD, text, startLine, startCol}; } return {TokenType::IDENTIFIER, text, startLine, startCol}; } Token scanNumber() { int startLine line, startCol col; std::string text; bool hasDot false; while (pos src.size()) { char c src[pos]; if (std::isdigit(c)) { text c; advance(); } else if (c . !hasDot) { hasDot true; text c; advance(); } else { break; } } return {TokenType::NUMBER, text, startLine, startCol}; } Token scanString() { int startLine line, startCol col; std::string text; advance(); // 跳过开头的引号 while (pos src.size() src[pos] ! ) { if (src[pos] \\ pos 1 src.size()) { text src[pos]; advance(); } text src[pos]; advance(); } if (pos src.size()) advance(); // 跳过结尾引号 return {TokenType::STRING, text, startLine, startCol}; } Token scanOperatorOrPunctuation() { int startLine line, startCol col; char c src[pos]; // 双字符运算符优先 if (pos 1 src.size()) { std::string two {c, src[pos 1]}; if (two || two ! || two || two || two || two ||) { advance(); advance(); return {TokenType::OPERATOR, two, startLine, startCol}; } } advance(); if (std::string(-*/%!|).find(c) ! std::string::npos) { return {TokenType::OPERATOR, std::string(1, c), startLine, startCol}; } return {TokenType::PUNCTUATION, std::string(1, c), startLine, startCol}; } };这段代码的逻辑很直白nextToken是入口先跳过空白和注释然后根据首字符分派到不同的扫描函数。skipWhitespaceAndComments处理了单行和块注释注意块注释里遇到换行要更新行号。scanIdentifierOrKeyword里内置了一个极简关键字表实际项目里建议把关键字单独放在一个unordered_set里避免每次比较都写一长串||。scanNumber目前只支持整数和简单小数如果要支持科学计数法需要在遇到e或E时继续吃字符。scanString处理了反斜杠转义但注意它把转义字符原样保留在lexeme里后续如果需要解释字符串内容还得再处理一遍。参数方面唯一需要留意的是line和col的更新时机。我在advance里只增加列号换行时手动重置列号并增加行号。这种写法容易在块注释里漏掉换行所以块注释循环里单独判断了\n。如果你发现报错行号总是差一行八成就是这里漏了。3. 语法分析器递归下降构建 AST 的完整路径3.1 递归下降为什么适合手写第一个语法分析器语法分析器的主流实现方式有递归下降、LL(1) 表驱动、LR 系列等。对于一份课程实验级别的 C 工程递归下降是最务实的选择代码结构和文法规则几乎一一对应调试时能直接跟到具体函数出错信息也容易定位。LL(1) 需要构造预测分析表LR 需要构造状态机代码量更大而且一旦文法有左递归还得先消除。递归下降的缺点是不适合处理左递归文法但你可以通过改写文法来规避比如把expr - expr term改成expr - term expr。我一般会先定义 AST 节点再写解析函数。AST 节点用基类指针加std::unique_ptr管理避免手动delete。每个非终结符对应一个解析函数函数内部按文法规则依次调用其他解析函数或匹配 token。3.2 定义 AST 节点和解析器骨架// ast.h #pragma once #include memory #include string #include vector struct ASTNode { virtual ~ASTNode() default; }; struct NumberNode : ASTNode { std::string value; explicit NumberNode(const std::string v) : value(v) {} }; struct IdentifierNode : ASTNode { std::string name; explicit IdentifierNode(const std::string n) : name(n) {} }; struct BinaryOpNode : ASTNode { std::string op; std::unique_ptrASTNode left; std::unique_ptrASTNode right; BinaryOpNode(const std::string o, std::unique_ptrASTNode l, std::unique_ptrASTNode r) : op(o), left(std::move(l)), right(std::move(r)) {} }; struct AssignmentNode : ASTNode { std::string name; std::unique_ptrASTNode value; AssignmentNode(const std::string n, std::unique_ptrASTNode v) : name(n), value(std::move(v)) {} };这里定义了四种最基础的节点数字、标识符、二元运算和赋值。BinaryOpNode用unique_ptr持有左右子树所有权清晰。如果你要支持语句块、if 语句、while 循环可以继续加BlockNode、IfNode、WhileNode结构类似。// parser.cpp #include ast.h #include token.h #include stdexcept #include iostream class Parser { public: Parser(Lexer lexer) : lexer(lexer) { current lexer.nextToken(); } std::unique_ptrASTNode parse() { return parseAssignment(); } private: Lexer lexer; Token current; void advance() { current lexer.nextToken(); } void expect(TokenType type, const std::string msg) { if (current.type ! type) { throw std::runtime_error(Line std::to_string(current.line) , Col std::to_string(current.column) : msg); } advance(); } std::unique_ptrASTNode parseAssignment() { if (current.type TokenType::IDENTIFIER) { std::string name current.lexeme; advance(); if (current.type TokenType::OPERATOR current.lexeme ) { advance(); auto value parseExpression(); return std::make_uniqueAssignmentNode(name, std::move(value)); } // 不是赋值回退到表达式解析 // 这里简化处理把标识符当作表达式重新解析 // 实际项目建议用回溯或预读两个 token throw std::runtime_error(Expected after identifier); } return parseExpression(); } std::unique_ptrASTNode parseExpression() { auto left parseTerm(); while (current.type TokenType::OPERATOR (current.lexeme || current.lexeme -)) { std::string op current.lexeme; advance(); auto right parseTerm(); left std::make_uniqueBinaryOpNode(op, std::move(left), std::move(right)); } return left; } std::unique_ptrASTNode parseTerm() { auto left parseFactor(); while (current.type TokenType::OPERATOR (current.lexeme * || current.lexeme /)) { std::string op current.lexeme; advance(); auto right parseFactor(); left std::make_uniqueBinaryOpNode(op, std::move(left), std::move(right)); } return left; } std::unique_ptrASTNode parseFactor() { if (current.type TokenType::NUMBER) { auto node std::make_uniqueNumberNode(current.lexeme); advance(); return node; } if (current.type TokenType::IDENTIFIER) { auto node std::make_uniqueIdentifierNode(current.lexeme); advance(); return node; } if (current.type TokenType::PUNCTUATION current.lexeme () { advance(); auto node parseExpression(); expect(TokenType::PUNCTUATION, Expected )); return node; } throw std::runtime_error(Line std::to_string(current.line) , Col std::to_string(current.column) : Unexpected token current.lexeme ); } };这段解析器代码实现了赋值语句和四则运算表达式。parseAssignment先看当前 token 是不是标识符如果是且下一个是就构造赋值节点否则抛异常。这里有个简化没有做回溯所以如果标识符后面不是直接报错。实际项目里可以用一个peek函数预读下一个 token或者把赋值语句的文法改写成statement - IDENTIFIER expression | expression然后在解析时先保存标识符再根据下一个 token 决定走哪条路。parseExpression和parseTerm分别处理加减和乘除优先级通过函数调用层次体现parseExpression调用parseTermparseTerm调用parseFactor。parseFactor处理数字、标识符和括号表达式。括号表达式递归调用parseExpression自然支持嵌套。参数方面expect函数里的错误信息包含了行号和列号这是从 token 里带过来的。如果你发现报错位置不对先检查词法分析器的line和col更新逻辑。另外parseAssignment里对标识符后面不是的情况直接抛异常这在只支持赋值语句的极简语言里没问题但如果你要支持表达式语句就得改。4. 避坑与排查词法语法分析器最容易翻车的 5 个地方4.1 现象数字后面跟字母词法分析器把整个串当成标识符原因scanIdentifierOrKeyword在首字符是字母时才进入但如果首字符是数字scanNumber只吃数字和小数点遇到字母就停。可如果输入是123abcscanNumber返回123然后下一次nextToken从a开始又当成标识符。这本身不算错但如果你期望123abc报错就得在scanNumber结束后检查下一个字符是不是字母或下划线如果是就抛异常。解决在scanNumber返回前加一个判断if (pos src.size() (std::isalpha(src[pos]) || src[pos] _)) { throw std::runtime_error(Line std::to_string(startLine) : Invalid number literal); }4.2 现象块注释没闭合词法分析器直接读到文件尾原因skipWhitespaceAndComments里块注释循环条件是pos 1 src.size()如果文件末尾没有*/循环会一直走到pos 1 src.size()然后退出但不会报错。结果就是后面的 token 全部丢失语法分析器报一个莫名其妙的“意外结束”。解决在块注释循环结束后检查是否真的遇到了*/。如果pos 1 src.size()且没有匹配到抛异常“Unterminated block comment”。4.3 现象语法分析器遇到a b c时只解析了a b剩下的 c被忽略原因parseAssignment在解析完a b后返回没有检查是否还有剩余 token。如果parse()函数直接返回而不检查current.type END_OF_FILE多余的 token 就被静默丢弃。解决在parse()返回前加检查auto node parseAssignment(); if (current.type ! TokenType::END_OF_FILE) { throw std::runtime_error(Line std::to_string(current.line) : Unexpected token after expression); } return node;4.4 现象括号嵌套三层以上解析结果不对原因递归下降解析括号时parseFactor遇到(会递归调用parseExpression这本身没问题。但如果parseExpression里的while循环条件写错比如把和-的判断写成了就会导致括号内的表达式提前结束。解决仔细核对每个while循环里的运算符集合。建议把运算符集合定义成const std::unordered_setstd::string避免手写字符串比较时漏掉或写错。4.5 现象报错行号总是比实际行号大 1 或小 1原因行号更新时机不一致。有的地方在advance里统一处理换行有的地方手动line但忘了重置col。块注释里遇到换行如果只advance不更新行号就会导致后续所有 token 的行号偏移。解决把所有换行处理集中到一个函数里比如newline()里面统一做line; col 1; pos;。任何需要跳过换行的地方都调用这个函数不要手动改line和col。5. 进阶技巧用 AST 遍历做常量折叠和错误恢复5.1 常量折叠在解析阶段就把2 3算成5常量折叠是编译器前端一个很实用的优化。你可以在 AST 构建完成后写一个递归的fold函数遇到BinaryOpNode且左右子树都是NumberNode时直接计算结果并替换成新的NumberNode。这样后续如果要做代码生成就不用再处理这些常量表达式。std::unique_ptrASTNode fold(std::unique_ptrASTNode node) { auto bin dynamic_castBinaryOpNode*(node.get()); if (!bin) return node; bin-left fold(std::move(bin-left)); bin-right fold(std::move(bin-right)); auto leftNum dynamic_castNumberNode*(bin-left.get()); auto rightNum dynamic_castNumberNode*(bin-right.get()); if (leftNum rightNum) { double l std::stod(leftNum-value); double r std::stod(rightNum-value); double result 0; if (bin-op ) result l r; else if (bin-op -) result l - r; else if (bin-op *) result l * r; else if (bin-op /) result r ! 0 ? l / r : 0; return std::make_uniqueNumberNode(std::to_string(result)); } return node; }这个函数先递归折叠左右子树然后判断是否都是数字节点。如果是就按运算符计算结果返回新的数字节点。注意除法要处理除零这里简单返回 0实际项目应该报错或保留原表达式。std::to_string对浮点数会输出很多位小数如果在意格式可以用std::ostringstream控制精度。5.2 错误恢复让语法分析器一次报多个错默认情况下递归下降解析器遇到第一个错误就抛异常退出。但实际使用中用户希望一次看到所有错误。一个简单做法是在parseFactor等函数里捕获异常记录错误信息然后跳过当前 token 继续解析。更系统的做法是定义ParseError结构在解析循环里收集错误最后统一输出。我一般会在Parser里加一个std::vectorstd::string errors在expect失败时不抛异常而是记录错误并尝试同步到下一个分号或右括号。同步策略可以是一直advance直到遇到;、}或END_OF_FILE。这样虽然可能产生一些误报但至少能让用户看到大部分问题。5.3 验证方法用测试用例覆盖边界情况写完词法分析器和语法分析器后别急着交。先准备一组测试用例覆盖以下情况测试类型输入示例期望结果空输入词法返回 EOF语法报“意外结束”纯数字42解析为 NumberNode简单赋值x 10AssignmentNode嵌套括号(1 (2 * 3))正确的 AST 结构未闭合字符串abc词法报错未闭合块注释/* abc词法报错多余 tokenx 1 y 2语法报“意外 token”除零x 1 / 0常量折叠时处理把这些用例写成main函数里的循环每次解析后打印 AST 或错误信息。如果 AST 打印不方便至少打印 token 流和错误行号。我自己的习惯是每改一次词法或语法规则先把这组用例跑一遍确认没有回归。血泪经验是很多 bug 不是逻辑写错而是边界情况没考虑到比如文件末尾没有换行、字符串里包含转义引号、注释嵌套等。最后说一个我自己的教训不要等到全部写完再测试。词法分析器写完就单独测 token 流语法分析器写完就用手工构造的 token 序列测解析函数。分阶段验证比最后一起调试快得多。希望帮到你。本文还有配套的精品资源点击获取
返回列表