C++计算器核心:从表达式解析到递归下降的完整工程实践 简介这份C计算器工程是一份完整可运行的源码项目适合C初学者、期末课程设计者以及希望了解面向对象项目组织的开发者。程序实现了加、减、乘、除四则运算并支持撤销输入数字的交互功能同时针对除零和非法输入做了必要防护。压缩包共28个文件大小约283KB其中包含4个.h头文件、3个.cpp源文件构成核心类与主程序另有txt说明文档、可执行的exe程序以及Visual C工程辅助文件便于直接编译运行和对照学习。目前已有600人学习下载。通过阅读和运行本资源读者可以掌握Calculator类的设计、运算符重载、利用STL栈实现撤销操作、double精度处理、输入验证与异常处理等关键知识点不仅是C语法的综合练习也是构建友好交互式桌面程序的实用样板。1. C计算器程序核心不在按钮而在那套表达式解析逻辑很多开源项目看着复杂真正动手拆一遍才发现最难啃的往往不是界面而是背后处理输入的逻辑。这个用标准C写成的计算器程序就是一个典型它实现了计算器的全部功能包括加减乘除、幂运算、括号嵌套、三角函数、对数、Ans记忆、M存储器、历史记录但它的代码重心全放在了“怎么把用户敲下的一串表达式变成正确结果”上。对正在学C、手里缺一个有实战感的练手工程的开发者来说这份代码比单纯刷语法题有价值得多——它用到了栈、递归、状态机、异常处理几乎把C核心语法串了一遍。整个项目不依赖第三方库CMake一条命令就能构建拉下来就能动手跑跑完还能继续往上加功能。2. 整体架构表达式引擎与界面层分离四个目录一个入口2.1 先看目录结构和文件角色很多计算器Demo会把界面代码和计算逻辑写在一起按钮点击事件里直接拼字符串、直接算结果代码短的时候没问题一旦要加函数、括号、历史记录主文件直接变成一千行的“屎山”。这个项目一开始就按工程化的思路拆分目录结构大致像这样calc/ CMakeLists.txt src/ main.cpp tokenizer.h tokenizer.cpp parser.h parser.cpp engine.h engine.cpp history.h history.cpp tests/ test_expressions.cppmain.cpp 是唯一入口负责读取用户输入、调用引擎、输出结果tokenizer 负责把字符串拆成最小单位 Tokenparser 负责按语法规则把 Token 组织成可计算的结构engine 是门面对外只暴露一个double calculate(const std::string expr)把 tokenizer 和 parser 串起来history 负责把计算记录写入文件。这样的分层保证了“算数”和“显示”互不干扰以后哪怕把命令行界面换成窗口界面引擎部分也可以原封不动搬过去。2.2 核心数据结构Token 和 AST接下来要理解计算器里最核心的两个抽象Token 和 AST。Token 是“最小的语义块”比如123、、sin、(你把它们从字符串里一个个抠出来去掉空格再标注好类型这一步叫词法分析。AST抽象语法树是把这些 Token 按照运算优先级组织起来的一棵树1 2 * 3对应的树根是左孩子是1右孩子是一个乘法节点。有了树求值就变成一次递归遍历。我拆源码时特别注意了它的数据结构设计Token 用枚举类型区分“数字、运算符、括号、函数名”AST 节点则用std::variant保存要么是数字常量、要么是二元运算、要么是函数调用这样既不滥用继承又好调试。这里有一个常见的替代做法直接用运算符栈把中缀表达式转成逆波兰表达式再求值。那个方案代码更短但调试起来不直观出错时你很难告诉用户到底是哪一步不对。用 AST 的好处是计算器以后要支持“显示计算过程”“保存表达式树”这类高级功能时数据结构天然支持不需要再重构。2.3 求值主流程一条输入从按键到结果的路用户在界面上输入((12)*3)^2 sin(30)这串字符在项目里经历的完整路径是// engine.cpp 中简化的外部接口 double engine::calculate(const std::string expr) { try { auto tokens tokenizer::tokenize(expr); parser p(std::move(tokens)); double result p.parse(); // 对浮点结果做规范化避免 1.2000000000000002 这种输出 return math_utils::normalize(result); } catch (const calculator_error e) { // 把底层异常包装成用户可读的提示 throw std::runtime_error(e.what()); } }调用方main.cpp只看见engine::calculate一个函数输入字符串输出double。这层封装是很多半成品项目没有的它们的计算逻辑散落在一堆按钮事件里测试没法写。而这个项目把计算入口收敛成一个纯函数后面加自动化测试就非常顺手tests/test_expressions.cpp里直接断言calculate(12) 3这种用例就行。从这段代码能看出计算器工程的架构观通常是底层拆得足够碎对外接口足够瘦。3. 表达式求值词法分析和递归下降解析逐行拆3.1 词法分析器如何把字符串切成 Token词法分析是容易出错的一步。看这段核心实现// tokenizer.cpp 简化核心 std::vectorToken tokenizer::tokenize(const std::string expr) { std::vectorToken result; size_t i 0; while (i expr.size()) { char c expr[i]; if (std::isspace(c)) { i; continue; } if (std::isdigit(c) || c .) { std::string num; while (i expr.size() (std::isdigit(expr[i]) || expr[i] . || expr[i] e || expr[i] E)) { // 处理 3.14、2e-3 这类字面量 if ((expr[i] e || expr[i] E) i 1 expr.size() (expr[i 1] - || expr[i 1] )) { num expr[i]; num expr[i]; } else { num expr[i]; } } result.push_back({TokenType::Number, num, std::stod(num)}); continue; } // 识别 sin、cos、log 等函数名 if (std::isalpha(c)) { std::string name; while (i expr.size() std::isalpha(expr[i])) name expr[i]; result.push_back({TokenType::Function, name, 0.0}); continue; } // 运算符和括号直接映射 if (c || c - || c * || c / || c ^ || c %) { result.push_back({TokenType::Operator, std::string(1, c), 0.0}); i; continue; } if (c () { result.push_back({TokenType::LeftParen, (, 0.0}); i; continue; } if (c )) { result.push_back({TokenType::RightParen, ), 0.0}); i; continue; } throw calculator_error(无法识别的字符: std::string(1, c)); } result.push_back({TokenType::End, , 0.0}); return result; }这段代码在跑的时候有三个点值得留意。第一识别数字时把.也纳入数字字符所以能正确切出3.14同时处理了科学计数法2e-3单个字符识别会导致2e-3被切开所以代码里单独做了分支。第二函数名sin、cos等是用std::isalpha连续读字母直到遇到非字母为止这样sin30会被读成sin和30用户不加括号也能算。第三所有未知字符都抛出异常这个异常会被 engine 层捕获转成一句人话提示而不是让程序崩溃。如果你上手改代码最值得扩展的就是这里想支持阶乘!就在运算符分支里加一个!的映射。3.2 递归下降解析器让优先级不再靠猜Token 切好之后真正决定运算顺序的是 parser。这个项目用的是递归下降解析核心思路是每一层运算优先级对应一个函数高优先级的函数会被低优先级的函数调用。// parser.cpp 简化核心 double parser::parseExpression() { double value parseTerm(); while (peek().text || peek().text -) { Token op consume(); double rhs parseTerm(); value (op.text ) ? value rhs : value - rhs; } return value; } double parser::parseTerm() { double value parseFactor(); while (peek().text * || peek().text / || peek().text %) { Token op consume(); double rhs parseFactor(); if ((op.text / || op.text %) rhs 0.0) { throw calculator_error(除数不能为零); } value (op.text *) ? value * rhs : (op.text /) ? value / rhs : std::fmod(value, rhs); } return value; } double parser::parseFactor() { if (peek().text || peek().text -) { // 处理 -3 或 -(-3) bool neg (consume().text -); double val parseFactor(); return neg ? -val : val; } if (peek().text ^) { // 右结合幂运算 consume(); double base parseFactor(); double exp parseFactor(); return std::pow(base, exp); } if (peek().text () { consume(); double val parseExpression(); expect()); return val; } if (peek().type TokenType::Function) { std::string name consume().text; expect((); double arg parseExpression(); expect()); return applyFunction(name, arg); } if (peek().type TokenType::Number) return consume().value; throw calculator_error(意外的语法结构); }这段代码把优先级分层表达得很清楚parseExpression处理加减是所有层级最高的“收口”函数它内部调用parseTerm去处理乘除、取模parseTerm再调用parseFactor处理一元负号、括号、函数调用和高优先级的幂运算。单项式的循环while结构是关键parseTerm里的while让12*3*4能连续算完两个乘法而不会只算第一个就返回。另一个细节是取模和除法在rhs 0.0处统一抛出异常比让double变成inf再显示给用户要友好得多。3.3 处理和函数调用的扩展点函数调用这块项目里用的是一张表驱动的分发方式// engine.cpp 内置函数表 double engine::applyFunction(const std::string name, double arg) { struct Func { const char* name; double(*fn)(double); }; static const Func funcs[] { {sin, [](double x) { return std::sin(x); }}, {cos, [](double x) { return std::cos(x); }}, {tan, [](double x) { return std::tan(x); }}, {sqrt, [](double x) { return std::sqrt(x); }}, {log, [](double x) { return std::log10(x); }}, {ln, [](double x) { return std::log(x); }}, {abs, [](double x) { return std::fabs(x); }}, }; for (auto f : funcs) { if (name f.name) return f.fn(arg); } throw calculator_error(未知函数: name); }这张表的好处是加一个新函数不用改 parser只要在funcs数组里加一行。项目里还内置了pi和e两个常量在parsePrimary阶段检测到标识符为pi就返回3.141592653589793是e就返回2.718281828459045。如果你之后想支持asin、acos改这一张表就够了不用再碰语法分析这算是项目扩展性做得比较聪明的地方。4. 把计算器跑起来命令行交互、历史记录与自动化测试4.1 编译运行CMake 一条命令到底这份工程不依赖第三方库也不需要安装额外运行时只要有 C17 编译器和 CMake 3.10 以上版本就能构建。在项目根目录执行cmake -S . -B build cmake --build build ./build/calc第一条命令是让 CMake 读取CMakeLists.txt生成构建系统第二条命令编译出可执行文件第三条运行程序本身可执行文件叫calc。如果是 Windows 平台第二条命令生成的产物在build/Release/calc.exe直接双击运行或在命令行里切到对应目录再跑。如果 CMake 报错“找不到编译器”先确认环境变量里配置了g或 MSVC。编译完成后程序会进入交互模式显示一个简单的提示符。输入help会列出全部命令输入quit或exit退出。第一次用的时候可以输入(23)*4^2程序应当输出80以此确认核心引擎工作正常。4.2 命令行交互全部功能入口一览程序把功能分成两类一类是计算表达式一类是会话管理。表达式输入为普通一行比如9/2输出会带自动精度处理而不是一长串浮点尾巴。会话管理命令如下表命令作用说明help显示帮助列出所有命令和函数表history显示历史记录读取同目录下的history.txtclear清空历史记录会直接截断文件ans引用上一次结果直接参与表达式计算M/M-/MR内存加减和读取类似实体计算器的 M 键quit退出也会写一行退出日志内存存储实现得比较简单就是在引擎里留了两个double静态变量一个存ans一个存M。M和M-会读取当前显示值做加减再存回 M。这部分代码全在engine.cpp里没有单独开文件因为涉及状态不多开太多文件反而让新手看不懂。4.3 历史记录写文件不是只写一行那么简单历史记录模块单独成文件因为它额外处理了一个问题多条记录有顺序但history.txt不能只往里追加还得支持清空和去重。看核心读写逻辑// history.cpp 简化核心 std::string formatEntry(const std::string expr, double result) { std::ostringstream os; os std::fixed std::setprecision(12) expr result; return os.str(); } bool appendHistory(const std::string expr, double result) { std::ofstream f(history.txt, std::ios::app); if (!f) return false; f formatEntry(expr, result) \n; return f.good(); } bool clearHistory() { std::ofstream f(history.txt, std::ios::trunc); return f.good(); } std::vectorstd::string loadHistory() { std::vectorstd::string lines; std::ifstream f(history.txt); std::string line; while (std::getline(f, line)) { if (!line.empty()) lines.push_back(line); } return lines; }这段需要注意setprecision(12)是和std::fixed配合使用的不是单独用。std::fixed让输出固定为定点小数再配合setprecision(12)表示“小数点后最多12位”这样0.10.20.300000000000显示为0.3避免0.30000000000000004这种浮点尾巴。ios::app以追加方式打开文件不会覆盖之前的历史。std::getline逐行读取天然适应多行记录。写文件失败时返回false上层会给一句“无法写入历史记录”的提示程序不崩溃。5. 避坑与常见问题排查从“算不出来”到“算出错的数”5.1 三个最容易踩的坑现象、原因、解决坑一0.10.2显示成一长串丑陋的浮点数。现象是算简单的十进制小数结果变成0.30000000000000004。原因是二进制浮点数无法精确表示0.1和0.2IEEE 754 标准下这两个数的实际存储值相加出现在第16位以后的小数误差。解决办法是显示层统一做“规范化”用std::fixed加std::setprecision(12)格式化同时在normalize函数里把1.0000000000000002这类结果修整成1.0。坑二除以零没有报错程序直接输出inf。现象是输入1/0没有异常显示一个铺天盖地的inf。原因是 C 的浮点除法在除数为零时不会触发硬件异常IEEE 754 规定它返回无穷大。解决办法是在除法分支里显式判断rhs 0.0一旦检测到就抛异常提示“除数不能为零”而不是让数量逻辑在错误数据上继续算下去。这个检查必须放在运算之前放之后已经晚了。坑三递归下降解析负数表达式导致栈溢出。现象是输入----------1这种极端的连续负号程序卡死或报“栈溢出”。原因是实现一元负号时采用了递归每一个负号就调用一次parseFactor二十个负号就有二十层递归。解决办法是把连续的负号先折叠起来统计负号个数是奇数就取反、是偶数就不管或者给解析器加一个最大递归深度限制抛出“表达式过于复杂”的异常。5.2 边界输入回归测试源码里特地放了一个自动化测试文件tests/test_expressions.cpp它不是给用户用的而是给二次开发的人用的。测试的思路是构造一批合法和非法表达式逐一断言结果编译时把测试文件和主程序链接在一起运行时跑所有断言。如果你改了解析器跑一遍测试能立刻知道自己有没有把2^3^2的右结合性改坏、有没有让括号匹配失效。我自己的习惯是每改一处解析逻辑就往这个文件里加一组边界用例空字符串、只有数字、零除、多层括号、连续运算符、超大指数。半个小时跑一次全部断言比手动敲几小时输入可靠很多。5.3 改完代码后出现奇怪结果先怀疑这四处很多时候不是解析器坏了而是修改“漏了地方”。第一处加了新 Token 类型但忘了在 parser 的分支里处理结果是 tokenizer 报“未知字符”。第二处改了分母为零的检查但只检查了/没检查%有人输入5%0照样出nan。第三处改了格式化精度但发现历史记录里的结果和即时显示不一样这是两个地方要同时改。第四处函数表里增加了一个新函数但忘了给help命令补充说明。排查方式是按“tokenizer 输出 → parser 的分支 → 格式化函数 → 文档”这个顺序查基本一轮就能定位。6. 二次开发让这个计算器成为你的练手底座拿到这个项目真正值钱的是怎么基于它加功能。我建议从两个方向改造加一个新运算符加一个新的交互命令。加新运算符最典型的例子是阶乘!。先在 tokenizer 的运算符分支里加入!的识别然后在 parser 里单独处理它——因为阶乘是后缀运算符它要把“前一个已算好的值”和“运算符”绑定起来。在parsePrimary的末尾加一个判断如果当前 Token 是!就对已解析的值取阶乘。阶乘本身要有防溢出的意识10 的阶乘已经到 362880020 以上会溢出int64实现时用double保存并给一个上限提示。改完之后对应在测试文件里加5! 120和0! 1这两条用例。加交互命令比如“对比上次结果”则更简单history模块已经有了读文件的函数再加一个lastValue()读取history.txt最后一行并解析出等号右边的数字然后放进ans变量。这个方向适合练习文件 IO 和字符串解析风险比动 parser 小很多。验证方法也不复杂。改完代码把测试目标跑一遍再手动敲五组表达式普通四则、嵌套括号、函数调用、除零、负数。我从这个项目里学到的教训是改计算器这类“入口窄、逻辑深”的程序千万不能迷信手工测试一定要把断言用例写在下一次修改前。以前我改完觉得“应该没问题”交出去就被反馈2/-3和2/(-3)结果不一样。从那以后我每次改解析逻辑都会强制自己先碰一遍测试文件再动代码加一条用例、跑红、再改实现、跑绿。这套流程放在现在这个项目上十分钟就能给你足够的安全感。把分支逻辑理顺、把边角用例补齐再往上接一个带按钮的 GUI 界面其实也顺理成章——你只需要把 calculation 返回的结果填到QLineEdit里。这份工程我拆完之后最大的感受是它把“计算器”这个小题目做出了完整软件该有的分层和规范。如果你正想找一个能练手又能看懂源码的 C 项目把它下载下来按上面的步骤跑通再试着加个功能比看十篇教程管用。希望帮到你。本文还有配套的精品资源点击获取