简介:一款基于MFC的简易计算器项目源码,面向希望学习Windows界面编程与表达式解析的C++开发者。项目从词法分析、语法分析到后缀表达式求值,完整覆盖数字、四则运算符与括号的识别,并实现了运算符优先级校验与配对检查;在MFC对话框程序中,通过消息映射处理按钮点击事件,配合文本框实时显示输入与结果,同时加入非法表达式提示等基础错误处理。压缩包共21个文件,约2MB,以h头文件、cpp源文件、vcxproj项目配置为主,附带sln解决方案、rc资源脚本、ico图标、Release版的exe及pdb调试信息,结构清晰,可直接用Visual Studio打开编译运行。目前已有1177人学习下载,适合参考其对话框程序框架、栈式求值思路和MFC消息循环机制。通过阅读代码可掌握C++内存管理、调试工具使用以及面向对象封装技巧,也可作为课程设计或入门图形界面编程的实用样例。 做这个“基于MFC的简易计算器(表达式解析)”,其实是我当年练手C++和Windows桌面编程时认认真真啃下来的一个项目。很多人一提起计算器就觉得简单,按钮一个接一个,逻辑全写在OnBnClicked里,算“3+5”没问题,但一旦碰上“2*(3+4)-7/2”这种带括号的混合运算,程序就彻底不会玩了。这个项目真正的价值在于引入了表达式解析(Expression Parsing),让程序不再死板地按“数字、操作符、数字”这种固定格式去处理,而是像人一样理解一整串数学表达式。它涉及编译原理中的词法分析、语法分析基础,也涉及MFC的消息映射、控件交互、字符串处理,知识点密度相当高。
这个项目适合什么人来搞?已经能拖控件、能写基本按钮响应、但想更进一步理解“程序如何解析字符串形式的数学公式”的同学。也适合准备面试、想拿一个不落俗套的MFC练手项目的人。我下面从整体方案、解析原理、界面设计、代码实现、调试经验和打包发布几个角度,把这个项目从零到一完整拆解一遍。
1. 项目概述与整体方案选型
1.1 先把需求想清楚:计算器到底要算多复杂的式子
动手之前必须先确定目标,否则做着做着就会跑偏。一个最简版本的“两数计算器”只能处理一个操作符、两个操作数,但我们要做的是带“表达式解析”的计算器,所以我给自己定下的需求是:输入“3+52-6/3”这种混合运算符的式子,能自动按优先级算出正确结果;输入“2(3+4)/(5-1)”这种带括号、有嵌套的式子,也能正确算出结果;除此之外还要支持小数、清空、退格和对非法输入进行友好提示。
这里有个新手特别容易忽略的点:计算器接收的是字符串,而计算机真正能计算的是数值或者后缀表达式。我们小时候学的是中缀表达式,也就是操作符在两个操作数中间的写法,比如“3+5*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 中缀转后缀:逆波兰表达式的算法思路
在讲代码之前,必须把核心算法讲透。计算机处理表达式最顺手的形态是后缀表达式,也叫做逆波兰表达式。举个例子,“3+5*2”转换之后变成“3 5 2 * +”。这种格式的好处是:计算时不需要考虑优先级,也不需要括号,只要从左往右扫就行。遇到数字就压栈,遇到操作符就从栈里弹出两个数来算,算完再压回去。
那怎么把中缀表达式转成后缀表达式?这里要用的算法叫调度场算法(Shunting-yard Algorithm),核心逻辑是:从左到右遍历输入字符串,如果遇到数字,就输出(或者说放进结果队列);如果遇到操作符,就和栈顶的操作符比较优先级,当前操作符优先级低于或等于栈顶时,把栈顶弹出去输出,然后继续比较,直到栈为空或栈顶优先级更低,再把当前操作符压栈;如果遇到左括号,直接压栈;如果遇到右括号,一直弹出操作符输出,直到遇到左括号为止,左括号本身不输出。
我用个小例子推演一遍:“2*(3+4)”。遍历时“2”输出;“”压栈;“(”压栈;“3”输出;“+”压栈;“4”输出;“)”一到,弹出“+”输出,左括号弹掉不要;“”弹出来输出。最终得到后缀式“2 3 4 + *”。这个例子走通之后,嵌套括号也不过是反复套用同样的规则。
2.2 运算符优先级和括号匹配的处理细节
优先级表是整个算法的灵魂。我用一个函数来返回运算符的优先级,加减是1,乘除是2:
int GetPriority(char op) { if (op == '+' || op == '-') return 1; if (op == '*' || op == '/') return 2; return 0; }有了这个函数,主循环里的比较操作就非常直观了。需要注意的是,左括号也会被压入运算符栈,所以遇到右括号弹栈时要一直弹到左括号为止,但左括号本身不能放进后缀表达式里。我第一次写的时候漏了这条,弹出的字符串里带着一个'(',计算阶段一解析就崩。后来我在弹栈的地方专门加了一个判断,弹出'('就直接丢弃,不参与后续环节。
括号不匹配也是新手必踩的坑。表达式“(3+4)*2”之外,如果用户输入“((3+4)*2”这种多了一个左括号的式子,转换结束后运算符栈里会残留'(',这时候要返回一个“格式错误”的信号。如果用户输入“3+4)”这种多了一个右括号的式子,处理右括号时栈里没有对应的左括号,同样应当报错。这两类情况都必须在解析器里显式判断,不能糊里糊涂地把错误字符串交给计算函数。
2.3 小数、负数与连续运算符这些边界情况
表达式解析的坑往往不在主流逻辑,而在边界输入。我写的时候至少遇到了这些情况:负数开头的式子,比如“-5+3”;括号后跟负号,比如“2*(-3)”这种;带小数的格式,比如“3.14*2”;还有用户故意输入“2++3”这种连续运算符。
处理方案可以这样设计:在词法分析阶段,把当前字符识别为数字还是操作符之后,还要检查前一个字符。如果'-'出现在表达式开头,或者出现在左括号之后,就不应该把它当作减法操作符,而是当作负号,拼接进当前数字字符串中。小数的处理同样在数字收集阶段完成,我加了一个标记变量,确保同一个数字字符串里只能出现一个小数点,第二个小数点会被当成非法字符拦截。
连续运算符的情况,我放在界面输入层处理:编辑框内容发生变化时,检查末尾连续两个字符如果都是操作符,就拒绝第二次输入。有些人喜欢在解析器里处理,那样也能做,只不过界面层的实时反馈对用户更友好,输错了当场就能发现。
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::queue<std::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::stack<char> 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::stack<double> 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 括号不匹配的异常处理
括号问题是表达式解析最容易出错的地方之一。输入“((3+4)*2”时,中缀转后缀结束后,运算符栈里会残留一个'(',如果转换函数返回true,后面的计算就会拿到一个无法解析的token。我的处理是在转换函数结束时检查栈,如果栈里还有'(',直接返回false。另一种情况是多了一个右括号,比如“(3+4))”,这时在处理右括号的循环里如果栈已经空了,同样说明格式不对,要立刻返回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*(3+4)-7/2 => 后缀: 2 3 4 + * 7 2 / -”的信息。表达式解析这类逻辑问题,肉眼盯代码很难发现,但把中间过程打印出来,问题基本一眼就定位了。
我个人做这个项目的最大感受是:计算器表面上是个简单的练手项目,但真正把表达式解析吃透之后,再去接触数据库的SQL解析、编译器的前端处理、脚本语言的求值器,会发现它们的内核是一脉相承的。MFC负责的是形,表达式解析才是这个项目的神。如果你正在学MFC,或者想找一个同时练界面和逻辑的C++项目,这个选题很值得认真做完。先把中缀转后缀的算法写对,再去追求界面花哨,顺序千万别反。等你的计算器能正确处理“2*(3.5+4)/(5-1)”的时候,恭喜你,你已经不是那个只会拖控件的MFC新手了。
本文还有配套的精品资源,点击获取