ARTICLE DETAIL

资讯详情

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

基于MFC的表达式解析计算器:中缀转后缀与界面交互实战

基于MFC的表达式解析计算器:中缀转后缀与界面交互实战 简介一款基于MFC的简易计算器项目源码面向希望学习Windows界面编程与表达式解析的C开发者。项目从词法分析、语法分析到后缀表达式求值完整覆盖数字、四则运算符与括号的识别并实现了运算符优先级校验与配对检查在MFC对话框程序中通过消息映射处理按钮点击事件配合文本框实时显示输入与结果同时加入非法表达式提示等基础错误处理。压缩包共21个文件约2MB以h头文件、cpp源文件、vcxproj项目配置为主附带sln解决方案、rc资源脚本、ico图标、Release版的exe及pdb调试信息结构清晰可直接用Visual Studio打开编译运行。目前已有1177人学习下载适合参考其对话框程序框架、栈式求值思路和MFC消息循环机制。通过阅读代码可掌握C内存管理、调试工具使用以及面向对象封装技巧也可作为课程设计或入门图形界面编程的实用样例。 做这个“基于MFC的简易计算器表达式解析”其实是我当年练手C和Windows桌面编程时认认真真啃下来的一个项目。很多人一提起计算器就觉得简单按钮一个接一个逻辑全写在OnBnClicked里算“35”没问题但一旦碰上“2*(34)-7/2”这种带括号的混合运算程序就彻底不会玩了。这个项目真正的价值在于引入了表达式解析Expression Parsing让程序不再死板地按“数字、操作符、数字”这种固定格式去处理而是像人一样理解一整串数学表达式。它涉及编译原理中的词法分析、语法分析基础也涉及MFC的消息映射、控件交互、字符串处理知识点密度相当高。这个项目适合什么人来搞已经能拖控件、能写基本按钮响应、但想更进一步理解“程序如何解析字符串形式的数学公式”的同学。也适合准备面试、想拿一个不落俗套的MFC练手项目的人。我下面从整体方案、解析原理、界面设计、代码实现、调试经验和打包发布几个角度把这个项目从零到一完整拆解一遍。1. 项目概述与整体方案选型1.1 先把需求想清楚计算器到底要算多复杂的式子动手之前必须先确定目标否则做着做着就会跑偏。一个最简版本的“两数计算器”只能处理一个操作符、两个操作数但我们要做的是带“表达式解析”的计算器所以我给自己定下的需求是输入“352-6/3”这种混合运算符的式子能自动按优先级算出正确结果输入“2(34)/(5-1)”这种带括号、有嵌套的式子也能正确算出结果除此之外还要支持小数、清空、退格和对非法输入进行友好提示。这里有个新手特别容易忽略的点计算器接收的是字符串而计算机真正能计算的是数值或者后缀表达式。我们小时候学的是中缀表达式也就是操作符在两个操作数中间的写法比如“35*2”。计算机不会直接按“先乘除后加减”的口诀去算它需要一套明确的规则来说明优先级。这个规则你必须自己写出来而这套写出来的过程就是表达式解析的核心。1.2 为什么选MFC而不是Qt、WPF或纯控制台技术选型这件事没有绝对的好坏只有适不适合当前场景。纯控制台程序当然也能做表达式解析但完全没有界面交互做完之后拿到桌面上点不了按钮体验感差很多。Qt功能强大也跨平台可对于刚开始学Windows桌面开发的人来说信号槽、元对象系统这些概念会额外增加学习成本。WPF界面确实好看但那就基本切换到了C#的世界和“练C MFC”的目标背道而驰。MFC的好处在于它是Windows原生的一套C框架封装了Win32 API对话框程序拖控件、加消息映射、处理控件事件都是标准流程而且学MFC的过程本身就是深入理解Windows消息机制的过程。VS2013的MFC项目向导更是省事选择“基于对话框”点两下就生成了程序框架剩下的核心工作全在业务逻辑里。现实一点说MFC打包出来的程序体积也小页面响应快拿去做课设、去面试演示都很顺手不依赖各种重型运行时环境。1.3 整体架构界面层、控制层与解析层分离如果按很多教程的做法按钮点击后直接在响应函数里堆一堆字符串处理代码小程序也能跑但一旦要加功能、调bug你就等着头疼。我一开始就决定把表达式解析单独封装成一个类不写在对话框类的按钮响应里。这样做的好处很直接界面层只负责“收集输入、触发计算、显示结果”控制层拿到文本之后调用解析器解析器内部完成中缀转后缀和求值的完整流程。哪一层出了问题直接定位到那一层不用满屏幕文件翻来找去。另外一个更实际的好处是方便测试。解析器独立之后我可以在临时测试代码里直接给函数喂字符串不需要每次都打开程序、点按钮、看界面。这个“逻辑与界面分离”的习惯我后来写任何项目都在用调试体验差别真的很大。2. 表达式解析的核心原理2.1 中缀转后缀逆波兰表达式的算法思路在讲代码之前必须把核心算法讲透。计算机处理表达式最顺手的形态是后缀表达式也叫做逆波兰表达式。举个例子“35*2”转换之后变成“3 5 2 * ”。这种格式的好处是计算时不需要考虑优先级也不需要括号只要从左往右扫就行。遇到数字就压栈遇到操作符就从栈里弹出两个数来算算完再压回去。那怎么把中缀表达式转成后缀表达式这里要用的算法叫调度场算法Shunting-yard Algorithm核心逻辑是从左到右遍历输入字符串如果遇到数字就输出或者说放进结果队列如果遇到操作符就和栈顶的操作符比较优先级当前操作符优先级低于或等于栈顶时把栈顶弹出去输出然后继续比较直到栈为空或栈顶优先级更低再把当前操作符压栈如果遇到左括号直接压栈如果遇到右括号一直弹出操作符输出直到遇到左括号为止左括号本身不输出。我用个小例子推演一遍“2*(34)”。遍历时“2”输出“”压栈“(”压栈“3”输出“”压栈“4”输出“)”一到弹出“”输出左括号弹掉不要“”弹出来输出。最终得到后缀式“2 3 4 *”。这个例子走通之后嵌套括号也不过是反复套用同样的规则。2.2 运算符优先级和括号匹配的处理细节优先级表是整个算法的灵魂。我用一个函数来返回运算符的优先级加减是1乘除是2int GetPriority(char op) { if (op || op -) return 1; if (op * || op /) return 2; return 0; }有了这个函数主循环里的比较操作就非常直观了。需要注意的是左括号也会被压入运算符栈所以遇到右括号弹栈时要一直弹到左括号为止但左括号本身不能放进后缀表达式里。我第一次写的时候漏了这条弹出的字符串里带着一个(计算阶段一解析就崩。后来我在弹栈的地方专门加了一个判断弹出(就直接丢弃不参与后续环节。括号不匹配也是新手必踩的坑。表达式“(34)*2”之外如果用户输入“((34)*2”这种多了一个左括号的式子转换结束后运算符栈里会残留(这时候要返回一个“格式错误”的信号。如果用户输入“34)”这种多了一个右括号的式子处理右括号时栈里没有对应的左括号同样应当报错。这两类情况都必须在解析器里显式判断不能糊里糊涂地把错误字符串交给计算函数。2.3 小数、负数与连续运算符这些边界情况表达式解析的坑往往不在主流逻辑而在边界输入。我写的时候至少遇到了这些情况负数开头的式子比如“-53”括号后跟负号比如“2*(-3)”这种带小数的格式比如“3.14*2”还有用户故意输入“23”这种连续运算符。处理方案可以这样设计在词法分析阶段把当前字符识别为数字还是操作符之后还要检查前一个字符。如果-出现在表达式开头或者出现在左括号之后就不应该把它当作减法操作符而是当作负号拼接进当前数字字符串中。小数的处理同样在数字收集阶段完成我加了一个标记变量确保同一个数字字符串里只能出现一个小数点第二个小数点会被当成非法字符拦截。连续运算符的情况我放在界面输入层处理编辑框内容发生变化时检查末尾连续两个字符如果都是操作符就拒绝第二次输入。有些人喜欢在解析器里处理那样也能做只不过界面层的实时反馈对用户更友好输错了当场就能发现。3. MFC界面搭建与交互设计3.1 对话框框架和控件布局的思路我用VS2013的MFC应用程序向导选择了“基于对话框”框架自动生成后核心就是往对话框模板上摆放控件。布局上我放了两个Edit Control上面一个IDC_EDIT_DISPLAY用来显示最终结果下面一个IDC_EDIT_INPUT用来显示按钮输入的完整表达式也允许用户直接手动输入。两个编辑框分开的好处是用户既能看清自己输到一半的式子也能一眼看到结果不会混淆。数字按钮0到9一共10个加减乘除4个运算符按钮再加上小数点、等号、清除、退格基本就齐了。布局参考了Windows系统自带的计算器数字区三列运算符在右侧或左侧我用的是运算符放右侧。每个控件都设置了对应的ID数字键用IDC_BUTTON_0这种命名运算符键用IDC_BUTTON_ADD这类命名命名清晰能省掉后面大量的查错时间。3.2 消息映射用共用的处理函数响应成批按钮MFC处理按钮点击本质上是在处理BN_CLICKED通知消息。在对话框类的头文件里声明处理函数然后在BEGIN_MESSAGE_MAP和END_MESSAGE_MAP之间用ON_BN_CLICKED宏把控件ID和处理函数关联起来ON_BN_CLICKED(IDC_BUTTON_0, CCalculatorDlg::OnBnClickedNum) ON_BN_CLICKED(IDC_BUTTON_1, CCalculatorDlg::OnBnClickedNum) // ... ON_BN_CLICKED(IDC_BUTTON_ADD, CCalculatorDlg::OnBnClickedOperator)关键点在于数字按钮全都共用一个处理函数OnBnClickedNum运算符按钮共用一个处理函数OnBnClickedOperator。处理函数内部再通过GetDlgItem(ID)-GetWindowText取出对应按钮的文本然后把文本追加到显示编辑框里。用控件ID来区分来源比每个按钮写一个独立的处理函数要清爽得多。这个“相似控件共用一个处理函数、用ID区分具体来源”的模式在MFC项目里非常常见值得记住。3.3 显示区优化控件自适应与刷新问题实际用起来还有个很影响体验的问题窗口拉大或缩小时控件尺寸不会自动跟着变结果就是伸缩几次之后界面变得非常难看。这个问题的标准解法是响应WM_SIZE消息在OnSize里根据客户区实际大小用MoveWindow或SetWindowPos去动态调整子控件的位置和尺寸。基准坐标一定要用GetClientRect拿到的客户区坐标而不是屏幕分辨率因为窗口可能没有最大化直接用屏幕尺寸会算错。另外编辑框显示内容更新后偶尔会出现界面刷新不及时的情况尤其是你连续SetDlgItemText同一个编辑框的时候。我一般会在关键更新后调用Invalidate(FALSE)强制触发重绘基本都能解决。还有一个视觉细节编辑框设了ReadOnly之后背景会变灰如果介意的话可以用SetReadOnly(FALSE)再加上手动拦截按键输入的方式既能防止用户改结果区又能保持白底黑字的正常观感。4. 核心代码实现与解析4.1 解析器类的设计思路我把解析器封装成一个独立的类CExpressionParser对外只暴露一个接口。调用方不需要关心内部是转后缀还是直接递归求值只要传入中缀表达式字符串函数返回true表示计算成功结果通过引用参数带回来返回false表示表达式有误由界面层弹出错误提示。class CExpressionParser { private: std::queuestd::string m_tokens; int GetPriority(char op); bool InfixToPostfix(const std::string expr); bool IsNumber(const std::string token); double CalculatePostfix(); public: bool Calculate(const std::string expr, double result); };这里我要特别说明一个设计选择解析器内部用std::string而不是CString。MFC的CString确实方便但解析器如果过度依赖MFC后续做单元测试、换平台、写命令行调试代码都会多一层牵制。我的做法是在对话框层把CString转成std::string再传给解析器解析器内部全部使用标准库的string、queue、stack。这样控制台调试代码几乎可以原封不动地复用。4.2 中缀转后缀和后缀求值的主干代码下面的代码是我实现版的简化骨架完整功能还包括多位数字、小数点、负号和错误标记但主干逻辑就是这样bool CExpressionParser::InfixToPostfix(const std::string expr) { std::stackchar ops; std::string num; for (size_t i 0; i expr.length(); i) { char ch expr[i]; if (isdigit(ch) || ch .) { num ch; } else { if (!num.empty()) { m_tokens.push(num); num.clear(); } if (ch () { ops.push(ch); } else if (ch )) { while (!ops.empty() ops.top() ! () { m_tokens.push(std::string(1, ops.top())); ops.pop(); } if (!ops.empty()) ops.pop(); } else if (ch || ch - || ch * || ch /) { while (!ops.empty() GetPriority(ops.top()) GetPriority(ch)) { m_tokens.push(std::string(1, ops.top())); ops.pop(); } ops.push(ch); } } } if (!num.empty()) m_tokens.push(num); while (!ops.empty()) { if (ops.top() () return false; m_tokens.push(std::string(1, ops.top())); ops.pop(); } return true; }后缀表达式的计算稍微简单一些double CExpressionParser::CalculatePostfix() { std::stackdouble st; while (!m_tokens.empty()) { std::string token m_tokens.front(); m_tokens.pop(); if (IsNumber(token)) { st.push(atof(token.c_str())); } else { double b st.top(); st.pop(); double a st.top(); st.pop(); if (token ) st.push(a b); else if (token -) st.push(a - b); else if (token *) st.push(a * b); else if (token /) { if (b 0) return false; st.push(a / b); } } } return st.top(); }代码里有一个很容易踩的坑减法操作时先弹出的是右操作数后弹出的是左操作数所以必须是a-b而不是b-a。我最初写反过一次导致“5-3”的结果变成了-2调试了半个小时才发现。除法同理a/b才是正确的顺序别让b/a背锅。4.3 主对话框里的按钮响应与计算流程用户点等号按钮时我把整个流程串起来读取输入编辑框里的文本去掉空格如果有明显非法字符就直接提示否则交给解析器计算成功后用CString格式化显示到结果编辑框。格式化格式我用的是%g这个格式会自动去掉无意义的尾零“5”显示成5而不是5.00而小数又不会因为固定格式化位数被截断或补零。void CCalculatorDlg::OnBnClickedEqual() { CString strExpr; GetDlgItemText(IDC_EDIT_INPUT, strExpr); if (strExpr.IsEmpty()) return; CExpressionParser parser; double result 0.0; if (parser.Calculate(std::string(CT2A(strExpr)), result)) { CString strResult; strResult.Format(_T(%g), result); SetDlgItemText(IDC_EDIT_DISPLAY, strResult); } else { AfxMessageBox(_T(表达式错误请检查输入)); } }这个流程本身不长但每一层的职责很清晰编辑框提供文本解析器负责计算结果显示区负责输出。真正要改逻辑的时候只需要动解析器界面几乎不用碰。5. 实际调试中遇到的问题与排查方法5.1 减法除法连续计算错误操作数顺序惹的祸这是我调试中遇到的第一个印象深刻的bug。输入“8-3-2”预期结果是3程序却输出7。我一开始以为是优先级处理出了问题后来单步跟踪才发现后缀表达式转换是对的“8 3 2 - -”但计算第二次减法时栈里先弹出2再弹出3代码却写成了2-3结果自然是错的。操作数顺序在减法除法中极其重要每次写这段代码时都要反复确认弹出的第一个数是操作符右边的数。5.2 括号不匹配的异常处理括号问题是表达式解析最容易出错的地方之一。输入“((34)*2”时中缀转后缀结束后运算符栈里会残留一个(如果转换函数返回true后面的计算就会拿到一个无法解析的token。我的处理是在转换函数结束时检查栈如果栈里还有(直接返回false。另一种情况是多了一个右括号比如“(34))”这时在处理右括号的循环里如果栈已经空了同样说明格式不对要立刻返回false。5.3 除零与非法字符的提示策略除零错误我在后缀计算阶段直接判断如果除数为0就返回计算失败上层对话框弹窗提示。非法字符的校验我放在解析器内部做界面层只负责把文本传给解析器解析器在转换之前先遍历一遍字符出现既不是数字、小数点、合法运算符、也不是括号的字符就返回false。这样做的好处是不管用户是通过按钮输入还是直接往编辑框粘贴文本校验逻辑都不会被绕过去。5.4 编辑框显示和刷新的小毛病MFC编辑框内容更新后一般会自动重绘但如果程序里有多个控件频繁交互偶尔会出现显示不及时的问题。我在更新结果区之后加了一次Invalidate(FALSE)强制编辑框重绘这个动作成本很低但确实解决了我遇到的界面卡住不刷新的问题。另外用SetWindowText和SetDlgItemText更新控件内容时如果字符串里有中文要留意项目字符集设置VS2013默认使用Unicode字符集CString和std::string的转换最好用CT2A这种转换宏避免出现乱码。6. 项目打包与后续扩展经验6.1 MFC项目打包的三种方式程序写完之后不管拿去交课设还是给别人演示都要考虑打包发布。VS2013的MFC程序默认动态链接MFC库换了一台没装对应开发环境的机器运行时会弹窗报错说缺少MSVCR120.dll这类组件。解决方式有三种我实际都用过体验差别很大。第一种是在项目属性中找到“常规”下的“使用MFC”从“在共享DLL中使用MFC”改成“在静态库中使用MFC”这样MFC库会被编译进exe里。exe体积会增加到几十MB但目标机器基本都能直接运行。第二种是带着对应版本的Visual C Redistributable安装包一起分发程序体积小了但对方必须先装运行库。第三种是用VS自带的“Release配置部署工具”生成安装包把依赖一并打包。我的习惯是给同学演示时用静态链接省得对方还得额外装运行环境。6.2 控件自适应与界面美化的扩展方向基础功能做完之后这个计算器其实是一个很好的扩展试验田。先说控件自适应我在第3节讲过用OnSize动态调整控件位置再往深处做可以把按钮区域做成“九宫格”式伸缩也就是窗口变化时上方的编辑框拉伸宽度下方的按钮网格按比例缩放所有控件都相对于客户区取百分比坐标而不是固定像素。再说按钮外观很多人问“MFC里怎么做自定义按钮”。标准的做法是使用自绘按钮Owner Draw给按钮设置BS_OWNERDRAW样式然后重写DrawItem函数在函数里自己绘制背景、边框、文字甚至图标。做计算器的时候用不上复杂的自绘但如果你想做一个好看的皮肤从自绘数字按钮开始练习是最合适的路径。MFC的CButton还支持位图背景简单场景下直接SetBitmap也能快速见效。6.3 加入历史记录和扩展数学函数如果你想继续往上加功能优先级最高的两个扩展是历史记录和数学函数。历史记录可以用MFC的CListBox控件计算完成后把“表达式 结果”这一行字符串InsertString到列表框里再配合记忆清除按钮就能实现像Windows计算器那样的历史列表。整个过程几乎不用改动解析器代码只是在界面层增加一个控件、一组消息响应。数学函数的扩展需要动解析器。比较简单的做法是把函数名当做一个高优先级的“运算符”来处理比如识别到“sin(”之后先处理括号里的表达式计算出结果之后再回调sin函数求值。如果你已经把中缀转后缀的代码写明白了扩展起来并不难难的是要考虑函数的参数个数和优先级比如单目函数sin、cos、sqrt和双目运算符、-、*、/在优先级表里的位置不一样。这个阶段反而最能锻炼你对表达式结构的理解。6.4 推荐一个自查好用的调试技巧最后分享一个我自己用得很顺手的小技巧。在解析器里增加一个调试模式开关可以控制是否把每次转换后的后缀表达式输出到输出调试窗口用OutputDebugString输出。这样你运行程序时结合VS的“输出”面板能实时看到类似“中缀: 2*(34)-7/2 后缀: 2 3 4 * 7 2 / -”的信息。表达式解析这类逻辑问题肉眼盯代码很难发现但把中间过程打印出来问题基本一眼就定位了。我个人做这个项目的最大感受是计算器表面上是个简单的练手项目但真正把表达式解析吃透之后再去接触数据库的SQL解析、编译器的前端处理、脚本语言的求值器会发现它们的内核是一脉相承的。MFC负责的是形表达式解析才是这个项目的神。如果你正在学MFC或者想找一个同时练界面和逻辑的C项目这个选题很值得认真做完。先把中缀转后缀的算法写对再去追求界面花哨顺序千万别反。等你的计算器能正确处理“2*(3.54)/(5-1)”的时候恭喜你你已经不是那个只会拖控件的MFC新手了。本文还有配套的精品资源点击获取
返回列表