☰
C++计算器实战:手写调度场算法与逆波兰式求值
2026/10/20 2:51:57 网站建设 项目流程

简介:这是一份C++实现的计算器程序完整工程源码,面向C++初学者及对面向对象编程感兴趣的学习者,适合课设参考、期末复习或日常练手。程序不仅支持加、减、乘、除四则运算,还包含数字的撤销输入、除零异常提示等交互细节,覆盖从数据读取、运算执行到结果输出的完整流程,展现出清晰的控制台应用开发思路。资源包共28个文件,压缩后约283KB,内有4个头文件和3个C++源文件,同时附带可直接运行的exe程序、Visual C++工程配置文件以及说明文档,解压后即可快速编译运行。实现上,代码通过自定义Calculator类并重载运算符来组织计算逻辑,利用stack容器完成撤销输入,撤销时从栈顶弹出数字以避免误操作,并涉及标准输入输出、格式控制与基础错误处理等知识点,可作为理解C++核心机制的小型完整案例。目前已有600人查看学习,对巩固语法基础、掌握类与运算符重载的实际用法都有参考价值。

1. C++计算器项目:表面是入门练手,实则是编译原理的微缩战场

网上 C++计算器项目一抓一大把,但九成只能应付a+b这种简单需求,一旦遇到2^3^2这种右结合运算、(-3)这种一元运算符,或者嵌套十层的括号,立刻原形毕露。我去年在模拟项目X里接手“实现一个计算器的全部功能”需求时,发现这根本不是循环读cin再switch-case能搞定的小玩具,而是完整的编译器前端流程:词法分析、语法分析、表达式求值、异常处理、内存管理一个都逃不掉。对于正在学 C++ 的开发者,这个项目是把 STL、智能指针、设计模式串起来的最佳实战载体;对于需要内部效率工具的团队,一个可裁剪的表达式引擎能省掉无数重复造轮子的时间。这篇笔记从选型逻辑讲到具体实现,再落到血泪踩坑和构建验证,全程可复现。

2. 核心架构选型:为什么我放弃了递归下降,选择了调度场算法

2.1 递归下降与调度场方案的对比

网上博客最爱教递归下降解析,因为它足够教科书。每次看到一个factor函数里调用term,term里又调用expression,新手都会觉得好优雅。但真正写“全部功能”计算器时,我最后选择用调度场算法(Shunting Yard)。原因有三个:一是递归下降处理左结合、右结合和一元运算符时,需要做前置和后置的符号预读,代码里全是if分支,维护成本高;二是调度场算法天然区分“运算符栈”和“输出队列”,优先级和结合性被压缩成一张二维表,后续扩展&&、||、==等逻辑运算时只需要加表项;三是递归下降在表达式错误时报错信息往往很含糊,而调度场能够精确输出“在哪个 Token 位置发现括号不匹配”,这对做批处理脚本很关键。

当然调度场算法也有自己的学习曲线,它把语法树隐式地编码在栈的操作顺序里,初学者常常搞不懂为什么2 + 3 * 4转成逆波兰式(RPN)后变成了2 3 4 * +。这里的关键认知是:运算对象(操作数)先输出,运算符根据优先级决定是立即入栈还是先弹出栈顶元素。优先级高的运算符(如*)遇到优先级低的运算符(如+)时会压栈,而优先级低的运算符遇到优先级高的运算符时,会把高优先级的先弹出。

2.2 令牌(Token)与词法分析器的实现细节

词法分析是整个计算器最枯燥但最影响成败的环节。“全部功能”绝不意味着只读数字和符号,它要求识别合法的十进制小数、科学计数法(如1e-3)、函数名(sin、cos、log),以及区分正负号和二元减号。我一般会把词法分析器写成一个独立的类,对外只暴露一个接口:std::vector<Token> tokenize(const std::string& input)。这样后续如果要做 GUI 或文件批处理,可以反复调用而不会污染全局状态。

// token.h 核心数据结构 enum class TokenType { Number, Operator, // + - * / ^ LeftParen, RightParen, Function, // sin cos log sqrt End }; struct Token { TokenType type; double value; // 当 type == Number 时有效 char op; // 当 type == Operator 时有效 std::string func; // 当 type == Function 时有效 }; // lexer.cpp 主体逻辑 std::vector<Token> tokenize(const std::string& input) { std::vector<Token> tokens; size_t i = 0; while (i < input.size()) { if (std::isspace(static_cast<unsigned char>(input[i]))) { i++; // 跳过空格 } else if (std::isdigit(static_cast<unsigned char>(input[i])) || (input[i] == '.' && i + 1 < input.size() && std::isdigit(input[i + 1]))) { // 解析数字,注意小数点开头的情况 size_t start = i; while (i < input.size() && (std::isdigit(input[i]) || input[i] == '.')) i++; tokens.push_back(Token{TokenType::Number, std::stod(input.substr(start, i - start)), 0, ""}); } else if (input[i] == '+' || input[i] == '-' || input[i] == '*' || input[i] == '/' || input[i] == '^') { tokens.push_back(Token{TokenType::Operator, 0, input[i], ""}); i++; } else if (input[i] == '(') { tokens.push_back(Token{TokenType::LeftParen, 0, 0, ""}); i++; } else if (input[i] == ')') { tokens.push_back(Token{TokenType::RightParen, 0, 0, ""}); i++; } else { // 尝试解析函数名,如 sin size_t start = i; while (i < input.size() && std::isalpha(static_cast<unsigned char>(input[i]))) i++; std::string word = input.substr(start, i - start); if (word == "sin" || word == "cos" || word == "tan" || word == "log" || word == "sqrt") { tokens.push_back(Token{TokenType::Function, 0, 0, word}); } else { throw std::invalid_argument("Unknown token: " + word); } } } tokens.push_back(Token{TokenType::End, 0, 0, ""}); return tokens; }

这段代码有几个细节值得注意。第一,数字解析没有直接处理像.5这样的输入,但std::stod本身能处理以.开头的小数字符串,所以词法分析器把这个场景也覆盖了。第二,函数名解析放在最后,用isalpha收集连续字母,再比对预定义集合,这样如果用户输入ssin,会直接抛出未知令牌异常。第三,Token 结构里我同时保留了value、op、func三个字段,虽然同一时刻只有一个生效,但这比std::variant更直观,调试时有字段可看。

2.3 优先级表与结合性规则

调度场算法最核心的是优先级表。我把运算符分为三档:加减为一档,乘除为二档,幂运算为三档。这里最容易翻车的是幂运算^,它不仅是最高优先级,还是右结合。也就是说2^3^2应该被解析成2^(3^2),结果等于 512,而不是(2^3)^2的 64。很多开源项目在这里都会踩坑,因为默认实现左结合只需从栈里弹出即可,右结合却要求自己压栈。

// 优先级判断与结合性 int get_priority(char op) { switch (op) { case '+': case '-': return 1; case '*': case '/': return 2; case '^': return 3; default: return 0; } } bool is_left_assoc(char op) { // 除了 '^' 是右结合,其余都是左结合 return op != '^'; }

在调度场算法的主循环里,当读取到一个新运算符op时,需要比较op与栈顶运算符的优先级。左结合运算符的弹出条件是:

while (!op_stack.empty() && op_stack.top().type == TokenType::Operator && (get_priority(op) < get_priority(op_stack.top().op) || (is_left_assoc(op) && get_priority(op) == get_priority(op_stack.top().op)))) { output.push_back(op_stack.top()); op_stack.pop(); }

右结合运算符(^)遇到同等优先级时不能弹出,因为它要等右侧的表达式先算完。这里用is_left_assoc(op)做短路判断,左结合运算符在优先级相等时弹出,右结合运算符则不弹,从而实现了正确的结合性。

3. 手写核心解析器:从表达式到逆波兰式的完整落地

3.1 双栈求值方案与调度场实现

虽然调度场算法在逻辑上可以一边扫描一边输出,但为了代码的清晰性和后续功能扩展(比如插入 AST 节点做优化),我习惯先把中缀表达式转换成逆波兰式(RPN),再把 RPN 喂给一个极简的基于栈的求值器。这样做的好处是求值器完全不需要关注括号和优先级,只按顺序从栈里弹出操作数做运算,再压回栈中,逻辑简单到不可能出错。

// shunting_yard.cpp 调度场算法核心 std::vector<Token> shunting_yard(const std::vector<Token>& tokens) { std::vector<Token> output; std::stack<Token> op_stack; for (const auto& tok : tokens) { if (tok.type == TokenType::Number) { output.push_back(tok); } else if (tok.type == TokenType::Function) { op_stack.push(tok); } else if (tok.type == TokenType::Operator) { while (!op_stack.empty() && op_stack.top().type == TokenType::Operator && (get_priority(tok.op) < get_priority(op_stack.top().op) || (is_left_assoc(tok.op) && get_priority(tok.op) == get_priority(op_stack.top().op)))) { output.push_back(op_stack.top()); op_stack.pop(); } op_stack.push(tok); } else if (tok.type == TokenType::LeftParen) { op_stack.push(tok); } else if (tok.type == TokenType::RightParen) { // 弹出直到左括号 while (!op_stack.empty() && op_stack.top().type != TokenType::LeftParen) { output.push_back(op_stack.top()); op_stack.pop(); } if (op_stack.empty()) { throw std::invalid_argument("Mismatched parentheses: missing '('"); } op_stack.pop(); // 弹出左括号 // 如果左括号上方是函数,则把函数也弹出 if (!op_stack.empty() && op_stack.top().type == TokenType::Function) { output.push_back(op_stack.top()); op_stack.pop(); } } else if (tok.type == TokenType::End) { break; } } while (!op_stack.empty()) { if (op_stack.top().type == TokenType::LeftParen) { throw std::invalid_argument("Mismatched parentheses: missing ')'"); } output.push_back(op_stack.top()); op_stack.pop(); } return output; }

这段实现里有一个容易被忽略的细节是函数与括号的交互。当遇到右括号时,常规只是弹出运算符到左括号,但函数(如sin)是挂在左括号旁边的,所以弹出左括号后,还需要检查栈顶是否还有函数,如果有,把它也送入输出队列。没有这一步,sin(3)的 RPN 会变成3 sin缺失函数名的错误序列。

3.2 逆波兰式求值器的实现

RPN 求值器是整个计算器里最“笨”的模块,但也是最不容易出错的地方。它的核心是一个std::stack<double>,遍历 RPN Token 序列,遇到数字就压栈,遇到运算符就弹出两个操作数运算,遇到一元函数就弹出一个操作数运算。

// evaluator.cpp 求值器实现 double evaluate_rpn(const std::vector<Token>& rpn) { std::stack<double> stk; for (const auto& tok : rpn) { if (tok.type == TokenType::Number) { stk.push(tok.value); } else if (tok.type == TokenType::Operator) { // 注意弹出顺序:先弹出的是右操作数 double rhs = stk.top(); stk.pop(); double lhs = stk.top(); stk.pop(); double result = apply_op(lhs, rhs, tok.op); stk.push(result); } else if (tok.type == TokenType::Function) { double arg = stk.top(); stk.pop(); stk.push(apply_func(tok.func, arg)); } } if (stk.size() != 1) { throw std::runtime_error("Invalid expression: leftover stack values"); } return stk.top(); } double apply_op(double lhs, double rhs, char op) { switch (op) { case '+': return lhs + rhs; case '-': return lhs - rhs; case '*': return lhs * rhs; case '/': if (rhs == 0.0) throw std::runtime_error("Division by zero"); return lhs / rhs; case '^': return std::pow(lhs, rhs); default: throw std::invalid_argument("Unknown operator"); } } double apply_func(const std::string& func, double arg) { if (func == "sin") return std::sin(arg); if (func == "cos") return std::cos(arg); if (func == "tan") return std::tan(arg); if (func == "log") return std::log(arg); // 自然对数 if (func == "sqrt") { if (arg < 0) throw std::invalid_argument("sqrt of negative number"); return std::sqrt(arg); } throw std::invalid_argument("Unknown function"); }

这里有几个工程细节必须强调。第一,弹出顺序:lhs是后弹出的,因为栈顶是右侧操作数,处理除法时如果不区分左右,2 / 4会算出2。第二,除零检查必须在调用标准库前完成,否则会得到inf而不是清晰报错。第三,stk.size() != 1的最终检查能捕捉到输入1 2 + 3这种操作数比运算符多的情况。

3.3 从字符串到结果的完整调用链

把词法分析、调度场、求值器串起来,暴露给用户的接口应该极简。我习惯实现一个Calculator类,只提供double calculate(const std::string& expr)一个公开方法,内部捕获所有异常并转换成带具体信息的错误消息。

// calculator.cpp 门面模式封装 class Calculator { public: double calculate(const std::string& expr) { auto tokens = tokenize(expr); auto rpn = shunting_yard(tokens); return evaluate_rpn(rpn); } };

如果你只想做一个能运行的最小 demo,这三个模块加起来不超过 300 行。但“全部功能”意味着你要能处理sin(-1)、2^(3+1)、(-2)*(-3)这类混合场景。其中一元负号的解析是卡住无数新手的黑匣子,我在下一章里详细拆解。

4. 避坑现场:计算器项目最容易翻车的四个真实场景

4.1 负号与减号的二义性:3 - -2直接报错

  • 现象:输入3 - -2或(-3)*2时,词法分析器把第二个-当成二元操作符,调度场算法找不到它的左操作数,抛异常。
  • 原因:词法分析阶段没有区分一元负号和二元减法。二者在 Token 序列里的上下文不同:一元负号要么出现在表达式开头,要么出现在左括号后面,又或者出现在另一个运算符后面(如3 * -2)。
  • 解决:我习惯在词法分析后增加一个预处理步骤,扫描 Token 序列,把“前驱 Token 是左括号、运算符或序列起始位置”的-转换成Negate这种一元运算 Token。求值器看到Negate就弹出栈顶单个数字取反。RPN 转换时也要把Negate当作隐式函数处理,优先级比所有二元运算符都高。
// 一元负号预处理的伪代码片段 for (size_t i = 0; i < tokens.size(); i++) { if (tokens[i].type == TokenType::Operator && tokens[i].op == '-') { bool is_unary = (i == 0 || tokens[i-1].type == TokenType::LeftParen || tokens[i-1].type == TokenType::Operator); if (is_unary) { // 替换成特殊一元操作符标记,或者在 RPN 阶段直接处理 tokens[i].op = '_'; // 用 '_' 表示一元负号 } } }

这里我用了op = '_'作为内部标记,避免了新建 TokenType 的繁琐。调度场算法里针对_单独处理优先级,它是最高优先级右结合,但不需要左操作数。

4.2 浮点数精度丢失:0.1 + 0.2结果不等于0.3

  • 现象:输入0.1 + 0.2,显示0.30000000000000004。
  • 原因:IEEE 754 浮点数表示天然存在精度误差,0.1和0.2在二进制下是无限循环小数,相加结果自然有尾差。
  • 解决:这在计算器场景下无法根治,但可以降低恐慌。方案一:在最终输出时做格式化,比如std::ostringstream设置std::fixed << std::setprecision(10),把多余尾差抹掉;方案二:提供“分数模式”或“十进制高精度模式”,改用boost::multiprecision::cpp_dec_float_50替代double,代价是性能下降一个数量级。我一般默认用double加格式化输出,并在文档里明确精度边界。

4.3 异常路径上的内存泄漏:new出来的节点在解析器报错时无人回收

  • 现象:程序长期运行(比如作为后台服务循环计算表达式)时,内存持续上涨,最终崩溃。
  • 原因:初版实现里我为了让 Token 支持复杂表达式,把 AST 节点定义为裸指针Node*,在解析过程中用new创建。原本满心期待析构函数里统一delete,结果异常从解析器内部抛出时,析构函数根本没机会执行,内存直接泄漏。
  • 解决:彻底放弃手写内存管理,改用std::unique_ptr或std::shared_ptr管理所有节点。Token 结构里的value和op字段都用值类型存储,不涉及堆分配。从那以后我给自己立了规矩:写 C++ 代码第一原则是默认使用栈对象和智能指针,裸new只在没有异常可能且生命周期极其清晰的场景下才考虑。

4.4 极深括号导致的栈溢出:1+(1+(1+...))解析到十万层

  • 现象:用户粘贴一个夸张的表达式,比如 10 万层括号嵌套,程序在解析阶段异常退出,报segmentation fault。
  • 原因:调度场算法本身虽然是迭代的,但如果你在 RPN 求值阶段为了简化操作而使用了递归下降求值,那么深层括号会直接耗尽调用栈。即使是迭代的调度场,如果输出队列里含有海量 Token 且后续测试代码用了递归遍历,同样会崩。
  • 解决:保证核心求值链路全程迭代,禁止递归。同时给输入表达式长度加上限,比如 8192 字符,超过即拒绝,这不算限制功能,而是保护自己不被恶意输入搞崩。

5. 扩展“全部功能”的边界:从 CLI 到 GUI 与变量系统

5.1 引入变量表与赋值表达式

“全部功能”必然包含变量定义,比如let x = 5或直接x = 5,后续表达式引用x。此时 Token 类型里Variable不再是错误,而需要被解析器识别。实现方式是在计较器类里维护一个std::unordered_map<std::string, double> variables,在词法分析识别出标识符后,如果它不在内置函数列表里,就当作变量名压入 Token。求值阶段遇到Variable就查表,查不到就抛“undefined variable”。

// 变量表的集成实例 class Calculator { std::unordered_map<std::string, double> vars; public: void set_var(const std::string& name, double value) { vars[name] = value; } double calculate(const std::string& expr) { auto tokens = tokenize(expr); // 检查是否包含赋值语句 if (tokens.size() >= 3 && tokens[1].type == TokenType::Operator && tokens[1].op == '=') { if (tokens[0].type != TokenType::Variable) { throw std::invalid_argument("Left side of '=' must be a variable"); } // 剔除前三个 Token(变量名、等号、值),对右侧求值 std::vector<Token> rhs_tokens(tokens.begin() + 2, tokens.end()); double val = evaluate_rpn(shunting_yard(rhs_tokens)); vars[tokens[0].func] = val; return val; } auto rpn = shunting_yard(tokens); return evaluate_rpn(rpn); } };

注意这里的Token结构里func字段在变量场景下被复用为变量名,因为它本来就是std::string。这种方式虽然看起来有点“野”,但对于内部工具来说足够务实,比单纯为变量新增一个字段更节省内存。

5.2 GUI 封装:把计算引擎塞进 Qt 时的线程教训

桌面计算器需要 GUI 包装,我选择 Qt 搭建,因为信号槽机制和跨平台属性省掉大量重复工作。但把 CLI 引擎搬到 GUI 时最隐蔽的坑是主线程阻塞。假设用户点击“计算”按钮后直接在同线程调用calculate(),遇到9^99999999这种超大指数,界面会直接无响应数秒甚至数分钟。

// Qt 工作线程封装伪代码 class CalculationWorker : public QObject { Q_OBJECT public slots: void doCalculate(const QString& expr) { try { double result = calc.calculate(expr.toStdString()); emit resultReady(result); } catch (const std::exception& e) { emit errorOccurred(QString::fromStdString(e.what())); } } signals: void resultReady(double); void errorOccurred(const QString&); };

我用QThread和QObject的moveToThread把CalculationWorker挪到副线程,计算完成再通过信号把结果传回 UI 线程更新QLineEdit。这个模式下,计算器的核心引擎保持完全无状态(除了变量表),任何线程调用都不会互相污染。

5.3 支持函数扩展与插件化的探索

如果团队后续要加入物理常数(如pi、e)和更多数学函数(如asin、exp),在每个函数里写死分支会导致代码膨胀。我一般把函数列表抽象成一个注册表:

// function_registry.h 函数注册表模式 using UnaryFunc = double(*)(double); class FunctionRegistry { static std::unordered_map<std::string, UnaryFunc>& get() { static std::unordered_map<std::string, UnaryFunc> instance; return instance; } public: static void register_function(const std::string& name, UnaryFunc fn) { get()[name] = fn; } };

初始化时统一注册:

bool init_registry() { FunctionRegistry::register_function("sin", std::sin); FunctionRegistry::register_function("cos", std::cos); FunctionRegistry::register_function("abs", std::abs); return true; }

这样加新函数只需要写一行注册代码,核心求值器完全不用改。对于“全部功能”这个含糊需求,我认为做到“新增运算类型不需要改动既有代码逻辑”才算是真正合格的架构。

6. 让代码可验证:CMake 构建、单元测试与持续集成的习惯

6.1 用 CMake 科学组织项目结构

项目初期我只有一个main.cpp,随着词法分析、调度场、求值器拆分,必须用构建系统管理依赖。CMake 是现代 C++ 最不折腾的选择。我习惯把核心算法编译成静态库,主程序只链接这个库,这样测试代码也能复用同一份逻辑。

# CMakeLists.txt 核心片段 cmake_minimum_required(VERSION 3.16) project(CalcCore VERSION 1.0) set(CMAKE_CXX_STANDARD 17) set(CMAKE_CXX_STANDARD_REQUIRED ON) # 核心库 add_library(calc_core STATIC src/lexer.cpp src/shunting_yard.cpp src/evaluator.cpp ) target_compile_options(calc_core PUBLIC -Wall -Wextra -Wpedantic) # 可执行程序 add_executable(calc_app src/main.cpp) target_link_libraries(calc_app PRIVATE calc_core) # 测试 enable_testing() add_executable(unit_tests tests/test_calc.cpp) target_link_libraries(unit_tests PRIVATE calc_core) add_test(NAME calc_tests COMMAND unit_tests)

这段 CMake 有几个细节值得复制。第一,通过STATIC把中间产物锁在calc_core里,防止测试程序和主程序各编一遍核心代码导致符号重复。第二,-Wall -Wextra -Wpedantic编译选项挂在PUBLIC上,让所有链接calc_core的目标都强制开启严格告警。第三,enable_testing()后面直接用add_test注册,配合 CTest 一行命令跑完所有测试。

6.2 为调度场算法补齐最后一道防线:回归测试

计算器核心算法重构频率高,没有测试兜底纯属裸奔。我使用轻量级测试框架,不引入太重的基础设施。每一个测试用例都必须覆盖一个具体的业务规则。

// tests/test_calc.cpp 核心测试样例 #include <cassert> #include <cmath> double calc(const std::string& expr) { /* 这里调用项目接口 */ } int main() { // 基本运算 assert(std::abs(calc("1+2") - 3.0) < 1e-9); // 优先级:乘法先于加法 assert(std::abs(calc("1+2*3") - 7.0) < 1e-9); // 括号改变优先级 assert(std::abs(calc("(1+2)*3") - 9.0) < 1e-9); // 幂运算右结合 assert(std::abs(calc("2^3^2") - 512.0) < 1e-9); // 一元负号 assert(std::abs(calc("-3+2") - -1.0) < 1e-9); assert(std::abs(calc("3*(-2)") - -6.0) < 1e-9); // 函数调用 assert(std::abs(calc("sin(0)") - 0.0) < 1e-9); assert(std::abs(calc("sqrt(16)") - 4.0) < 1e-9); // 异常场景 bool threw = false; try { calc("1/0"); } catch (...) { threw = true; } assert(threw); return 0; }

这些测试覆盖的不仅仅是“能不能算”,而是把第二章里讨论的右结合性、一元负号、异常路径全部锁死在回归用例里。从那以后我每次提交代码前都强制跑一遍单测,确保调度场算法在边界输入下不崩,这个习惯帮我避开了无数次低级返工。希望帮到你。

本文还有配套的精品资源,点击获取

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询