
简介这是一份基于C与MFC框架编写的Windows计算器程序工程适合正在学习MFC界面开发或Windows桌面应用入门的开发者参考。工程以MFC对话框为主界面实现了基本运算、次方、开方、科学函数等功能并通过消息映射与控件事件完成交互代码模块划分明确能帮助读者快速理解MFC应用的组织方式。压缩包共44个文件以C头文件h、源文件cpp和编译中间文件obj为主同时包含Visual Studio工程文件dsp/sln/dsw、资源文件rc/bmp/ico以及可直接运行的exe程序整包仅484KB目录结构简洁。目前已有276人学习下载。通过该项目读者可获得一套完整的计算器源码与工程配置既能直接编译运行也可以基于现有代码扩展界面皮肤、增加连续计算或进制转换功能是练习C对象封装与Windows GUI逻辑的不错范例。1. 用 C 和 MFC 写计算器为什么到现在还值得做一遍计算器是那种看起来谁都能写、但要写到一个能交付的程度反而很考验基本功的程序。标题里的几个关键词——C、MFC、漂亮界面——放在一起说的不是玩具而是很多企业内部一直残留的那类工具几 MB 的 exe、双击即用、不依赖 Web 容器却在界面上做了远超默认控件的视觉处理。这恰恰是 MFC 计算器到今天仍然有需求的原因在锁死的离线环境里你没法指望目标机器装了新运行时而 MFC 静态链接出来的单文件可执行程序是最可控的交付物。下面按工程骨架、表达式求值、界面美化、发布验证四条线走完整条路径代码直接用 VS 新建一个 MFC 工程就能跟着落。2. 搭建 MFC 计算器工程基于 Dialog 的框架与按键布局2.1 用 MFC 应用程序向导生成基于对话框的工程VS 里新建项目选 Visual C → MFC → MFC 应用程序在向导第二页的“应用程序类型”里选“基于对话框”。基于对话框的意思是主窗口直接由 CDialogEx 派生没有菜单、工具栏和文档/视图结构最适合计算器这种固定面板形态的界面。这一步如果选成单文档后面要回来改窗口类、隐藏菜单、处理文档模板凭空多出一堆和计算器无关的消息路由。生成出来的工程自带一个 IDD_MFCALC_DIALOG 对话框资源默认放了一个 StaticText 和两个按钮全部删掉。显示区用一个 Edit Control属性里 Read Only 设为 True目的是禁止用户直接在编辑框内输入字符键盘输入后续走 PreTranslateMessage 统一接管所以不需要让编辑框本身可编辑。另一个常见坑是属性里的 Number 选项千万不要勾勾上之后小数点和负号会被 Windows 层的输入校验直接滤掉表达式的括号和运算符全都进不来。按钮全部用标准 Button 控件图标和位图样式这里都不需要后面用 CMFCButton 做美化。项目创建后先确认“项目属性 → 常规 → 字符集”是“使用 Unicode 字符集”。MFC 在 Unicode 下 CString 是宽字符版本所有字符串字面量用 _T() 宏包一层避免之后在中文 Windows 路径下出现编码和资源加载问题。2.2 计算器按键布局与连续 ID 的命名约定界面布局按下表排。第一行是显示区第二行开始是按键区列宽尽量一致按钮之间的间距保持在 4 到 6 像素。行第 1 列第 2 列第 3 列第 4 列1显示区 Edit跨 4 列2C⌫%÷3789×4456−51236±0.数字键的 ID 建议按 IDC_BTN_0 到 IDC_BTN_9 连续分配运算符单独命名成 IDC_BTN_ADD、IDC_BTN_SUB、IDC_BTN_MUL、IDC_BTN_DIV等号用 IDC_BTN_EQU清零用 IDC_BTN_CLEAR。连号的价值要到写消息处理时才体现出来十个数字键只需要一次区间判断就能全部分派不用写十个 ON_BN_CLICKED 宏。在资源编辑器中手工添加控件时resource.h 里生成的 ID 值不保证连续需要在资源视图里打开 ID 定义把 IDC_BTN_1 到 IDC_BTN_9 的值手动调成紧挨 IDC_BTN_0。这个细节在代码量小的计算器里不影响功能但直接影响消息处理的可读性。2.3 用 OnCommand 统一分派按钮点击消息传统 MFC 写法是给每个按钮单独映射一个点击回调十个数字键就要写十个函数。计算器这种按键密集的界面更适合重写 CDialogEx 的 OnCommand在一个函数里按控件 ID 完成分派BOOL CMFCCalcDlg::OnCommand(WPARAM wParam, LPARAM lParam) { UINT nID LOWORD(wParam); if (nID IDC_BTN_0 nID IDC_BTN_9) { AppendChar(_T(0) (nID - IDC_BTN_0)); return TRUE; } switch (nID) { case IDC_BTN_ADD: AppendOp(_T()); break; case IDC_BTN_SUB: AppendOp(_T(-)); break; case IDC_BTN_MUL: AppendOp(_T(*)); break; case IDC_BTN_DIV: AppendOp(_T(/)); break; case IDC_BTN_EQU: Evaluate(); break; case IDC_BTN_CLEAR: Reset(); break; default: return CDialogEx::OnCommand(wParam, lParam); } return TRUE; }LOWORD(wParam) 取到的是触发命令的控件 IDHIWORD(wParam) 是通知码。按钮点击产生的 BN_CLICKED 通知码值为 0所以这里不判通知码不会出问题但如果你同时希望响应回车键触发的默认按钮消息就要在默认分支里保留 CDialogEx 的调用链。AppendChar 负责把单个数字字符追加到当前表达式缓冲区AppendOp 负责追加运算符并顺带做一次合法性校验——比如不允许表达式以 或 * 开头。Evaluate 是调表达式解析器的地方这部分在第 3 章展开。2.4 对话框最小尺寸与显示区拉伸策略常见的 MFC 计算器都是一个固定大小对话框就完事但高分屏上用户如果把窗口拉大控件不跟着走会非常难看。这里最稳妥的策略是显示区跟随窗口宽度变化按钮区保持设计尺寸并整体靠下居中。先记录显示区的初始矩形再在 WM_SIZE 里按新窗口宽度重新定位显示区void CMFCCalcDlg::OnSize(UINT nType, int cx, int cy) { CDialogEx::OnSize(nType, cx, cy); if (GetDlgItem(IDC_DISPLAY) nullptr) return; if (m_bLayoutReady) { CRect rc m_displayInitRect; rc.right cx - 12; // 右边距保持 12 像素 GetDlgItem(IDC_DISPLAY)-MoveWindow(rc); } }m_bLayoutReady 在 OnInitDialog 里记录完初始尺寸后置 true。第一次触发 OnSize 时控件可能还没初始化完必须做空指针保护。显示区变宽之后等号按钮不用动因为按钮区位置没变用户操作肌肉记忆不会被打断。这种“只拉伸信息展示区不缩放按键”的布局是计算器工具里最符合使用直觉的做法。3. 计算核心用 C 实现表达式解析与求值3.1 单步计算与完整表达式求值的差距很多网上能找到的 MFC 计算器源码走的是“单步”路线输入一个数选运算符再输一个数按等号立即算完内部状态只有两个 double 变量。这个实现三十分钟能写完但用户体验也就是个普通手持计算器水平。标题指向的 C 计算器核心差异在于能不能一次解析12.53*(4-2)这种完整表达式按一次等号得到 18.5。表达式求值有三条常见路线直接写递归下降语法分析、转逆波兰式再求值、用表达式模板库。计算器这种语法足够小的场景调度场算法Shunting-yard配上后续的 RPN 求值是最常见、最稳的选型。它的优点是把“语法分析”拆成“分词 栈操作”两个步骤每一步都可以单独打印出来调试后续要加开方、取反、百分比也只是增加 token 类型和运算符优先级表不需要动整个语法分析框架。3.2 Tokenizer把输入字符串切成 Token先定义 Token 结构struct Token { enum Type { NUM, OP, LPAREN, RPAREN } type; double value; // type NUM 时有效 char op; // type OP 时有效_ 表示一元负号 };再用一个函数把 CString 转成 std::string 后做分词std::vectorToken tokenize(const std::string s) { std::vectorToken tokens; size_t i 0; while (i s.size()) { char c s[i]; if (std::isspace((unsigned char)c)) { i; continue; } if (std::isdigit((unsigned char)c) || c .) { size_t start i; bool hasDot false; while (i s.size() (std::isdigit((unsigned char)s[i]) || s[i] .)) { if (s[i] .) { if (hasDot) throw std::invalid_argument(小数点重复); hasDot true; } i; } tokens.push_back({Token::NUM, std::stod(s.substr(start, i - start)), 0}); continue; } if (std::string(-*/()).find(c) ! std::string::npos) { tokens.push_back({Token::OP, 0, c}); i; continue; } throw std::invalid_argument(std::string(非法字符: ) c); } return tokens; }这个函数有三个要点。第一isdigit 的参数必须转成 unsigned char否则在扩展字符集下会触发未定义行为。第二小数点重复检测放在分词阶段拦截比放到求值阶段再报错要友好得多。第三非法字符直接抛 std::invalid_argumentUI 层在调用点捕获然后弹一条消息不能让异常跨过 OnCommand 边界。注意MFC 的 Unicode 工程里 CString 是宽字符转 std::string 时用 CT2A 宏。如果不想引入字符集转换的额外负担整个求值核心也可以用 std::wstring 改写。3.3 调度场算法中缀转后缀与运算符优先级运算符优先级表如下。一元负号用_表示和二元减号在求值阶段区分开。运算符优先级结合性 -二元1左结合* /2左结合_一元负号3右结合调度场算法的实现int precedence(char op) { if (op || op -) return 1; if (op * || op /) return 2; if (op _) return 3; return 0; } bool isOperator(char op) { return op || op - || op * || op / || op _; } std::vectorToken shuntingYard(const std::vectorToken tokens) { std::vectorToken output; std::vectorToken opStack; bool expectOperand true; // 判断一元负号 for (const Token t : tokens) { if (t.type Token::NUM) { output.push_back(t); expectOperand false; } else if (t.type Token::LPAREN) { opStack.push_back(t); expectOperand true; } else if (t.type Token::RPAREN) { while (!opStack.empty() opStack.back().type ! Token::LPAREN) { output.push_back(opStack.back()); opStack.pop_back(); } opStack.pop_back(); // 弹出左括号 expectOperand false; } else if (isOperator(t.op)) { if (t.op - expectOperand) { Token unary{Token::OP, 0, _}; while (!opStack.empty() isOperator(opStack.back().op) precedence(opStack.back().op) precedence(unary.op)) { output.push_back(opStack.back()); opStack.pop_back(); } opStack.push_back(unary); expectOperand true; } else { while (!opStack.empty() isOperator(opStack.back().op) precedence(opStack.back().op) precedence(t.op)) { output.push_back(opStack.back()); opStack.pop_back(); } opStack.push_back(t); expectOperand true; } } } while (!opStack.empty()) { if (opStack.back().type Token::LPAREN) throw std::invalid_argument(括号不匹配); output.push_back(opStack.back()); opStack.pop_back(); } return output; }expectOperand 是整个算法的关键状态表达式的开头、或者是左括号之后、或者是运算符之后都应该期望一个操作数。当下一个 token 是-时如果当前处在这个状态就把这个减号当作一元负号处理否则它是二元减号。右括号弹出运算符时如果发现栈里没有左括号说明括号不匹配也是在 RPN 转换阶段就能发现的问题。3.4 RPN 求值、除零保护与浮点显示double evalRPN(const std::vectorToken rpn) { std::vectordouble st; for (const Token t : rpn) { if (t.type Token::NUM) { st.push_back(t.value); } else if (t.op _) { st.back() -st.back(); } else { double b st.back(); st.pop_back(); double a st.back(); st.pop_back(); if (t.op / b 0) throw std::overflow_error(除数为零); switch (t.op) { case : st.push_back(a b); break; case -: st.push_back(a - b); break; case *: st.push_back(a * b); break; case /: st.push_back(a / b); break; } } } if (st.size() ! 1) throw std::invalid_argument(表达式不完整); return st.back(); }除零检查放在弹栈之后、除法运算之前。注意 pop 的顺序减法和除法都是a - b、a / b先弹出来的是右操作数 b后弹出来的是左操作数 a顺序写反会导致3-5算成5-3。表达式结束后栈里应该有且只有一个元素这个检查能兜住23*、(12这类输入。UI 层调用 Evaluate 时try { auto tokens tokenize(expr); auto rpn shuntingYard(tokens); double result evalRPN(rpn); m_display.SetWindowText(CString(CT2A(CString(_T()), CP_UTF8))); // 实际用 CString::Format } catch (const std::exception e) { AfxMessageBox(CString(e.what())); Reset(); }显示层用CString::Format(_T(%.10g), result)输出结果。%g 会自动去掉末尾多余的零把 1/3 显示成 0.333333而不是把浮点误差连着一起显示出来。限制 10 位精度是为了避免0.10.2显示成 0.30000000000000004 这种观感很差的结果。4. 让计算器界面更漂亮自绘按钮、配色与高 DPI 适配4.1 用 CMFCButton 做自绘按钮免去手工 DrawItem从 VS2008 SP1 开始MFC 提供了 CMFCButton按钮的美化不需要再自己继承 CButton 写 DrawItem。在 OnInitDialog 里通过 SubclassDlgItem 把资源上的普通按钮替换成 CMFCButton然后逐项设置外观// 等号按钮主色填充、白字、手形光标 m_btnEqu.SubclassDlgItem(IDC_BTN_EQU, this); m_btnEqu.SetFaceColor(RGB(70, 120, 220), TRUE); m_btnEqu.SetTextColor(RGB(255, 255, 255)); m_btnEqu.SetMouseCursorHand(); // 数字按钮浅灰底、深色字 m_btn7.SubclassDlgItem(IDC_BTN_7, this); m_btn7.SetFaceColor(RGB(245, 245, 245), FALSE); m_btn7.SetTextColor(RGB(40, 40, 40));SetFaceColor 的第二个参数是 bHighlight。TRUE 时 MFC 会把给定颜色和系统高亮色混合视觉效果更柔和FALSE 则是直接使用给定颜色。计算器的配色约定就是功能区分数字键浅灰、运算符键中灰、等号键高亮色、清零键用暖色提醒。这套视觉规律不需要设计功底用户第一眼就能知道哪里该按。控件类型推荐底色文字颜色鼠标光标数字键RGB(245,245,245)RGB(40,40,40)箭头运算符键RGB(225,228,235)RGB(30,30,30)箭头等号键RGB(70,120,220)白色手形清零键RGB(220,170,50)白色手形4.2 OnCtlColor 统一对话框背景和文字配色按钮美化完成之后剩下的 StaticText 和 Edit 控件如果还是系统默认灰色整个界面会显得割裂。对话框的控件背景色由 WM_CTLCOLOR 消息控制MFC 里对应的虚函数是 OnCtlColorHBRUSH CMFCCalcDlg::OnCtlColor(CDC* pDC, CWnd* pWnd, UINT nCtlColor) { if (nCtlColor CTLCOLOR_STATIC) { pDC-SetBkMode(TRANSPARENT); pDC-SetTextColor(RGB(60, 60, 60)); return (HBRUSH)m_bgBrush.GetSafeHandle(); } if (nCtlColor CTLCOLOR_EDIT) { pDC-SetTextColor(RGB(40, 40, 40)); pDC-SetBkColor(RGB(250, 250, 252)); } return CDialogEx::OnCtlColor(pDC, pWnd, nCtlColor); }m_bgBrush 是 CBrush 类型的成员变量在 OnInitDialog 里用m_bgBrush.CreateSolidBrush(RGB(238, 240, 245))创建。Static 文本设置成透明背景并返回画刷可以避免文字周围出现一块灰色的底。Edit 控件的底色单独调成接近白色让显示区和按钮区形成层次。这个函数里判断 nCtlColor 的优先级很重要Static 和 Edit 分支不能写反因为有些控件会同时触发多个颜色消息。4.3 用 GDI 渐变背景替代纯色面板纯色背景的“漂亮”还是有限要想让界面有别于默认风格步骤是链接 gdiplus.lib、包含 Gdiplus 头文件然后处理 WM_ERASEBKGND 画一个垂直渐变背景。这样做的好处是不影响控件自身的绘制也不会引起明显的闪烁BOOL CMFCCalcDlg::OnEraseBkgnd(CDC* pDC) { CRect rc; GetClientRect(rc); Gdiplus::Graphics g(pDC-GetSafeHdc()); Gdiplus::Rect r(0, 0, rc.Width(), rc.Height()); Gdiplus::LinearGradientBrush br(r, Gdiplus::Color(238, 240, 245), Gdiplus::Color(210, 220, 235), Gdiplus::LinearGradientModeVertical); g.FillRectangle(br, r); return TRUE; }OnEraseBkgnd 返回 TRUE 之后对话框背景就是自己画的渐变按钮是独立的子窗口会被系统或 CMFCButton 自己的绘制逻辑覆盖所以不冲突。GDI 的类名要注意和 MFC 自带的 CRect、CDC 区分这里用的都是Gdiplus::前缀避免using namespace Gdiplus和 MFC 的头文件产生名字冲突。渐变色不要选饱和度过高的计算器是高频使用工具浅色系比深色系更耐看。如果想要更进一步的圆角按钮CMFCButton 支持通过SetRoundedType来设置圆角但圆角半径过大时文字会贴边8 到 12 像素的效果比较合适。4.4 高 DPI 环境下的控件自适应方案“MFC 控件自适应屏幕分辨率”是网上的高频问题。最常用的最小改动方案是在 CWinApp::InitInstance 里、创建主对话框之前调用SetProcessDPIAware()让窗口系统按物理像素渲染而不是按 96 DPI 虚拟化缩放。只用这一行计算器在 150% 缩放的屏幕上就不会出现字体模糊。但 SetProcessDPIAware 只解决系统级的缩放问题对话框内部的字体和控件布局仍然需要配合。如果显示区字太小在 OnInitDialog 里创建字体并设置到 Edit 控件上m_displayFont.CreateFont(-20, 0, 0, 0, FW_BOLD, FALSE, FALSE, 0, DEFAULT_CHARSET, OUT_DEFAULT_PRECIS, CLIP_DEFAULT_PRECIS, DEFAULT_QUALITY, DEFAULT_PITCH, _T(Microsoft YaHei UI)); GetDlgItem(IDC_DISPLAY)-SetFont(m_displayFont);CreateFont 的第一个参数用负号表示按字符像素高度指定20 像素显示在 144 DPI 下大约 15pt在 96 DPI 下约 10pt。更精细的做法是把字体大小也按照 DPI 比例缩放但计算器的使用频率不高字体在我们设定的高度下只要保证等号按钮和数字按钮的文字清晰即可。布局方面计算器不应该做成每个控件都随窗口等比例缩放——那会导致按钮在高分辨率下被拉扁。相对更合理的是让按钮区固定显示区随宽度伸缩这样窗口不管拉多大操作区域始终稳定。5. 发布验证把 MFC 计算器打包成单个可执行文件5.1 静态链接 MFC 与 C 运行库去掉运行库依赖如果你接触过从这个标题所在那类压缩包解出来的现成 exe大概率见过目标机器上弹出“缺少 VCRUNTIME140.dll”或“没有找到 MFC140U.dll”的弹窗。要彻底避开这类问题做法是静态链接项目属性 → 常规 → MFC 的使用选“在静态库中使用 MFC”C/C → 代码生成 → 运行库选“多线程 (/MT)”。改完重新编译后exe 会把 MFC 库和 C 运行库一起编进去体积从几百 KB 涨到几 MB但换来的是任意 Windows 机器都能双击运行连 Visual C 运行库合集都不用装。这里要注意 Debug 和 Release 配置分开处理日常开发用 Debug 动联系方式便于调试发布时切到 Release 再改静态链接。Debug 版本静态链接会生成体积更大的输出而且会把调试堆也带进去性能和内存占用都不适合交付。5.2 在干净环境里验证依赖确认单文件可用把 Release 的 exe 单独复制到一台没有安装过 Visual Studio、没有装过任何运行库的 Windows 虚拟机或沙箱双击运行输入12.53*(4-2)按等号再试一次8/0看异常提示是否正常弹出。这一步能一次性暴露三类问题依赖缺失、DPI 模糊、异常处理崩溃。验证通过后这个 exe 就是唯一需要随压缩包分发的文件。现象原因处理缺少 VCRUNTIME140.dll运行库用了 /MD改成 /MT 重编缺少 MFC140U.dllMFC 选了共享 DLL改成静态库中的 MFC按钮字体发虚没有 DPI 感知声明InitInstance 调 SetProcessDPIAware 或在 manifest 声明 PerMonitorV25.3 压缩包里现成 exe 的安全提醒最后说一个和标题直接相关的实际问题从资源站转存的 rar 包里如果带了一个编好的 exe不要急着双击运行。来源不明的可执行文件可能被改过、加壳甚至捆绑了额外的东西。你自己在 VS 里把工程重新建一遍把前几章的代码贴进去编译拿自己这份输出去做发布验证——这样既得到了功能一致的计算器也确认了每一条代码路径都是可靠的。用源码自己重新编译是拿到这类“漂亮界面 MFC 计算器”资源后最稳妥的验证方式。本文还有配套的精品资源点击获取