C++计算器项目实战:双栈法实现表达式求值与避坑指南 简介这是一份面向C初学者与进阶学习者的计算器程序源码资源基于VC6.0工程组织完整实现了加减乘除四则运算、撤销输入数字、除零校验与输入验证等功能适合用来练习面向对象设计、运算符重载、STL栈应用以及异常处理等核心知识点。压缩包共28个文件约283KB包含3个cpp源文件与4个h头文件承载主要逻辑另有dsp、dsw工程文件、rc资源脚本与ico图标、exe可执行程序及pdb、obj等编译中间产物并附ReadMe与说明文档便于直接编译运行和对照阅读。目前已有600人学习下载。通过研读这份代码读者可以掌握Calculator类的封装思路、利用stack实现撤销操作的技巧、基于iostream的输入输出处理方式以及除法异常与输入校验的健壮性设计从而理解一个交互式控制台程序的完整构建流程。1. 从一份 C 计算器源码说起为什么我建议你亲手跑一遍很多人看到“C 计算器程序”第一反应是这玩意儿太基础了网上随便一搜一大把有什么好拆的。但恰恰是这种看起来简单的项目最能暴露一个 C 学习者到底有没有真正把语法、输入处理、状态管理和边界条件串起来。我见过太多人能手写链表、能背多态结果一让他处理1/0或者连续输入23*4就翻车。这份资源实现了一个计算器的全部功能从四则运算到括号优先级再到连续计算和错误处理麻雀虽小五脏俱全。它适合刚学完 C 基础语法、想找一个能完整跑通的小项目练手的人也适合工作几年但一直没认真写过命令行交互逻辑的开发者拿来补课。你不需要配环境配到怀疑人生一个 g 加一个终端就能跑起来但里面藏着的坑足够让你重新审视自己写过的每一行输入输出代码。2. 拆开看结构表达式求值到底怎么落地2.1 两种主流实现路线双栈法 vs 递归下降拿到一个计算器需求第一件事不是写代码而是选实现路线。常见做法有两种双栈法和递归下降。双栈法用两个栈分别存操作数和运算符遇到运算符时根据优先级决定是否弹出栈顶进行计算逻辑直白代码量少适合表达式语法简单的场景。递归下降则是把表达式拆成expression - term - factor的层级每一层对应一个函数天然支持括号嵌套和自定义函数扩展但代码结构更复杂对新手不太友好。这份资源采用的是双栈法原因很实际它要处理的是标准四则运算加括号没有变量赋值、没有函数调用双栈法足够用而且调试起来直观——你可以在每次入栈出栈时打印栈内容一眼就能看出优先级判断对不对。如果你后面想扩展成支持sin、cos或者变量那就得换成递归下降或者引入词法分析器那是另一个量级的工作。选型没有绝对优劣关键是匹配当前需求。2.2 核心数据结构与优先级表双栈法的核心就两个std::stack一个存double类型的操作数一个存char类型的运算符。优先级判断靠一张表运算符优先级结合性-1左结合*/2左结合(0入栈后—)—触发弹栈这张表决定了什么时候可以把栈顶运算符弹出来计算。规则是当前运算符优先级小于等于栈顶运算符优先级时先弹出栈顶计算再把当前运算符入栈。左括号入栈后优先级视为最低保证括号内的运算先算完。右括号则一路弹到左括号为止。2.3 一个能跑的最小实现下面这段代码是双栈法的骨架我把它拆成三个函数优先级查询、单步计算、主求值循环。你可以直接复制到.cpp文件里编译运行。#include iostream #include stack #include string #include cctype #include stdexcept // 查询运算符优先级数字越大越优先 int priority(char op) { if (op || op -) return 1; if (op * || op /) return 2; return 0; // 左括号返回0保证不弹出 } // 从操作数栈取两个数按op计算后压回 void applyOp(std::stackdouble nums, std::stackchar ops) { if (nums.size() 2) throw std::runtime_error(操作数不足); double b nums.top(); nums.pop(); double a nums.top(); nums.pop(); char op ops.top(); ops.pop(); double result 0; switch (op) { case : result a b; break; case -: result a - b; break; case *: result a * b; break; case /: if (b 0) throw std::runtime_error(除数不能为0); result a / b; break; } nums.push(result); } // 主求值函数逐字符扫描表达式 double evaluate(const std::string expr) { std::stackdouble nums; std::stackchar ops; for (size_t i 0; i expr.size(); i) { char ch expr[i]; if (isspace(ch)) continue; if (isdigit(ch) || ch .) { // 解析完整数字支持多位数和小数 std::string numStr; while (i expr.size() (isdigit(expr[i]) || expr[i] .)) { numStr expr[i]; } --i; // 回退一格因为for循环还会 nums.push(std::stod(numStr)); } else if (ch () { ops.push(ch); } else if (ch )) { while (!ops.empty() ops.top() ! () { applyOp(nums, ops); } if (!ops.empty()) ops.pop(); // 弹出左括号 } else { // 当前运算符优先级 栈顶时先算栈顶 while (!ops.empty() priority(ops.top()) priority(ch)) { applyOp(nums, ops); } ops.push(ch); } } // 处理剩余运算符 while (!ops.empty()) { applyOp(nums, ops); } if (nums.size() ! 1) throw std::runtime_error(表达式不合法); return nums.top(); } int main() { std::string line; while (std::getline(std::cin, line)) { if (line quit) break; try { std::cout evaluate(line) std::endl; } catch (const std::exception e) { std::cout 错误: e.what() std::endl; } } return 0; }逻辑说明evaluate函数从左到右扫描表达式遇到数字就解析完整数值压入nums遇到左括号直接压入ops遇到右括号就一路弹栈计算直到左括号遇到普通运算符则比较优先级决定是否先算栈顶。applyOp负责实际的二元运算除法前检查除数是否为零。main函数用getline循环读取输入输入quit退出每次计算都用try-catch包住保证一次错误不会让程序崩溃。参数说明priority函数返回的数值只用于比较具体大小不重要关键是相对关系。std::stod会把字符串转成double如果字符串格式不对会抛invalid_argument这里因为我们已经用isdigit和.过滤过所以不会触发。applyOp里先取b再取a是因为栈是后进先出先弹出的是右操作数。编译命令g -stdc11 -o calc calc.cpp ./calc输入23*4应该输出 14输入(23)*4应该输出 20输入1/0应该输出错误提示。3. 避坑指南我踩过的五个血泪坑3.1 多位数解析时下标回退漏写现象输入123得到的结果是5而不是15。原因解析数字的while循环结束后i已经指向运算符但for循环还会再执行一次i导致跳过了运算符。解决在while循环后加--i回退一格让for的i正好落在运算符上。这个坑我当年写了三遍才记住后来养成了习惯——只要在for里手动推进了下标就一定检查是否需要回退。3.2 括号不匹配导致栈溢出或结果错误现象输入(23程序不报错但结果不对输入23)直接崩溃。原因左括号多余时最后的while循环会把左括号当运算符弹出来计算但priority(()返回 0applyOp里switch没有对应分支结果未定义。右括号多余时弹栈循环找不到左括号会把整个栈弹空。解决在applyOp里对未知运算符抛异常在右括号处理时检查栈是否为空在最后检查ops栈里是否只剩合法的运算符。更稳妥的做法是先用一个括号匹配检查函数预扫描一遍。3.3 浮点数精度问题让相等判断失效现象输入0.10.2输出0.30000000000000004。原因IEEE 754 浮点数表示法天生无法精确表示某些十进制小数这不是代码 bug是计算机底层限制。解决如果只是展示用std::setprecision控制输出位数如果要做相等比较用fabs(a-b) 1e-9这种容差判断。计算器场景下一般展示层面处理就够了但如果你后面要加连续计算或者记忆功能就得注意累积误差。3.4 连续输入时状态未重置现象第一次输入23得到5第二次输入45得到9看起来正常但第三次输入*2期望得到18却报错。原因如果设计成支持连续计算用上一次结果继续算需要在每次计算后保留结果并清空栈而不是每次重新开始。这份资源的基础版本是每次独立计算如果你要改成连续模式记得在evaluate返回后把结果存到一个全局变量下次输入如果以运算符开头就自动补上上次结果。这个逻辑不复杂但容易漏掉状态清理导致栈里残留上次的数据。3.5 除零检查只做了一半现象1/0报错了但1/(2-2)没报错直接输出了inf。原因除零检查只在applyOp里做了b 0的判断但2-2的结果是0.0浮点数的0.0和整数的0在比较时是相等的理论上应该能捕获。实际翻车是因为某些编译器优化或者-0.0的情况。解决用std::abs(b) 1e-12来判断除数是否为零比直接 0更稳妥。另外如果表达式里出现了inf或nan最好在输出前检查一下std::isinf和std::isnan给用户一个友好提示而不是直接打印inf。4. 进阶玩法从能算到好用还差哪几步4.1 加一个词法分析层把表达式拆成 Token现在这份代码是边扫描边计算逻辑耦合在一起。如果你想支持更多运算符比如%、^或者变量建议先写一个tokenize函数把输入字符串拆成Token列表每个Token有类型数字、运算符、括号和值。这样主求值循环只需要遍历Token列表不用再处理字符级别的细节。代码大概长这样struct Token { enum Type { NUM, OP, LPAREN, RPAREN } type; double numVal; char opVal; }; std::vectorToken tokenize(const std::string expr) { std::vectorToken tokens; for (size_t i 0; i expr.size(); i) { char ch expr[i]; if (isspace(ch)) continue; if (isdigit(ch) || ch .) { std::string numStr; while (i expr.size() (isdigit(expr[i]) || expr[i] .)) { numStr expr[i]; } --i; tokens.push_back({Token::NUM, std::stod(numStr), 0}); } else if (ch () { tokens.push_back({Token::LPAREN, 0, ch}); } else if (ch )) { tokens.push_back({Token::RPAREN, 0, ch}); } else { tokens.push_back({Token::OP, 0, ch}); } } return tokens; }这样做的好处是求值逻辑和解析逻辑分离后面加新运算符只需要改tokenize和priority主循环不用动。代价是多了一次遍历和内存分配但对于计算器这种输入规模性能完全不是问题。4.2 用逆波兰表达式做中间层另一个常见做法是把中缀表达式转成后缀表达式逆波兰式再用一个栈求值。转换过程用调度场算法规则和双栈法类似但转换后的后缀表达式求值非常简单——遇到数字压栈遇到运算符弹两个算完压回去。这种方案的优点是求值阶段不需要优先级判断而且后缀表达式可以缓存起来重复求值如果表达式里有变量的话。缺点是代码量比双栈法多适合表达式需要多次求值或者需要做表达式优化的场景。4.3 错误恢复与用户友好提示现在的错误处理是抛异常然后打印what()信息比较粗糙。更好的做法是定义一套错误码和对应的中文提示比如ERROR_DIV_ZERO对应“除数不能为零”ERROR_MISMATCH_PAREN对应“括号不匹配”ERROR_INVALID_CHAR对应“包含非法字符”。然后在main里根据错误码输出提示而不是直接打印异常信息。另外如果用户输入了非法字符可以跳过该字符继续解析而不是整个表达式报错这样体验会好很多。4.4 一个验证技巧用随机表达式做回归测试改完代码怎么确认没改坏我一般会写一个简单的测试脚本生成一批随机表达式分别用计算器和 Python 的eval求值对比结果。Python 的eval对四则运算和括号的支持和我们的计算器一致可以作为参考实现。测试脚本大概这样import random import subprocess def random_expr(depth0): if depth 2 or random.random() 0.3: return str(random.randint(1, 100)) op random.choice([, -, *, /]) left random_expr(depth 1) right random_expr(depth 1) if op /: right str(random.randint(1, 100)) # 避免除零 return f({left} {op} {right}) for _ in range(100): expr random_expr() expected eval(expr) result subprocess.run([./calc], inputexpr \nquit\n, capture_outputTrue, textTrue) actual float(result.stdout.strip().split()[-1].strip()) if abs(expected - actual) 1e-6: print(fFAIL: {expr} expected {expected} got {actual}) break else: print(All tests passed)这个脚本会生成 100 个随机表达式逐个喂给计算器对比 Python 的求值结果。如果全部通过说明核心逻辑没问题。从那以后我每次改完计算器的求值部分都会先跑一遍这个回归测试确认没有引入新 bug 再手动测几个边界用例。这个习惯帮我省了很多后悔药——有一次我改优先级表时把*和/的优先级写反了手动测23*4居然没发现因为优先级更低先算后算结果一样但随机测试立刻报了2*34的结果不对。希望你也能养成这个习惯别等到线上出问题才回头查。本文还有配套的精品资源点击获取