简介:这份资源面向C++初学者与Visual Studio 2019入门开发者,围绕计算器程序展开,同时覆盖简单四则运算与括号、平方根、对数等复杂运算两种模式,帮助学习者在真实项目中理解C++语法、面向对象设计与Windows应用开发流程。压缩包共48个文件,约162.28MB,以h头文件、cpp源文件、vcxproj项目文件、sln解决方案为主,并包含obj、pdb、ilk等编译中间产物与exe可执行文件,另有rc资源脚本、ico图标及一份课程清单表格,完整呈现从源码到构建输出的工程结构。目前已有1553人学习下载。读者可借此掌握Calculator类的封装、iostream输入输出、try-catch异常处理、MFC界面搭建以及VS2019断点调试与项目配置方法,并参考附带的排错说明文档定位常见编译与运行问题,适合作为课程设计或自学练手项目。
1. 从 VS2019 计算器项目说起:为什么“简单+复杂”双模式值得动手
很多人第一次在 Visual Studio 2019 里新建 MFC 项目,都是从一个计算器开始的。但真正让这个练手项目变得有工程价值的,不是把加减乘除按钮摆出来,而是标题里那个“简单+复杂”的双模式设计——简单模式只做四则运算,复杂模式要处理括号、优先级、连续运算甚至表达式解析。这背后其实是一个经典问题:如何用同一套 UI 框架,承载两种完全不同复杂度的计算逻辑,并且让代码不变成一团乱麻。
VS2019 计算器项目适合两类人:一是刚学完 C++ 语法、想找一个能跑起来、能看见界面的练手项目;二是已经工作但想补一补 Windows 桌面开发、事件驱动模型和表达式求值算法的工程师。它不需要数据库、不需要网络,一台装了 VS2019 的 Windows 机器就能从头复现。但“简单+复杂”这个组合,恰恰是很多人翻车的地方——简单模式写得太随意,复杂模式又硬塞进去,最后按钮响应和计算逻辑搅在一起,改一个功能崩三个地方。下面按实际落地顺序,把这个项目从建工程到双模式切换、再到表达式解析的完整路径拆开讲。
2. 用 VS2019 建 MFC 计算器工程:从空项目到能响应按钮
2.1 选 MFC 还是 WinForms:先定框架再动手
在 VS2019 里做计算器,常见有三条路:MFC 对话框程序、WinForms(C++/CLI 或 C#)、WPF。标题里明确是 VS2019 计算器,且带“简单+复杂”双模式,我一般会选 MFC 对话框程序。原因很直接:MFC 是纯 C++,不引入 .NET 运行时依赖,编译出来就是一个 exe,适合练手和交付;对话框资源编辑器可以直接拖按钮,布局直观;消息映射机制能让你清楚看到“点按钮→触发函数”的完整链路,这对理解 Windows 事件驱动模型很有帮助。
WinForms 虽然拖控件更快,但 C++/CLI 语法混杂,新手容易在托管和非托管之间绕晕。WPF 的数据绑定很优雅,但学习曲线陡,一个计算器项目犯不上。所以选型结论:VS2019 + MFC 对话框程序,字符集用 Unicode,平台用 x64 或 Win32 都行,我习惯 x64。
创建步骤:打开 VS2019 → 创建新项目 → 搜索“MFC” → 选“MFC 应用” → 项目名填 Calculator → 位置选一个没有中文和空格的路径 → 应用程序类型选“基于对话框” → 取消“使用 Unicode 库”之外的额外选项 → 完成。生成后你会看到两个核心文件:CalculatorDlg.h和CalculatorDlg.cpp,以及资源视图里的IDD_CALCULATOR_DIALOG。
2.2 对话框资源布局:按钮 ID 和 Tab 顺序的坑
资源视图里打开对话框,先把默认的“TODO: 在此放置对话框控件”静态文本删掉。然后从工具箱拖按钮和编辑框。简单模式我建议布局成:一个只读编辑框显示表达式和结果,下面 4×5 的按钮网格,包含 0-9、小数点、加减乘除、等号、清空、退格。复杂模式再加一列括号按钮和几个函数按钮(比如平方、开方)。
每个按钮都要改 ID,不要用默认的 IDC_BUTTON1。命名规则我习惯用IDC_BTN_0到IDC_BTN_9,IDC_BTN_ADD、IDC_BTN_SUB、IDC_BTN_MUL、IDC_BTN_DIV、IDC_BTN_EQ、IDC_BTN_CLR、IDC_BTN_BACK、IDC_BTN_LPAREN、IDC_BTN_RPAREN。编辑框 ID 设为IDC_EDIT_DISPLAY,并勾选“只读”和“多行”去掉,保持单行。
这里有一个血泪经验:Tab 顺序一定要调。资源编辑器里按 Ctrl+D 可以查看和调整 Tab 顺序,顺序错了会导致键盘操作时焦点乱跳,用户按数字键结果跑到别的控件上。调整原则是按视觉从上到下、从左到右。
2.3 消息映射与按钮响应:别把逻辑写进 OnBnClicked
MFC 里给按钮加响应,右键按钮 → 添加事件处理程序 → 选 BN_CLICKED → 类选CCalculatorDlg→ 函数名自动生成,比如OnBnClickedBtn0。新手最容易犯的错是在每个OnBnClickedBtnX里直接写计算逻辑,比如OnBnClickedBtnAdd里直接做加法。这样简单模式能跑,但复杂模式一进来就崩,因为表达式解析需要统一的输入流。
正确做法是:所有按钮响应只做一件事——把对应的字符追加到一个成员变量CString m_expr里,然后刷新编辑框显示。等号按钮才触发真正的计算。下面是一个最小可运行的按钮响应代码示例:
// CalculatorDlg.h 中添加成员变量 CString m_expr; // 当前表达式 bool m_bComplex; // 是否复杂模式 // CalculatorDlg.cpp 中按钮响应 void CCalculatorDlg::OnBnClickedBtn0() { m_expr += _T("0"); UpdateDisplay(); } void CCalculatorDlg::OnBnClickedBtnAdd() { // 防止连续运算符,简单处理:如果末尾已是运算符则替换 if (!m_expr.IsEmpty()) { TCHAR last = m_expr[m_expr.GetLength() - 1]; if (last == _T('+') || last == _T('-') || last == _T('*') || last == _T('/')) { m_expr.SetAt(m_expr.GetLength() - 1, _T('+')); UpdateDisplay(); return; } } m_expr += _T("+"); UpdateDisplay(); } void CCalculatorDlg::UpdateDisplay() { SetDlgItemText(IDC_EDIT_DISPLAY, m_expr); }逻辑说明:m_expr是唯一的数据源,所有按钮只修改它,不直接算结果。UpdateDisplay负责把m_expr同步到编辑框。参数说明:_T()宏用于兼容 Unicode 和多字节字符集,虽然我们选了 Unicode,但保持这个习惯没坏处。SetAt用于替换末尾字符,避免出现5++3这种非法表达式。
等号按钮的响应先留空,下一章再填。清空按钮直接把m_expr置空并刷新,退格按钮用m_expr.Delete(m_expr.GetLength()-1)并刷新。到这里,简单模式的输入框架就搭好了,编译运行应该能看到按钮点下去编辑框有反应。
3. 简单模式:四则运算的状态机与边界处理
3.1 用状态机管住运算符和数字的交替
简单模式看起来只是加减乘除,但如果不做状态约束,用户能输入5++3、5..3、/5这种非法表达式。我一般用一个轻量状态机来管:定义两个状态STATE_INPUT_NUM和STATE_INPUT_OP,初始为STATE_INPUT_NUM。按数字键时,如果当前是STATE_INPUT_OP,先追加一个空格或直接切换状态;按运算符时,如果当前是STATE_INPUT_OP,就替换末尾运算符而不是追加。
更简单的做法是在按钮响应里做末尾字符检查,就像上一章OnBnClickedBtnAdd那样。但状态机的好处是逻辑集中,后面复杂模式也能复用。下面是一个可抄的状态机版本:
enum InputState { STATE_NUM, STATE_OP }; InputState m_state = STATE_NUM; void CCalculatorDlg::AppendDigit(TCHAR ch) { if (m_state == STATE_OP) { m_expr += _T(" "); m_state = STATE_NUM; } m_expr += ch; UpdateDisplay(); } void CCalculatorDlg::AppendOperator(TCHAR op) { if (m_expr.IsEmpty()) { if (op == _T('-')) // 允许负号开头 { m_expr += op; UpdateDisplay(); } return; } if (m_state == STATE_OP) { // 替换最后一个运算符 int len = m_expr.GetLength(); m_expr.SetAt(len - 1, op); } else { m_expr += op; m_state = STATE_OP; } UpdateDisplay(); }逻辑说明:AppendDigit在运算符后自动补空格,方便后续用Token拆分。AppendOperator处理了空表达式时只允许负号的情况,以及连续运算符替换。参数说明:m_state是成员变量,每次清空按钮要重置为STATE_NUM。
3.2 等号触发求值:中缀转后缀再计算
简单模式的求值,我建议直接上中缀转后缀(逆波兰)再计算,不要用eval那种字符串拼接。虽然四则运算可以用两个栈边扫边算,但写成中缀转后缀后,复杂模式的括号和优先级可以直接复用同一套代码。下面是最小实现:
#include <vector> #include <stack> #include <string> int Priority(TCHAR op) { if (op == _T('+') || op == _T('-')) return 1; if (op == _T('*') || op == _T('/')) return 2; return 0; } double Evaluate(const CString& expr) { std::vector<CString> tokens; CString token; for (int i = 0; i < expr.GetLength(); i++) { TCHAR ch = expr[i]; if (ch == _T(' ')) continue; if ((ch >= _T('0') && ch <= _T('9')) || ch == _T('.')) { token += ch; } else { if (!token.IsEmpty()) { tokens.push_back(token); token.Empty(); } CString op; op += ch; tokens.push_back(op); } } if (!token.IsEmpty()) tokens.push_back(token); std::vector<CString> output; std::stack<CString> ops; for (auto& t : tokens) { if (t == _T("+") || t == _T("-") || t == _T("*") || t == _T("/")) { while (!ops.empty() && Priority(ops.top()[0]) >= Priority(t[0])) { output.push_back(ops.top()); ops.pop(); } ops.push(t); } else { output.push_back(t); } } while (!ops.empty()) { output.push_back(ops.top()); ops.pop(); } std::stack<double> vals; for (auto& t : output) { if (t == _T("+") || t == _T("-") || t == _T("*") || t == _T("/")) { double b = vals.top(); vals.pop(); double a = vals.top(); vals.pop(); if (t == _T("+")) vals.push(a + b); else if (t == _T("-")) vals.push(a - b); else if (t == _T("*")) vals.push(a * b); else vals.push(a / b); } else { vals.push(_ttof(t)); } } return vals.top(); }逻辑说明:先按空格和运算符拆分 token,数字和小数点归为一个 token。然后标准调度场算法转后缀,最后后缀求值。参数说明:_ttof是CString转double的 TCHAR 版本,对应atof。除零没有处理,实际项目里要在除法前判断b == 0并弹提示。
等号按钮响应里调用Evaluate(m_expr),把结果转成CString显示,并把m_expr重置为结果字符串,方便连续运算。这里有一个坑:浮点数显示要控制精度,直接用%f会显示一堆小数位。我一般用CString::Format(_T("%.10g"), result),%g会自动去掉末尾多余的零。
3.3 简单模式的三个边界:负号、小数点、除零
负号处理:如果表达式以-开头,中缀转后缀时会被当成运算符,但栈里没有左操作数。解决办法是在 token 化阶段,如果-出现在表达式开头或左括号后,就把它和后面的数字合并成一个负数 token。简单模式可以只允许开头负号,复杂模式再处理括号后的负号。
小数点:要防止一个数字里出现两个小数点。在AppendDigit里,如果ch == '.',检查当前 token 是否已包含.。可以维护一个bool m_bHasDot,按运算符或清空时重置。
除零:在Evaluate的除法分支里,如果b == 0,抛出异常或返回一个特殊值,UI 层捕获后显示“除数不能为零”。不要直接让程序崩溃。
4. 复杂模式:括号、优先级与表达式解析的工程化
4.1 复杂模式不是简单模式加括号,而是换一套输入约束
简单模式的状态机只区分数字和运算符,复杂模式要额外处理左括号、右括号和函数。如果直接在简单模式上打补丁,状态机会迅速膨胀。我的做法是:复杂模式单独一套输入校验规则,但复用同一套中缀转后缀求值代码。输入校验规则如下:
- 左括号可以出现在开头、运算符后、另一个左括号后。
- 右括号只能出现在数字或右括号后,且左括号数量必须大于右括号。
- 运算符不能出现在左括号后(负号除外),不能出现在表达式开头(负号除外)。
- 等号按下时,左右括号数量必须相等。
这些规则用一个ValidateExpression函数在每次输入后检查,不合法就拒绝追加并给出提示。下面是一个校验片段:
bool CCalculatorDlg::CanAppend(TCHAR ch) { if (m_expr.IsEmpty()) { return (ch >= _T('0') && ch <= _T('9')) || ch == _T('(') || ch == _T('-'); } TCHAR last = m_expr[m_expr.GetLength() - 1]; if (ch == _T('(')) { return last == _T('+') || last == _T('-') || last == _T('*') || last == _T('/') || last == _T('('); } if (ch == _T(')')) { int left = 0, right = 0; for (int i = 0; i < m_expr.GetLength(); i++) { if (m_expr[i] == _T('(')) left++; if (m_expr[i] == _T(')')) right++; } return left > right && (last == _T(')') || (last >= _T('0') && last <= _T('9'))); } if (ch == _T('+') || ch == _T('*') || ch == _T('/')) { return last != _T('(') && last != _T('+') && last != _T('-') && last != _T('*') && last != _T('/'); } if (ch == _T('-')) { return true; // 负号在复杂模式里允许出现在左括号后 } // 数字和小数点 return true; }逻辑说明:CanAppend在按钮响应里先调用,返回 false 就不追加。参数说明:last是当前表达式末尾字符,括号计数每次遍历,表达式不长时性能没问题。复杂模式的按钮响应统一走AppendDigit和AppendOperator,但AppendOperator里对-要特殊处理,允许它出现在左括号后作为负号。
4.2 中缀转后缀支持括号:调度场算法的两个修改点
上一章的Evaluate已经支持括号,但需要补两个地方:一是遇到左括号直接入栈;二是遇到右括号时,弹出栈顶运算符直到遇到左括号,并把左括号弹出但不输出。修改后的核心循环如下:
for (auto& t : tokens) { if (t == _T("(")) { ops.push(t); } else if (t == _T(")")) { while (!ops.empty() && ops.top() != _T("(")) { output.push_back(ops.top()); ops.pop(); } if (!ops.empty()) ops.pop(); // 弹出左括号 } else if (t == _T("+") || t == _T("-") || t == _T("*") || t == _T("/")) { while (!ops.empty() && ops.top() != _T("(") && Priority(ops.top()[0]) >= Priority(t[0])) { output.push_back(ops.top()); ops.pop(); } ops.push(t); } else { output.push_back(t); } }逻辑说明:左括号入栈后,它下面的运算符不会被弹出,保证了括号内优先级独立。右括号触发弹出直到左括号。参数说明:Priority函数不变,左括号不参与优先级比较,所以遇到左括号就停止弹出。
4.3 复杂模式的 UI 切换:一个复选框控制两套按钮
简单模式和复杂模式共用同一个对话框,我一般加一个复选框IDC_CHECK_COMPLEX,勾选时显示括号和函数按钮,取消时隐藏。按钮的显示隐藏用GetDlgItem(IDC_BTN_LPAREN)->ShowWindow(SW_SHOW/SW_HIDE)。同时把m_bComplex置为对应状态,清空当前表达式,重置状态机。
这里有一个容易忽略的点:隐藏按钮后,Tab 顺序里仍然包含它们,键盘操作会跳到隐藏按钮上。解决办法是在切换时调用EnableWindow(FALSE)而不只是ShowWindow,或者动态调整 Tab 顺序。我一般直接EnableWindow,简单可靠。
复杂模式的求值仍然调用同一个Evaluate,但要在求值前检查括号匹配。如果左右括号数量不等,弹提示“括号不匹配”。另外,复杂模式建议支持连续运算,即等号后把结果作为下一次运算的起点,而不是清空。
5. 避坑与排查:VS2019 计算器项目里最容易翻车的 5 个点
5.1 现象:编译报错“无法打开包括文件 afxwin.h”
原因:VS2019 安装时没有勾选“使用 C++ 的桌面开发”里的 MFC 组件。MFC 不是默认安装的,很多人装完 VS2019 直接建 MFC 项目就报这个错。
解决:打开 Visual Studio Installer → 修改 → 单个组件 → 搜索“MFC” → 勾选“适用于最新 v142 生成工具的 C++ MFC” → 安装。安装后重启 VS2019,重新生成项目。
5.2 现象:按钮点下去没反应,编辑框不更新
原因:消息映射没生效,或者按钮 ID 和响应函数没对上。常见的是手动改了按钮 ID 但没重新生成映射,或者ON_BN_CLICKED宏里的 ID 写错。
解决:在CalculatorDlg.cpp的BEGIN_MESSAGE_MAP和END_MESSAGE_MAP之间检查每个ON_BN_CLICKED(IDC_BTN_X, &CCalculatorDlg::OnBnClickedBtnX)是否和头文件里的声明一致。改完 ID 后重新右键按钮添加事件处理程序,让 VS 自动生成映射。
5.3 现象:表达式求值结果不对,比如5+3*2算出 16
原因:中缀转后缀时优先级比较写反了,或者后缀求值时操作数顺序搞错。5+3*2正确是 11,如果先算5+3再乘 2 就是 16。
解决:检查Priority函数,乘除返回 2,加减返回 1。检查调度场循环里Priority(ops.top()[0]) >= Priority(t[0])的条件,必须是>=才能保证同级从左到右。后缀求值时,先弹出的b是右操作数,后弹出的a是左操作数,减法和除法不能反。
5.4 现象:复杂模式输入(-3)求值崩溃
原因:token 化时-被当成运算符,但栈里没有左操作数。或者中缀转后缀时-直接入栈,后缀求值时栈里只有一个操作数。
解决:在 token 化阶段,如果-出现在表达式开头或左括号后,把它和后面的数字合并成一个负数 token。具体做法是遍历时记录前一个非空格字符,如果是(或开头,且当前是-,就继续读取数字拼成-3这样的 token。
5.5 现象:浮点结果显示0.30000000000000004
原因:二进制浮点数无法精确表示十进制小数,0.1+0.2在计算机里不等于0.3。
解决:显示时用%.10g或%.12g格式化,%g会自动去掉末尾无意义的零。如果对精度要求高,可以用整数运算模拟定点小数,或者引入decimal库。计算器项目里%.10g足够。
6. 进阶技巧:把计算器改成可扩展的表达式引擎
如果你已经跑通了简单+复杂双模式,下一步我建议把求值部分抽成一个独立的ExpressionEngine类,不依赖 MFC。这样你可以单独写单元测试,也可以把同一套引擎用到控制台程序或别的 UI 框架里。具体做法:新建一个ExpressionEngine.h和.cpp,把Evaluate、Priority、token 化逻辑全部移进去,输入用std::string或std::wstring,输出用double加错误码。
抽离之后,MFC 对话框只负责收集输入和显示结果,逻辑和界面彻底解耦。测试的时候,在 VS2019 里新建一个控制台测试项目,引用ExpressionEngine.cpp,写几个断言:
#include <cassert> #include "ExpressionEngine.h" int main() { ExpressionEngine engine; assert(engine.Evaluate("5+3*2") == 11); assert(engine.Evaluate("(5+3)*2") == 16); assert(engine.Evaluate("10/4") == 2.5); assert(engine.Evaluate("-3+5") == 2); assert(engine.Evaluate("2*(3+4)-5") == 9); return 0; }逻辑说明:每个断言覆盖一个典型场景,包括优先级、括号、除法、负号。参数说明:Evaluate返回double,比较时注意浮点误差,简单场景可以直接==,复杂场景用fabs(a-b) < 1e-9。
再进一步,你可以给引擎加函数支持,比如sqrt、sin、cos。做法是在 token 化时识别字母序列,在调度场算法里把函数当成右结合运算符,优先级最高。后缀求值时遇到函数名就弹出一个操作数计算。这样你的计算器就从四则运算升级到了科学计算器,但核心架构没变。
我自己的习惯是:每加一个新功能,先在控制台测试项目里写断言,跑通了再接到 MFC 界面上。这样界面调试的时间会少很多,也不会因为一个按钮响应写错就怀疑整个算法。希望帮到你。
本文还有配套的精品资源,点击获取