简介这是一份C实现的计算器程序完整工程源码面向C初学者及对面向对象编程感兴趣的学习者适合课设参考、期末复习或日常练手。程序不仅支持加、减、乘、除四则运算还包含数字的撤销输入、除零异常提示等交互细节覆盖从数据读取、运算执行到结果输出的完整流程展现出清晰的控制台应用开发思路。资源包共28个文件压缩后约283KB内有4个头文件和3个C源文件同时附带可直接运行的exe程序、Visual C工程配置文件以及说明文档解压后即可快速编译运行。实现上代码通过自定义Calculator类并重载运算符来组织计算逻辑利用stack容器完成撤销输入撤销时从栈顶弹出数字以避免误操作并涉及标准输入输出、格式控制与基础错误处理等知识点可作为理解C核心机制的小型完整案例。目前已有600人查看学习对巩固语法基础、掌握类与运算符重载的实际用法都有参考价值。1. C计算器项目表面是入门练手实则是编译原理的微缩战场网上 C计算器项目一抓一大把但九成只能应付ab这种简单需求一旦遇到2^3^2这种右结合运算、(-3)这种一元运算符或者嵌套十层的括号立刻原形毕露。我去年在模拟项目X里接手“实现一个计算器的全部功能”需求时发现这根本不是循环读cin再switch-case能搞定的小玩具而是完整的编译器前端流程词法分析、语法分析、表达式求值、异常处理、内存管理一个都逃不掉。对于正在学 C 的开发者这个项目是把 STL、智能指针、设计模式串起来的最佳实战载体对于需要内部效率工具的团队一个可裁剪的表达式引擎能省掉无数重复造轮子的时间。这篇笔记从选型逻辑讲到具体实现再落到血泪踩坑和构建验证全程可复现。2. 核心架构选型为什么我放弃了递归下降选择了调度场算法2.1 递归下降与调度场方案的对比网上博客最爱教递归下降解析因为它足够教科书。每次看到一个factor函数里调用termterm里又调用expression新手都会觉得好优雅。但真正写“全部功能”计算器时我最后选择用调度场算法Shunting Yard。原因有三个一是递归下降处理左结合、右结合和一元运算符时需要做前置和后置的符号预读代码里全是if分支维护成本高二是调度场算法天然区分“运算符栈”和“输出队列”优先级和结合性被压缩成一张二维表后续扩展、||、等逻辑运算时只需要加表项三是递归下降在表达式错误时报错信息往往很含糊而调度场能够精确输出“在哪个 Token 位置发现括号不匹配”这对做批处理脚本很关键。当然调度场算法也有自己的学习曲线它把语法树隐式地编码在栈的操作顺序里初学者常常搞不懂为什么2 3 * 4转成逆波兰式RPN后变成了2 3 4 * 。这里的关键认知是运算对象操作数先输出运算符根据优先级决定是立即入栈还是先弹出栈顶元素。优先级高的运算符如*遇到优先级低的运算符如时会压栈而优先级低的运算符遇到优先级高的运算符时会把高优先级的先弹出。2.2 令牌Token与词法分析器的实现细节词法分析是整个计算器最枯燥但最影响成败的环节。“全部功能”绝不意味着只读数字和符号它要求识别合法的十进制小数、科学计数法如1e-3、函数名sin、cos、log以及区分正负号和二元减号。我一般会把词法分析器写成一个独立的类对外只暴露一个接口std::vectorToken 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::vectorToken tokenize(const std::string input) { std::vectorToken tokens; size_t i 0; while (i input.size()) { if (std::isspace(static_castunsigned char(input[i]))) { i; // 跳过空格 } else if (std::isdigit(static_castunsigned 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_castunsigned 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::vectorToken shunting_yard(const std::vectorToken tokens) { std::vectorToken output; std::stackToken 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::stackdouble遍历 RPN Token 序列遇到数字就压栈遇到运算符就弹出两个操作数运算遇到一元函数就弹出一个操作数运算。// evaluator.cpp 求值器实现 double evaluate_rpn(const std::vectorToken rpn) { std::stackdouble 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^(31)、(-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_mapstd::string, double variables在词法分析识别出标识符后如果它不在内置函数列表里就当作变量名压入 Token。求值阶段遇到Variable就查表查不到就抛“undefined variable”。// 变量表的集成实例 class Calculator { std::unordered_mapstd::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::vectorToken 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_mapstd::string, UnaryFunc get() { static std::unordered_mapstd::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(12) - 3.0) 1e-9); // 优先级乘法先于加法 assert(std::abs(calc(12*3) - 7.0) 1e-9); // 括号改变优先级 assert(std::abs(calc((12)*3) - 9.0) 1e-9); // 幂运算右结合 assert(std::abs(calc(2^3^2) - 512.0) 1e-9); // 一元负号 assert(std::abs(calc(-32) - -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; }这些测试覆盖的不仅仅是“能不能算”而是把第二章里讨论的右结合性、一元负号、异常路径全部锁死在回归用例里。从那以后我每次提交代码前都强制跑一遍单测确保调度场算法在边界输入下不崩这个习惯帮我避开了无数次低级返工。希望帮到你。本文还有配套的精品资源点击获取