简介:这是一份C++计算器程序完整工程,覆盖加、减、乘、除、求余等基础运算,并支持基于栈的撤销输入功能,适合初学C++面向对象编程的开发者对照学习。压缩包共28个文件,约283KB,包含头文件、源文件、可执行程序、调试记录及说明文档,可同时查看工程结构与直接运行效果。工程以double类型处理数值,演示了Calculator类的设计、运算符重载、cin/cout交互、除数非零校验和异常提示等关键点;通过stack容器实现逐位撤销,为理解STL与交互逻辑提供了直观案例。作者还保留了obj、pdb、ncb等编译过程文件,方便追踪VC++6.0环境下的构建流程。已有599人学习下载,适合作为课程设计、自学练手或教学演示的参考素材。 我先说个暴论:网上绝大多数"C++计算器"的代码,根本不能叫计算器,只能叫"两个数的四则运算Demo"。你拿那种代码往控制台里输入"1+2",它确实能算出3,可你一旦输入"1+2*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 *。你看,括号没了,但运算顺序被完整保留了下来:先算1+2,再乘以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_64(64位)、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-charset=GBK"编译参数,保证运行时的中文输出正常。
环境准备好了以后,下面开始写真正的代码。
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::vector<Token> tokenize(const std::string& expr) { std::vector<Token> 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::vector<Token> shuntingYard(const std::vector<Token>& tokens) { std::vector<Token> output; std::stack<Token> 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::vector<Token>& rpn) { std::stack<double> 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++ -std=c++17 -o calculator main.cpp如果你在VSCode里,配好tasks.json后按Ctrl+Shift+B就能编译。
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_map<std::string, std::function<double(double)>>,把函数名映射到实现上,新增函数只需要往表里加一行。
方向三:给计算器加历史记录。用一个std::vector<std::pair<std::string, double>>存下每次的表达式和结果,再加一条命令history打印这些记录。这个功能锻炼的是数据组织能力,和算法无关,但做出来很讨喜。
方向四:图形界面。用Qt写一个界面版,或者用一个叫imgui的库做即时模式GUI。这一步能让你的项目从"控制台玩具"晋升为"一个真正的软件"。但要注意,GUI不是重点,计算核心要保持独立,这样既方便测试,也为后面换GUI框架留了余地。
我个人在实现这个计算器时,最有收获的不是调度场算法本身,而是体会到了"把一个大问题拆成三个小问题"的工程思路。分词器、转换器、求值器,每个模块单独看都不复杂,组合起来却完成了一个大多数人觉得"简单"但实际很容易写错的功能。这也是为什么C++面试官总爱问计算器——它不是一个语法题,而是一个设计题。
如果你是从零开始写的这个项目,写完以后一定要做一件事:把(1 + 2) * (3 - 4 / 5)这种又带括号又混着优先级表达式丢进去,看结果对不对,再试试1 / 0、((1+2)这种非法输入,看程序会不会优雅地报错而不是直接崩溃。这几个用例过了,你的计算器就真的立住了。
本文还有配套的精品资源,点击获取