ARTICLE DETAIL

资讯详情

深耕网站建设与运营推广的一线实战洞察。

C++计算器程序实战:表达式解析与调度场算法详解

C++计算器程序实战:表达式解析与调度场算法详解 简介这款C计算器程序是一份完整的可运行项目面向学习面向对象编程和基本交互逻辑的C初学者也可作为课程设计参考。程序中实现了加减乘除等基础运算并通过栈来支持撤销已输入数字体现了类设计、运算符重载、STL容器以及输入输出流在真实程序中的配合用法同时包含除零检查等异常处理思路可帮助读者理解程序健壮性的常见做法。压缩包共28个文件以头文件、cpp源文件、可执行文件和说明文档为主整体约283KB还包含Visual C6.0工程配置及调试辅助文件便于直接运行、对照检查和二次开发。已有599人下载学习读者可以借助源码组织、错误处理和界面逻辑学习C项目从编写、编译到生成可执行程序的完整流程并根据说明文档进一步修改扩展功能。 C计算器程序这个题目在很多人眼里就是个入门练手的小东西但实际上真把它做完整涉及的知识量一点不比一个中型项目少。字符串解析、运算符优先级处理、异常输入兜底、浮点精度问题哪一样都能让人踩上半天坑。我去年用 C 完整实现了一个计算器程序从控制台版本一路做到带图形界面的版本前前后后重构了三遍今天把完整的设计思路、核心代码和踩坑记录整理出来给正在做这个项目或者想拿它练手的朋友做个参考。1. 功能边界与整体方案设计1.1 什么叫“全部功能”——先把需求列清楚动手写代码之前我花了整整一个晚上把“全部功能”这个词拆开。如果只说“能算加减乘除”那这个项目三天就能写完。可日常使用的计算器不管是手机上的、Windows 自带的还是物理计算器其实包含一系列容易被忽略的能力支持多位数、小数、负数输入比如-3.14 2这种表达式支持括号嵌套能正确计算((23)*4-1)/3这种复杂式子连续运算时能自动处理优先级23*4的结果必须是 14 而不是 20支持退格、清除、清空全部类似键盘上 Backspace 和 C 键的逻辑处理非法输入和异常情况比如除数为零、表达式不完整、括号不匹配支持键盘输入而不是只能点按钮这对桌面应用来说几乎是个默认要求我把这些拆成了两个版本。第一版聚焦核心计算引擎跑在控制台输入一行表达式输出结果第二版加了图形界面用鼠标按键输入同时保留键盘操作能力。两个版本共用同一套计算核心只是 UI 层不同。这样做的最大好处是计算逻辑和展示层彻底解耦调试时只要专注看计算引擎的输入输出就行。1.2 技术路线对比控制台、Qt 还是 Web 前端关于界面形态我一开始纠结了很久。当时对比了三条技术路线方案优点缺点适合场景纯控制台程序零依赖、编译即可运行、专注算法本身交互笨拙没有按钮学习表达式解析核心逻辑Qt Widgets跨平台、控件丰富、信号槽机制清晰需要学习 Qt 框架、安装环境较麻烦想做桌面 GUI 应用Web 前端 C 后端界面美观灵活技术栈割裂不适合单机小工具想同时练前后端我最终的方案是“两端共用核心引擎”计算核心用纯 C 标准库完成控制台版本直接调用GUI 版本用 Qt 5 的 QLineEdit 和 QPushButton 搭了一个简易界面按钮点击时把字符拼成表达式字符串再调用同一个引擎求值。这样既保证了核心算法的独立性也能让读者根据自己的兴趣选择去实现哪一层。2. 表达式解析原理与核心数据结构2.1 为什么不能按顺序读数字直接算这是很多新手第一次卡住的地方。比如用户输入23*4如果程序从头到尾读先看到 2再看到 再看到 3然后直接算 235接着乘 4 得 20这个结果就是错的因为乘法优先级高于加法正确结果应该是 14。这还只是最简单的优先级问题。加一个括号进去比如(23)*4按顺序读就彻底崩了——左括号不知道什么时候才能“闭合”括号内部的子表达式是一块完整的整体不能简单地拆成一个个平铺的 token 来处理。顺带说一句括号的本质是改变优先级而不是一个运算符号。它的作用相当于把一块区域“圈”起来让里面的内容先被完整求值再参与外部运算。理解这一点就能明白为什么后面所有算法都在围绕“让优先级高的先算、让括号内的先算”来展开。2.2 中缀转后缀调度场算法的思路处理优先级和括号的主流方案有两种一种是递归下降解析写一个语法分析器递归地处理表达式中的每个部分另一种是把人类习惯的“中缀表达式”比如 23*4转换成“后缀表达式”也叫逆波兰表达式比如 2 3 4 * 再让计算机按顺序直接求值。我选了后者因为它的代码结构更直观理解成本更低而且每一步都可以打印中间结果来调试。后缀表达式的核心特点操作数在前运算符在后表达式从左到右扫描遇到操作数就压栈遇到运算符就弹出两个操作数计算结果再压回栈中。因为运算符的顺序已经通过转换规则排好了所以求值时不再需要考虑优先级。中缀转后缀最经典的算法就是 Dijkstra 的调度场算法Shunting-yard algorithm整个过程只需要一个运算符栈。从左到右扫描 token如果是数字直接输出到结果队列如果是运算符弹出栈中所有优先级不低于当前运算符的栈顶元素直到遇到左括号或栈空再压入当前运算符如果是左括号直接压栈如果是右括号弹出栈顶运算符到结果队列直到遇到左括号弹出并丢弃这个规则的核心思想是当一个新的运算符到来时栈里那些优先级不低于它的运算符已经“可以安全结算”了所以先输出让它们在后缀表达式里排在新运算符前面——这样求值时高优先级的运算会先被计算。2.3 Token 类型设计与词法分析做词法分析之前要把表达式拆成最小单位——token。我定义了一个枚举类型来区分不同类型的 tokenenum class TokenType { Number, Plus, // Minus, // - Multiply, // * Divide, // / LParen, // ( RParen, // ) End // 表达式结束标记 };词法分析器做的事情很简单逐个字符扫描输入字符串把连续的数字字符拼成一个完整的数字 token把单个运算符字符映射成对应的运算符 token。这里有个容易忽略的坑减法符号和负号。像-35这种以负号开头的表达式以及2*(-3)这种括号后跟负号的情况负号其实是一元运算符不是二元减法。我的做法是如果-出现在表达式开头或者紧跟在左括号或另一个运算符后面就把它当作数字的一部分处理解析成负数。判断条件写起来不长但能避免一大堆边缘情况。3. 核心代码实现与实操细节3.1 词法分析器实现词法分析器的代码是整个项目里最直白的部分核心就是一个带状态的循环。我把它写成独立函数输入字符串输出一个std::vectorTokenstruct Token { TokenType type; double value; // 当 type 为 Number 时有效 }; std::vectorToken tokenize(const std::string expr) { std::vectorToken tokens; size_t i 0; double currentNumber 0.0; bool inNumber false; while (i expr.size()) { char ch expr[i]; if (std::isspace(ch)) { i; continue; } if (std::isdigit(ch) || ch .) { size_t start i; while (i expr.size() (std::isdigit(expr[i]) || expr[i] .)) { i; } std::string numStr expr.substr(start, i - start); tokens.push_back({TokenType::Number, std::stod(numStr)}); inNumber false; continue; } // 处理负号出现在开头或前一个 token 是运算符 / 左括号按负数处理 if (ch -) { bool unaryMinus tokens.empty() || tokens.back().type TokenType::LParen || tokens.back().type TokenType::Multiply || tokens.back().type TokenType::Divide || tokens.back().type TokenType::Plus || tokens.back().type TokenType::Minus; if (unaryMinus) { // 处理 -3.14 这种情况直接把负号合并到下一个数字里 i; size_t start i; while (i expr.size() (std::isdigit(expr[i]) || expr[i] .)) { i; } std::string numStr expr.substr(start, i - start); tokens.push_back({TokenType::Number, -std::stod(numStr)}); continue; } } switch (ch) { case : tokens.push_back({TokenType::Plus, 0}); break; case -: tokens.push_back({TokenType::Minus, 0}); break; case *: tokens.push_back({TokenType::Multiply, 0}); break; case /: tokens.push_back({TokenType::Divide, 0}); break; case (: tokens.push_back({TokenType::LParen, 0}); break; case ): tokens.push_back({TokenType::RParen, 0}); break; default: throw std::runtime_error(无法识别的字符); } i; } return tokens; }这个实现有一个潜在问题如果用户输入了3..14或者2.5.6这种多小数点数字std::stod会只解析到第二个小数点前为止多余的内容会被忽略或抛异常。为了避免这种情况我后来在词法分析阶段加了检查每个数字 token 中最多只能有一个小数点否则直接抛异常。这个细节虽然小但能挡掉很多来自真实用户的手滑输入。3.2 调度场算法核心实现拿到 token 序列之后接下来用调度场算法生成后缀表达式队列std::vectorToken shuntingYard(const std::vectorToken tokens) { std::vectorToken output; std::vectorToken opStack; for (const auto tok : tokens) { if (tok.type TokenType::Number) { output.push_back(tok); } else if (tok.type TokenType::Plus || tok.type TokenType::Minus || tok.type TokenType::Multiply || tok.type TokenType::Divide) { int precedence (tok.type TokenType::Plus || tok.type TokenType::Minus) ? 1 : 2; while (!opStack.empty()) { Token top opStack.back(); if (top.type TokenType::LParen) { break; } int topPrec (top.type TokenType::Plus || top.type TokenType::Minus) ? 1 : 2; if (topPrec precedence) { output.push_back(top); opStack.pop_back(); } else { break; } } opStack.push_back(tok); } else if (tok.type TokenType::LParen) { opStack.push_back(tok); } else if (tok.type TokenType::RParen) { while (!opStack.empty() opStack.back().type ! TokenType::LParen) { output.push_back(opStack.back()); opStack.pop_back(); } if (opStack.empty()) { throw std::runtime_error(括号不匹配缺少左括号); } opStack.pop_back(); // 弹出左括号 } } while (!opStack.empty()) { if (opStack.back().type TokenType::LParen) { throw std::runtime_error(括号不匹配缺少右括号); } output.push_back(opStack.back()); opStack.pop_back(); } return output; }两个容易忽略的细节写在注释里了右括号处理完要检查栈是否为空防止用户输入(23))这种多了一个右括号的情况最后要把栈里所有剩余运算符弹出此时如果栈中还有左括号说明左括号没有匹配的右括号。实现细节值得展开说说优先级判断。这里我用了一个整数来表示优先级加减为 1乘除为 2。比较topPrec precedence时用“”而不是“”是为了保证同优先级运算符按从左到右的顺序计算。以2-34为例如果不弹出同优先级的栈顶减号后缀表达式会变成2 3 4 -算出来的结果是 -5而正确后缀表达式是2 3 - 4 结果是 3。这一点在调试时非常容易栽跟头。3.3 后缀表达式求值实现后缀表达式求值非常简单只要一个数字栈double evaluateRPN(const std::vectorToken rpn) { std::vectordouble stack; for (const auto tok : rpn) { if (tok.type TokenType::Number) { stack.push_back(tok.value); } else { if (stack.size() 2) { throw std::runtime_error(表达式不合法运算符缺少操作数); } double b stack.back(); stack.pop_back(); double a stack.back(); stack.pop_back(); switch (tok.type) { case TokenType::Plus: stack.push_back(a b); break; case TokenType::Minus: stack.push_back(a - b); break; case TokenType::Multiply: stack.push_back(a * b); break; case TokenType::Divide: if (b 0.0) { throw std::runtime_error(除数不能为零); } stack.push_back(a / b); break; default: throw std::runtime_error(未知运算符); } } } if (stack.size() ! 1) { throw std::runtime_error(表达式不合法操作数过多); } return stack.back(); }这里有一个我自己踩过坑的细节弹出两个数字时第一个弹出的是 b右操作数第二个弹出的是 a左操作数。减法a - b和除法a / b的顺序一定不能反否则算出来完全错误。我最初写的时候把顺序搞反了导致所有的减法和除法结果都是错的调试了很久才发现是变量赋值顺序的问题。这种错误编译器不会报错结果也不会太离谱但就是不对只能通过一点点打印中间结果来排查。还有个细节是1/3 * 3这种浮点运算会产生0.333333 * 3 0.999999显示出来可能带一长串小数。实际使用中我做了结果格式化超过 8 位小数就四舍五入避免把2/3显示成0.666666666666这种没法看的数。但要注意格式化只影响显示内部计算仍用完整精度否则连续运算的误差会累积到不可接受的程度。3.4 控制台交互与循环框架核心引擎完成之后控制台版本就非常简单了。我写了一个run()函数来承载交互主循环void run() { std::cout C 计算器输入 exit 退出 std::endl; std::string line; while (true) { std::cout ; std::getline(std::cin, line); if (line exit || line quit) break; if (line.empty()) continue; try { auto tokens tokenize(line); auto rpn shuntingYard(tokens); double result evaluateRPN(rpn); std::cout formatResult(result) std::endl; } catch (const std::exception e) { std::cout 错误: e.what() std::endl; } } }主循环结构很简单但有一个很重要的设计决策一定要用std::getline读整行而不是std::cin 一个字符一个字符地读。用getline的好处是当用户输入一个完整表达式后所有字符都交给词法分析器统一处理逻辑清晰也不会有缓冲区残留问题。整个计算引擎我用类重新封装了一下对外只暴露一个接口double calculate(const std::string expr)内部把 tokenize、shuntingYard、evaluateRPN 串起来。这样 GUI 版本和未来的任何扩展都只需要调用这个接口。4. 常见 bug 与排错经验记录4.1 常见问题速查表把这几个月开发过程中遇到的典型 bug 整理成了一张表方便后来者对照排查症状根本原因解决方案减法结果总是错的求值时弹出操作数顺序反了先弹 b后弹 a计算 a - b连续同优先级运算结果不对调度场算法弹出条件用了而不是改为topPrec precedence输入(23程序崩溃字符串结束时栈中还有左括号遍历结束后检查运算符栈是否为空为什么1/0结果是inf没有做除零检查在求值阶段验证除数是否为零负数开头的表达式解析失败词法分析器把-当作二元运算符增加一元负号检测逻辑输入2..3没有报错std::stod只解析到第一个非法字符在词法分析阶段校验数字格式4.2 几个印象深刻的调试过程第一个印象深刻的是浮点精度问题。我当时随手测试0.1 0.2结果打印出来是0.30000000000000004和大多数人第一次遇到这个问题时的反应一样我第一反应是计算器坏了。后来确认IEEE 754 浮点数本来就无法精确表示所有十进制小数这属于 C 语言本身的特性不是逻辑错误。处理方案有两种一种是显示层做格式化保留合理的小数位数另一种是改用十进制运算库或者用整数模拟定点运算。对于计算器这种应用格式化足够了没必要引入额外依赖。第二个印象深刻的是 GUI 版本和核心引擎的字符编码问题。Qt 的 QString 和 C 字符串之间的转换、按钮中文标签的显示都容易出乱码。我最后是用QString::fromUtf8统一处理中文按钮的全角符号和半角符号也要统一否则用户在键盘上输入*和点击界面上×对应的字符串不一致会导致解析失败。为了兼容我做了层映射把×、÷这些全角符号统一转换成*、/再做解析。第三个要提的是用户输入防护。比如用户直接在文本框里粘贴了一个包含换行符或者多个空格表达式或者输入了2这种不完整的表达式程序都要能返回错误而不是崩溃。我实现了一个通用的错误机制所有解析和计算失败的路径都抛异常由最外层统一捕获并显示错误信息。这是我建议新手一定要养成的习惯——只要涉及外部输入的程序就要假设任何输入都是可能的。5. 项目扩展的几点个人体会计算器程序做完之后如果你想继续深化有几个不错的方向一个是把表达式引擎扩展成支持科学计算函数比如sin、cos、sqrt、pow、log。这个扩展中词法分析器需要识别多字符单词调度场算法需要把函数名作为一种特殊 token 来处理求值时根据函数名调用对应的库函数。做完这一步你对“如何在语法层面区分变量名和保留字”会有直观的理解——这几乎是所有脚本语言编译器的入门课题。另一个方向是给表达式引擎支持变量比如x 3之后输入x * 2能得到 6。这意味着要增加一个符号表来维护变量名和值的映射还要在词法分析阶段把变量名识别为一个新的 token 类型。熟悉了这套机制再去看解释器的实现代码结构会非常眼熟——本质上都是一个 lexer 加 parser 加 evaluator 的三段式结构。从我自己重构这三遍的经验来看计算器项目最重要的价值不在于“做出了一个能用的东西”而在于它强迫你去理解一台机器究竟是怎么识别数学表达式的。整个过程就是从“程序按行执行”的直觉思维切换到“程序按 token 流解析”的结构化思维。项目做完后我明显感觉自己对栈、队列、递归这些基础数据结构的掌控感强了很多——这种收获比计算器本身有意义得多。本文还有配套的精品资源点击获取
返回列表