
简介这是一份C计算器程序完整工程覆盖加、减、乘、除、求余等基础运算并支持基于栈的撤销输入功能适合初学C面向对象编程的开发者对照学习。压缩包共28个文件约283KB包含头文件、源文件、可执行程序、调试记录及说明文档可同时查看工程结构与直接运行效果。工程以double类型处理数值演示了Calculator类的设计、运算符重载、cin/cout交互、除数非零校验和异常提示等关键点通过stack容器实现逐位撤销为理解STL与交互逻辑提供了直观案例。作者还保留了obj、pdb、ncb等编译过程文件方便追踪VC6.0环境下的构建流程。已有599人学习下载适合作为课程设计、自学练手或教学演示的参考素材。 我先说个暴论网上绝大多数C计算器的代码根本不能叫计算器只能叫两个数的四则运算Demo。你拿那种代码往控制台里输入12它确实能算出3可你一旦输入12*3很多实现直接给算出9——因为它根本不知道乘除法优先级更高。更别提括号、小数、连续运算、错误处理了。所以当我看到C计算器程序 实现了一个计算器的全部功能这个标题时第一反应是这玩意儿的关键点根本不在界面或按钮而在表达式解析。真正配得上全部功能四个字的计算器核心一定绕不开两样东西——逆波兰表达式和调度场算法。这篇文章我就把这些东西掰开揉碎讲清楚从原理到代码一步步给你拆解保证你不仅能看懂还能自己动手写一个能处理加减乘除、括号、小数甚至一元负号的完整计算器。1. 为什么说计算器的灵魂不是按钮而是表达式解析很多初学者拿到这个题目第一反应是这还不简单用一个switch语句把运算符分出来两个数一算不就完了于是写出来的代码长这样double a, b; char op; std::cin a op b; switch (op) { case : std::cout a b; break; case -: std::cout a - b; break; case *: std::cout a * b; break; case /: std::cout a / b; break; }这段代码我见过太多次了它的问题不在于简陋而在于它根本没有抓住计算器程序的核心矛盾。我们平时在纸上、在手机计算器里写的表达式叫中缀表达式也就是运算符在操作数中间的形式1 2 * 3。人一眼能看出要先算2 * 3因为乘法优先级高。但计算机不是人它从左往右读这串字符时读到2的时候并不知道后面跟着一个优先级更高的*。你如果让它机械地从左算到右结果必然是错的。这就引出计算器程序真正要解决的问题如何把一串带有优先级、括号等嵌套信息的字符流转化成一个计算机可以顺序执行的操作序列。这个问题的标准解法业界已经沉淀了几十年基本上就是两步走把中缀表达式转换成后缀表达式逆波兰表达式Reverse Polish Notation转换过程中利用栈来处理运算符优先级和括号。对后缀表达式进行顺序求值遇到数字压栈遇到运算符弹出两个数字计算结果再压回栈里最后栈顶就是答案。这一步转换专业术语叫调度场算法Shunting Yard Algorithm由计算机科学家Edsger Dijkstra提出——没错就是那个Dijkstra最短路径算法的Dijkstra。这个算法把所有运算符按优先级分成不同等级再借助一个运算符栈和一个输出队列完成表达式转化。理解了这一步你才算真正理解了计算器程序这个项目在考你什么不是switch的用法而是栈的运用、字符串处理、状态机思维。这也解释了为什么这个题目从本科作业一直火到面试题库——它问的是你的数据结构功底。2. 逆波兰表达式把中缀表达式翻译成机器好算的形式逆波兰表达式这个名字听着唬人其实一句话就能解释把运算符写在操作数的后面。中缀表达式1 2写成后缀就是1 2 中缀1 2 * 3后缀是1 2 3 * 。我习惯用一个生活化的类比来理解它**想象你在逛超市手上有一张购物清单上面写的是苹果、牛奶、面包。你买东西的过程是看清单→去货架拿→放进购物车这个顺序是线性的、一步一步来的不需要回头。**后缀表达式就是这种顺着走一遍就能算完的格式因为它把运算符的优先级信息全部消化在了排列顺序里。再举一个带括号的例子。中缀表达式(1 2) * 3后缀就是1 2 3 *。你看括号没了但运算顺序被完整保留了下来先算12再乘以3。后缀表达式求值的规则极其简单简单到让人觉得就该这么算从左到右扫描后缀表达式。遇到数字压入栈。遇到运算符从栈里弹出两个数字注意弹出顺序第一个弹出的是右操作数第二个是左操作数进行运算结果压回栈里。扫描结束后栈里唯一剩下的数字就是计算结果。用1 2 3 *走一遍压入1压入2遇到弹出2和1算出3压回栈然后压入3遇到*弹出3和栈里的3算出9。完全正确。所以说计算器程序的核心难点不在求值而在转换——怎么把普通人习惯的中缀表达式转成后缀表达式。这个过程就是调度场算法的工作了。3. VSCode下搭建C环境给新手的三个坑在动笔写代码之前有必要先说一下环境。因为从相关热搜词看得出来不少读者还在纠结VSCode怎么配置C/C环境、Dev C怎么下载。这里我直接给你结论用VSCode加MinGW-w64别再用Dev C了。Dev C的问题很多老程序员都懂它自带的编译器版本太老很多现代C特性比如std::optional、std::visit根本用不了。而我们这个计算器项目虽然用不上特别新的特性但趁这个机会配好VSCode环境后面做任何C项目都顺手。配置过程不展开细讲网上教程一抓一大把但有三个坑我必须提醒你第一个坑MinGW-w64别乱下载。很多人搜MinGW结果装了个32位的版本编译出来的程序在64位系统上跑没问题但调试的时候偶尔会出现莫名其妙的崩溃。建议直接去MinGW-w64的官方GitHub页面下载x86_64-posix-seh版本关键词记住了x86_6464位、posix支持线程、seh异常处理模型。第二个坑VSCode的tasks.json和launch.json配置。很多新手卡在能编译但没法调试这一步原因几乎都是launch.json里的miDebuggerPath没有指向gdb.exe的实际路径。你可以打开命令行输入where gdb确认路径然后填进去。第三个坑中文乱码。Windows下VSCode默认utf-8编码但cmd控制台默认gbk编码。你如果在代码里打印了中文控制台大概率乱码。最简单的办法是代码文件保持utf-8但运行程序时在tasks.json里加一句-fexec-charsetGBK编译参数保证运行时的中文输出正常。环境准备好了以后下面开始写真正的代码。4. 完整代码实现分词、调度场、RPN求值整个程序我拆成三个模块来写每个模块对应一个独立的、可测试的功能单元。这种拆法本身就是一种工程习惯——不要指望一口气写一个大函数搞定所有事一个函数只干一件事出了问题也好排查。4.1 第一步把输入字符串切成Token序列我们不能直接把字符串丢给算法处理首先要做的是分词也就是把输入的字符串切割成一个个有意义的词法单元Token。一个Token要么是数字比如3.14要么是运算符 - * /要么是括号()。#include iostream #include string #include vector #include stack #include cctype #include stdexcept enum class TokenType { Number, Operator, LeftParen, RightParen, End }; struct Token { TokenType type; double value; // 当 type 为 Number 时有效 char op; // 当 type 为 Operator 时有效 };分词逻辑并不复杂核心就两点跳过空格遇到数字就连续往后读直到读到一个非数字字符为止。这里要特别注意小数点——一个数字里只能出现一次小数点你要是不管这个输入1.2.3这种非法表达式程序就会给出奇怪的结果。std::vectorToken tokenize(const std::string expr) { std::vectorToken tokens; size_t i 0; while (i expr.size()) { if (expr[i] ) { i; continue; } if (std::isdigit(expr[i]) || expr[i] .) { size_t start i; bool dot_seen false; while (i expr.size() (std::isdigit(expr[i]) || expr[i] .)) { if (expr[i] .) { if (dot_seen) throw std::runtime_error(数字格式错误... 中出现了多个小数点); dot_seen true; } i; } double val std::stod(expr.substr(start, i - start)); tokens.push_back({TokenType::Number, val, 0}); } else if (expr[i] || expr[i] - || expr[i] * || expr[i] /) { tokens.push_back({TokenType::Operator, 0, expr[i]}); i; } else if (expr[i] () { tokens.push_back({TokenType::LeftParen, 0, 0}); i; } else if (expr[i] )) { tokens.push_back({TokenType::RightParen, 0, 0}); i; } else { throw std::runtime_error(std::string(无法识别的字符: ) expr[i]); } } tokens.push_back({TokenType::End, 0, 0}); return tokens; }4.2 第二步调度场算法中缀转后缀这一步是整个计算器的灵魂。算法逻辑一句话概括从左到右扫描Token序列数字直接进输出队列运算符与运算符栈顶比较优先级栈顶优先级高或相等就弹出到输出队列左括号直接入栈右括号则弹出运算符直到遇到左括号。优先级表很简单*和/是2和-是1。左括号特殊处理它在栈内时优先级最低这是为了保证括号内的运算符能先把括号内的运算处理完。int precedence(char op) { if (op * || op /) return 2; if (op || op -) return 1; return 0; } std::vectorToken shuntingYard(const std::vectorToken tokens) { std::vectorToken output; std::stackToken opstack; for (const auto tok : tokens) { if (tok.type TokenType::Number) { output.push_back(tok); } else if (tok.type TokenType::Operator) { while (!opstack.empty() opstack.top().type TokenType::Operator precedence(opstack.top().op) precedence(tok.op)) { output.push_back(opstack.top()); opstack.pop(); } opstack.push(tok); } else if (tok.type TokenType::LeftParen) { opstack.push(tok); } else if (tok.type TokenType::RightParen) { while (!opstack.empty() opstack.top().type ! TokenType::LeftParen) { output.push_back(opstack.top()); opstack.pop(); } if (opstack.empty()) throw std::runtime_error(括号不匹配多了一个右括号); opstack.pop(); // 弹出左括号丢弃 } else if (tok.type TokenType::End) { break; } } while (!opstack.empty()) { if (opstack.top().type TokenType::LeftParen) { throw std::runtime_error(括号不匹配多了一个左括号); } output.push_back(opstack.top()); opstack.pop(); } return output; }为什么优先级时也要弹出栈顶因为同优先级运算符遵循左结合规则。比如1 - 2 3如果遇到时不弹出前面的-就会出现1 - (2 3)的错误结果。用保证了从左到右计算的正确语义。4.3 第三步后缀表达式求值后缀求值这部分就是前文说的规则机械执行即可。唯一要注意的是除法的符号因为栈是LIFO结构先弹出的是右操作数。double evaluateRPN(const std::vectorToken rpn) { std::stackdouble values; for (const auto tok : rpn) { if (tok.type TokenType::Number) { values.push(tok.value); } else if (tok.type TokenType::Operator) { if (values.size() 2) throw std::runtime_error(表达式错误操作数不足); double right values.top(); values.pop(); double left values.top(); values.pop(); double result 0; switch (tok.op) { case : result left right; break; case -: result left - right; break; case *: result left * right; break; case /: if (right 0) throw std::runtime_error(错误除数不能为零); result left / right; break; } values.push(result); } } if (values.size() ! 1) throw std::runtime_error(表达式错误操作数过多); return values.top(); }4.4 主函数组装支持连续多次计算有了这三个模块主函数就是简单地组合调用。我还特地加了循环让程序支持一次启动后连续计算多道题而不是算一道就退出——这才像一个计算器该有的样子。int main() { std::cout C 计算器 std::endl; std::cout 支持: 加减乘除、括号、小数输入 q 退出 std::endl; std::string line; while (true) { std::cout ; std::getline(std::cin, line); if (line q || line quit) break; try { auto tokens tokenize(line); auto rpn shuntingYard(tokens); double result evaluateRPN(rpn); std::cout result std::endl; } catch (const std::exception e) { std::cerr 错误: e.what() std::endl; } } return 0; }整个程序逻辑清晰而且每一块拆出来都能单独测试。编译命令也很简单g -stdc17 -o calculator main.cpp如果你在VSCode里配好tasks.json后按CtrlShiftB就能编译。5. 那些会让计算器崩溃的隐藏坑代码写完之后你以为就完事了吗我在实际测试时踩过不少坑有几个特别隐蔽这里单独拿出来讲每个都是真实会遇到的。坑一一元负号问题。输入-5 3程序直接崩。为什么因为分词器看到-就当成二元运算符了调度场算法期待它前面应该有一个数字结果等来一个左括号或直接等来一个数字整个转换逻辑就乱了。解决方法有好几种比较简单的一种是在分词阶段做判断如果-或出现在表达式的开头或者紧跟在左括号或运算符后面就在它前面补一个0。比如-5 3变成0 - 5 3计算结果一样而且不用改算法。这个方法虽然不优雅但工程上非常实用。坑二栈溢出隐患。理论上如果有人输入一个超长的表达式递归或栈操作都可能出问题。我们的算法用的是显式栈问题不大但你要注意遇到异常情况比如除零、括号不匹配时栈里可能残留数据。这也是为什么evaluateRPN里在弹栈前一定要检查values.size()——不仅是为了报错信息更友好更是为了防止对空栈做top()操作导致未定义行为。坑三浮点数精度。0.1 0.2你猜结果是多少在C里算出来是0.30000000000000004。这不是我们的算法有问题而是IEEE 754浮点数表示的固有限制。在做计算器项目时我建议结果输出用std::setprecision控制一下小数位数比如std::cout std::setprecision(10) result或者干脆保留12位有效数字避免用户看到一堆诡异的尾数。坑四空白字符和非法字符混在一起。比如用户输入1 2 # 3#会被分词器当成非法字符抛异常。这个处理是对的但要注意异常发生的位置未必是用户真正出错的位置因为分词器是顺序处理的可能前面的#拦住了后面的合法字符。在报错信息里把位置带上是一个更好的实践比如第5个字符无法识别。6. 从能用到好用这个计算器还能怎么升级到这一步你手里的计算器已经能够处理加减乘除、括号、小数、连续运算、错误提示核心功能确实算是全部功能了。但如果你想让这个项目更有诚意或者拿它去面试时多聊几句下面这几个方向的性价比极高。方向一支持一元运算符正负号和幂运算^。一元运算符的处理方法上面已经说了幂运算则需要调整优先级表和求值逻辑^的优先级比乘除高且是右结合的。方向二支持函数扩展比如sqrt、sin、cos。分词器多识别字母开头的标识符调度场算法把函数名当成一种特殊的运算符处理求值时去查函数表。这里推荐用std::function或函数指针表避免写一堆if-else。你甚至可以做一个std::unordered_mapstd::string, std::functiondouble(double)把函数名映射到实现上新增函数只需要往表里加一行。方向三给计算器加历史记录。用一个std::vectorstd::pairstd::string, double存下每次的表达式和结果再加一条命令history打印这些记录。这个功能锻炼的是数据组织能力和算法无关但做出来很讨喜。方向四图形界面。用Qt写一个界面版或者用一个叫imgui的库做即时模式GUI。这一步能让你的项目从控制台玩具晋升为一个真正的软件。但要注意GUI不是重点计算核心要保持独立这样既方便测试也为后面换GUI框架留了余地。我个人在实现这个计算器时最有收获的不是调度场算法本身而是体会到了把一个大问题拆成三个小问题的工程思路。分词器、转换器、求值器每个模块单独看都不复杂组合起来却完成了一个大多数人觉得简单但实际很容易写错的功能。这也是为什么C面试官总爱问计算器——它不是一个语法题而是一个设计题。如果你是从零开始写的这个项目写完以后一定要做一件事把(1 2) * (3 - 4 / 5)这种又带括号又混着优先级表达式丢进去看结果对不对再试试1 / 0、((12)这种非法输入看程序会不会优雅地报错而不是直接崩溃。这几个用例过了你的计算器就真的立住了。本文还有配套的精品资源点击获取