简介:这份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 负责把字符串拆成最小单位 Token;parser 负责按语法规则把 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 求值主流程:一条输入从按键到结果的路
用户在界面上输入((1+2)*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("1+2") == 3这种用例就行。从这段代码能看出,计算器工程的架构观通常是:底层拆得足够碎,对外接口足够瘦。
3. 表达式求值:词法分析和递归下降解析逐行拆
3.1 词法分析器:如何把字符串切成 Token
词法分析是容易出错的一步。看这段核心实现:
// tokenizer.cpp 简化核心 std::vector<Token> tokenizer::tokenize(const std::string& expr) { std::vector<Token> 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让1+2*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 一条命令到底
这份工程不依赖第三方库,也不需要安装额外运行时,只要有 C++17 编译器和 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退出。第一次用的时候可以输入(2+3)*4^2,程序应当输出80,以此确认核心引擎工作正常。
4.2 命令行交互:全部功能入口一览
程序把功能分成两类:一类是计算表达式,一类是会话管理。表达式输入为普通一行,比如9/2,输出会带自动精度处理,而不是一长串浮点尾巴。会话管理命令如下表:
| 命令 | 作用 | 说明 |
|---|---|---|
help | 显示帮助 | 列出所有命令和函数表 |
history | 显示历史记录 | 读取同目录下的history.txt |
clear | 清空历史记录 | 会直接截断文件 |
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::vector<std::string> loadHistory() { std::vector<std::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.1+0.2=0.300000000000,显示为0.3,避免0.30000000000000004这种浮点尾巴。ios::app以追加方式打开文件,不会覆盖之前的历史。std::getline逐行读取,天然适应多行记录。写文件失败时返回false,上层会给一句“无法写入历史记录”的提示,程序不崩溃。
5. 避坑与常见问题排查:从“算不出来”到“算出错的数”
5.1 三个最容易踩的坑:现象、原因、解决
坑一:0.1+0.2显示成一长串丑陋的浮点数。现象是算简单的十进制小数,结果变成0.30000000000000004。原因是二进制浮点数无法精确表示0.1和0.2,IEEE 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 的阶乘已经到 3628800,20 以上会溢出int64,实现时用double保存并给一个上限提示。改完之后,对应在测试文件里加5! = 120和0! = 1这两条用例。
加交互命令比如“对比上次结果”,则更简单:history模块已经有了读文件的函数,再加一个lastValue()读取history.txt最后一行并解析出等号右边的数字,然后放进ans变量。这个方向适合练习文件 IO 和字符串解析,风险比动 parser 小很多。
验证方法也不复杂。改完代码把测试目标跑一遍,再手动敲五组表达式:普通四则、嵌套括号、函数调用、除零、负数。我从这个项目里学到的教训是:改计算器这类“入口窄、逻辑深”的程序,千万不能迷信手工测试,一定要把断言用例写在下一次修改前。以前我改完觉得“应该没问题”,交出去就被反馈2/-3和2/(-3)结果不一样。从那以后,我每次改解析逻辑,都会强制自己先碰一遍测试文件再动代码,加一条用例、跑红、再改实现、跑绿。这套流程放在现在这个项目上,十分钟就能给你足够的安全感。
把分支逻辑理顺、把边角用例补齐,再往上接一个带按钮的 GUI 界面其实也顺理成章——你只需要把 calculation 返回的结果填到QLineEdit里。这份工程我拆完之后最大的感受是:它把“计算器”这个小题目做出了完整软件该有的分层和规范。如果你正想找一个能练手又能看懂源码的 C++ 项目,把它下载下来,按上面的步骤跑通,再试着加个功能,比看十篇教程管用。希望帮到你。
本文还有配套的精品资源,点击获取