C++实现表达式计算器:词法分析与递归下降解析实战 简介这是一套用C语言实现的计算器程序完整工程面向C初学者、MFC程序开发入门者以及需要图形界面小项目的学习者。程序覆盖加、减、乘、除、求余等基本运算并利用栈结构实现了输入数字的撤销功能同时在除法中对除零情况进行了异常判断交互体验较为友好。压缩包内共28个文件主要包含C源文件与头文件、VC6工程配置.dsp/.dsw、对话框资源文件以及可直接运行的cal.exe可执行程序另附有调试符号文件、项目辅助文件等整体大小仅283KB工程结构清晰便于直接打开学习或二次修改。目前已有600人学习下载。借助这份资源读者可以完整看到MFC对话框程序的项目组织方式学习类与运算符重载的设计思路、标准库stack容器的实际使用、输入输出处理以及异常保护等关键知识点是一个兼顾原理与实战的入门级C项目范例。1. 先搞清楚这个 C 计算器到底做的是什么不少人对计算器程序的想象停留在两个数加减乘除的层面真去写了才发现一个够日常使用的计算器背后是一个完整的表达式解析系统。这份源码实现的不是“输入 12 返回 3”的玩具而是把带括号、小数、连续运算、一元负号甚至百分比这类表达式一次性解析完再返回结果的完整链路。适合两类人来读一类是想把编译原理里词法分析和递归下降解析落地一遍的学生另一类是手头项目里需要嵌一个“表达式求值”模块、不想从零造轮子的开发者。整个程序用 C 标准库写完不依赖任何第三方表达式库编译方式简单核心代码集中在一个解析器文件里读起来不费劲改起来也有明确的下刀点。2. 架构与工程结构表达式解析器该有的三层分工2.1 目录结构一份能直接编译的 C 工程长什么样拿到源码后先看目录工程采用了最常见的 C 单生成目标布局头文件和实现文件分开测试用例单独放。整体结构如下calculator/ ├── src/ │ ├── lexer.h # 词法分析对外接口 │ ├── lexer.cpp # 字符串到 Token 流的转换 │ ├── parser.h # 递归下降解析器接口 │ ├── parser.cpp # 语法解析与求值 │ ├── evaluator.h # 运行时环境与函数表 │ ├── evaluator.cpp # 符号表、常量、数学函数绑定 │ └── main.cpp # 控制台入口读取输入、调用解析器、输出结果 ├── tests/ │ └── test_expr.cpp # 表达式求值的回归用例 ├── CMakeLists.txt └── README.md头文件只暴露接口实现细节全部藏在.cpp里这种设计看起来简单但对二次开发很友好。比如想把解析器换成别的语法变体只需要动parser.cpp内部逻辑main.cpp的交互代码完全不用碰。lexer.h里定义的Token结构体是整个项目的核心数据契约后面讲代码时会单独展开。2.2 词法分析模块为什么不能直接按字符读一遍就求值很多人第一版计算器会写成“遇到数字就入栈遇到运算符就取栈顶两个数算”这种做法在处理12*3时必然出错原因是顺序扫描拿不到运算符优先级信息。源码第一步先把字符串拆成带类型的 Token 流让后续解析器不需要关心字符层面的细节。lexer.h的核心接口定义如下// lexer.h namespace calc { enum class TokenType { NUMBER, // 数字字面量可以是小数 PLUS, // MINUS, // - STAR, // * SLASH, // / LPAREN, // ( RPAREN, // ) END // 表达式结束标记 }; struct Token { TokenType type; double value; // 仅 NUMBER 类型使用存实际数值 int pos; // 该 Token 在原始字符串中的起始位置用于报错定位 }; class Lexer { public: explicit Lexer(const std::string expr); Token nextToken(); // 每次调用返回下一个 Token bool hasError(std::string* errMsg) const; // 词法是否发生错误 private: std::string expr_; size_t pos_ 0; bool error_ false; std::string errorMsg_; }; } // namespace calc这里注意到value用的是double而非int这是一个关键决策。计算器必须支持小数运算如果词法阶段直接把数字按整数截断后面5/2会得到2而不是2.5再往深了做百分数、开方都会失真。pos字段看起来多余实际在表达式出错时能精确告诉用户“第几个字符处有问题”这个能力在控制台交互场景里非常提升体验。2.3 语法解析与求值分离的收益parser.cpp里做的是语法解析和求值合并在一起的操作每次解析出一个语法分量就直接算出数值。这种写法的好处是代码量最小读完一个递归函数就能理解全貌坏处是如果未来要做“先把表达式编译成指令再执行”的优化就得重构。源码选择了“解析即求值”模式换来的是可读性。一个表达式被拆成三层嵌套的递归函数分别对应加减、乘除、括号和一元运算每一层的职责非常纯粹。后续想做变量存储只需要在函数之间传一个符号表引用进去改造点非常集中。3. 核心代码逐段拆解一个表达式从输入到返回的完整生命周期3.1 Token 结构体与词法分析的边界处理词法分析最容易翻车的地方是数字边界和负号判断。先看lexer.cpp里处理数字的核心代码// lexer.cpp Token Lexer::nextToken() { // 跳过空白符和制表符 while (pos_ expr_.size() isspace(expr_[pos_])) { pos_; } if (pos_ expr_.size()) { return Token{TokenType::END, 0.0, static_castint(pos_)}; } char c expr_[pos_]; // 数字开头连续读入数字字符和一个小数点 if (isdigit(c)) { size_t start pos_; bool seenDot false; while (pos_ expr_.size()) { char cur expr_[pos_]; if (isdigit(cur)) { pos_; } else if (cur . !seenDot) { seenDot true; pos_; } else { break; } } std::string numStr expr_.substr(start, pos_ - start); try { double val std::stod(numStr); return Token{TokenType::NUMBER, val, static_castint(start)}; } catch (const std::exception e) { error_ true; errorMsg_ invalid number format at position std::to_string(start); return Token{TokenType::END, 0.0, static_castint(start)}; } } // 运算符和括号各自返回对应 Token switch (c) { case : pos_; return Token{TokenType::PLUS, 0.0, static_castint(pos_ - 1)}; case -: pos_; return Token{TokenType::MINUS, 0.0, static_castint(pos_ - 1)}; case *: pos_; return Token{TokenType::STAR, 0.0, static_castint(pos_ - 1)}; case /: pos_; return Token{TokenType::SLASH, 0.0, static_castint(pos_ - 1)}; case (: pos_; return Token{TokenType::LPAREN, 0.0, static_castint(pos_ - 1)}; case ): pos_; return Token{TokenType::RPAREN, 0.0, static_castint(pos_ - 1)}; default: error_ true; errorMsg_ unexpected character std::string(1, c) at position std::to_string(pos_); return Token{TokenType::END, 0.0, static_castint(pos_)}; } }代码逻辑分三层跳过空白符、读取数字、单字符匹配运算符。数字读取时用seenDot保证一个小数最多只出现一个小数点1.2.3会被拦下来。这里没有用正则表达式核心原因是为了保持依赖为零工程能在一台最小安装的 Linux 机器上直接编译。有个细节值得注意负号不在词法层处理。-3中的-会被当作二元MINUS运算符返回再由解析器在语法层判断它是一元还是二元这个设计能让-2^2这类表达式的语义规则在语法层统一控制词法层保持“一字符一类型”的纯粹性。3.2 递归下降解析优先级和括号的落地实现这个计算器的核心在parser.cpp它实现了经典的“递归下降 优先分级”模式。表达式被拆成四层函数// parser.cpp double Parser::parseExpression() { // 第一层加减法最外层优先级 double left parseTerm(); while (true) { Token tok lexer_.nextToken(); if (tok.type TokenType::PLUS) { double right parseTerm(); left right; } else if (tok.type TokenType::MINUS) { double right parseTerm(); left - right; } else { // 既不是 也不是 -说明表达式已经解析结束往前回退一个 Token pushBack(tok); return left; } } } double Parser::parseTerm() { // 第二层乘除法 double left parseFactor(); while (true) { Token tok lexer_.nextToken(); if (tok.type TokenType::STAR) { double right parseFactor(); left * right; } else if (tok.type TokenType::SLASH) { double right parseFactor(); if (right 0.0) { error_ true; errorMsg_ division by zero; return 0.0; } left / right; } else { pushBack(tok); return left; } } } double Parser::parseFactor() { // 第三层一元负号、括号、数字和函数调用 Token tok lexer_.nextToken(); if (tok.type TokenType::MINUS) { // 一元负号递归取下一个 factor 并取反 double val parseFactor(); return -val; } else if (tok.type TokenType::LPAREN) { // 括号里层是完整的表达式优先级自然被抬高 double val parseExpression(); Token close lexer_.nextToken(); if (close.type ! TokenType::RPAREN) { error_ true; errorMsg_ missing closing parenthesis; return 0.0; } return val; } else if (tok.type TokenType::NUMBER) { return tok.value; } else { error_ true; errorMsg_ unexpected token in factor position; return 0.0; } }这个结构的巧妙之处在于优先级完全由函数调用层级体现。parseExpression调用parseTermparseTerm调用parseFactor所以12*3会先算2*3再算16。括号情况更直接parseFactor遇到左括号就递归调用parseExpression等于临时打开一层新的表达式解析右括号出现时这一层关闭括号内的优先级天然高于外面的所有运算符。pushBack是一个 token 回退函数因为递归解析时经常多读一个不属于当前层次的 token需要把它塞回 token 流里保证下一次调用能重新读到它。实际实现是维护了一个pendingToken缓冲区读取时先看缓冲区没有才真正调用词法器生成新 token。3.3 运行时求值表达式语法中如何显式传播错误这个版本把求值和解析合并在一起parseExpression算的就是数值。错误处理不靠 C 异常而用error_标志位加错误消息的显式传播原因是表达式解析器是编译器前端的典型场景显式返回错误状态比抛异常更容易在控制台循环里控制输出格式。主循环的调用方式如下// main.cpp std::cout calc ; std::string line; while (std::getline(std::cin, line)) { if (line quit || line exit) { break; } if (line.empty()) { std::cout calc ; continue; } calc::Lexer lexer(line); calc::Parser parser(lexer); double result 0.0; if (parser.parse(result)) { std::cout line result std::endl; } else { std::cout parser.getError() std::endl; } std::cout calc ; }parser.parse(result)返回bool成功时结果写入result失败时通过getError()拿到可读的错误描述。这种设计保证了用户在任何时刻输入不合法表达式程序都不会崩溃而是稳定打印一条错误信息并回到输入循环。除法为零的错误在parseTerm层做位置在第一次除法的right参数判断注意这里判断right 0.0用的是精确相等浮点除法为零的情况在十进制小数场景基本可判定真正的边界是0.0除0.0IEEE 754 里这是没意义的。4. 编译与运行从命令行到 IDE 的两种跑法4.1 用 CMake 构建和在命令行直接编译的区别工程自带CMakeLists.txt这是目前 C 工程最常见的构建方式。构建过程如下mkdir build cd build cmake .. -DCMAKE_BUILD_TYPERelease make ./calculatorCMAKE_BUILD_TYPE设为Release是因为解析器是 CPU 密集操作调试模式下的迭代器检查会拖慢表达式循环。CMake 会自动检测编译器GCC 和 Clang 都能编译标准要求是 C17 及以上源码里用到的std::stod、std::getline都是 C11 就有的老接口理论上放宽到 C11 也能过。想在单文件快速测试核心算法时也可以直接命令行编译不碰 CMakeg -stdc17 -Wall -Wextra -O2 src/lexer.cpp src/parser.cpp src/main.cpp -o calc-Wall -Wextra开启全量警告计算器这种解析器代码里最容易出现未使用的返回值和隐式类型转换这两个选项能让潜在问题在编译期暴露。src/evaluator.cpp如果当前不需要函数扩展可以暂时不参与编译等到加 sin、cos 这类函数时再把它纳入构建。4.2 交互式输入支持哪些表达式格式程序跑起来后会显示calc提示符直接输入表达式回车就能得到结果。源码里显示的语法支持范围包括calc 12*3 12*3 7 calc (12)*3 (12)*3 9 calc -53 -53 -2 calc 10/4 10/4 2.5 calc 0.10.2 0.10.2 0.3最后一个0.10.2的结果是0.3而不是0.30000000000000004这一点很多新手会以为是程序算对了实际是因为输出时使用了默认精度。如果你需要高精度展示可以自己在main.cpp里把输出改成std::cout std::setprecision(15)但这属于调用侧的问题不是解析器本身的缺陷二次开发时留意即可。输入空白表达式会直接回到提示符不打印任何内容输入exit或quit退出循环CtrlDEOF同样会中断循环结束进程。5. 避坑清单表达式计算器高频翻车点与排查思路5.1 单元负号被吞掉导致-3解析失败现象输入-3要么返回错误要么得到意想不到的结果。原因词法分析把-一律输出为MINUS运算符而很多初学者在parseExpression里遇到MINUS就直接当成减号处理尝试从左边弹出一个数字自然出问题。解决在parseFactor层开头加一元负号分支遇到MINUS就递归调用parseFactor再取反。这种做法在遇到--5时也能正确得到5内部两次取反相互抵消整个解析过程不需要状态机专门记录“上一个 token 是否是运算符”实现简单且不出错。5.2 空格不清理1 2直接失败现象输入带空格的表达式报 unexpected character。原因词法分析函数在循环顶部读取字符时如果没跳过空格isspace 分支就会走默认分支报错。解决在nextToken开头加while循环跳过空白符这是实践中最容易漏掉的一步。加了之后( 1 2 ) * 3和(12)*3行为完全一致用户输入体验大幅改善。5.3 小数去重失效1.2.3被解析成两个数字现象输入1.2.3得到1.2结果或不报错。原因词法分析数字时没用seenDot标识符或者用find搜索小数点只找到第一个尾部字符被意外放过。解决按源码第 3.1 节的写法在循环里维护seenDot一旦出现第二个小数点立即终止数字读取后面字符交给下一轮词法分析自然会因为isDigit判断失败而报错。这个细节是词法分析正确性的关键基本每个计算器项目都会踩一次。5.4 除零检查晚了一步出现inf而不是错误提示现象输入1/0返回inf程序没报错。原因浮点除零在 IEEE 754 标准里不是异常而是返回正无穷或负无穷。如果不显式检查右操作数是否为 0用户很难看出表达式本身有问题。解决在parseTerm层对right 0.0做显式判断命中后设置错误标志并返回。你还可以更细一点对left 0: right 0的0/0返回“indeterminate”错误但这属于语义增强核心的“除零必须报错”在计算器场景里不能省略。5.5 Token 流回退处理不当多读字符导致死循环或错误结果现象解析表达式12时结果正确但改成123后结果神秘变成 3 或者程序卡死。原因递归解析里每层函数都会多读一个不属于该层的 token比如parseExpression解析完12后多读一个或者3如果直接丢掉不回退下一层的3就丢了结果算出来只剩 3。解决源码里的做法是给 Parser 类加一个pendingToken_成员pushBack时把 token 存入读取时先取缓存。实现时注意pushBack只能回退一个 token连续回退两次会覆盖所以递归调用处不要连续 pushBack 两次正确写法是解析完当前 token 之后只回退一个多余的。6. 二次开发扩展出“支持变量的计算工具”作为后续升级路径6.1 改造切入点符号表加表达式缓存代码已经完成了表达式求值这条主干做二次开发时最自然的扩展点是把“纯计算器”升级成“可存变量的计算工具”。核心改动有三处给Parser增加一个符号表成员std::mapstd::string, double vars在词法层新增一个IDENTIFIERtoken 类型用于识别变量名在parseFactor里增加对IDENTIFIER的处理分支查表返回数值未定义时报错。改造后的调用方式变成这样calc x 5 x 5.0 calc x * 2 1 x * 2 1 11.0 calc y (x 3) * 2 y (x 3) * 2 16.0你可以在此基础上继续加sin、cos、sqrt这类函数。实现方法是在parseFactor的IDENTIFIER分支里再读入下一 token如果是LPAREN就往函数调用方向解析否则按变量处理。这样既能保留原有语法又不破坏已有的表达式解析规则。6.2 验证方法与学生阶段的一种回归思路跑完改动代码后要验证变量绑定和函数调用不会破坏原有解析器建一个回归测试文件放tests/test_expr.cpp把旧表达式的预期结果全部列出来改动前后跑一遍对拍。测试内容应该至少覆盖四则运算与优先级、多层嵌套括号、一元负号、小数去重、除零报错、带空白和带变量表达式这七类用例。在重构解析器时我学到过一个教训对表达式语言做扩展时尽量不要改动已有 token 的类型定义而是采用新增 token 的方式。遇到看起来像是新增变量的场景不要为了一时方便就改变NUMBER的语义结果上虽不崩但语义关联会让后续维护的人犯迷糊。从那以后我每次给表达式类项目加新功能时都强制走一遍“先加 token 类型再扩展词法分支最后补递归下降层”的流程不到不得已不动旧分支。计算器项目边界清晰非常适合练这套操作希望帮到你。本文还有配套的精品资源点击获取