ARTICLE DETAIL

资讯详情

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

VS2019 C++计算器工程全解析:从MFC消息映射到表达式解析

VS2019 C++计算器工程全解析:从MFC消息映射到表达式解析 简介这是一份基于VS2019开发的C计算器项目设计了简单四则运算与复杂数学运算两种模式特别适合刚入门C的学习者用于跟随完整案例进行上机练习。源码工程覆盖了类与对象、iostream输入输出、异常处理、基础界面搭建以及VS2019调试与构建流程读者可以借此理解从需求分析、模块划分、编码测试到生成可执行文件的软件开发链路。压缩包共48个文件主要包含cpp源文件、h头文件、sln解决方案与vcxproj工程配置以及obj、pdb、ipch等编译中间文件和最终exe程序此外还附带了基于MFC的简易计算器实现、问题解决说明文档和课程清单等补充材料整体体积约162.28MB。已有1552人学习下载较适合在掌握C基础后对照源码逐模块阅读、修改和重新编译从而把握工程化编程的常见组织方式与排错思路。1. 一份能直接编译的 VS2019 计算器工程为什么值得把它拆开看网上找计算器源码十个里有七个是半截子工程要么缺资源文件要么是控制台黑框只能输入输出、没法当桌面工具用。手头这份“VS2019计算器简单复杂”的 C 工程包属于少见的“能开箱即编译”类型——解压后用 VS2019 打开 .sln设置好字符集就能跑简单版覆盖日常四则运算复杂版带运算符优先级、括号和函数扩展。对刚接触 Windows 桌面开发的人它是理解消息映射、控件回调、工程配置的现成教材对有经验的人它是改改就能复用到项目里的 UI 框架底座。接下来我会按“工程结构 → 简单版实现 → 复杂版解析 → 踩坑记录 → 二次开发”的顺序把它从头到尾过一遍。2. 解压到跑通先搞清楚 sln、vcxproj、rc 文件再动手2.1 工程文件全景打开 .sln 之前先认识这四件套一个 VS2019 的 C 窗口程序不管写计算器还是写别的工具文件构成基本是固定的。拿到压缩包先别急着双击 .sln在资源管理器里看一眼顶层结构心里先有个地图。常见的组成是这几个.sln 是解决方案文件VS 双击它才能把整个工程加载进来.vcxproj 是项目文件它决定了编译器选项、平台工具集、字符集、预编译头这些关键参数本质上是一份 XML.rc 是资源脚本对话框、按钮、图标、字符串表都在这里面定义resource.h 是资源 ID 的头文件代码里用的 IDC_BUTTON_0、IDC_EDIT_DISPLAY 这些宏全在这里声明。stdafx.h新版本叫pch.h也值得看一眼。VS2019 的 MFC 工程默认开启预编译头pch.h里会把 afxwin.h、afxext.h 这些大块头提前编译一遍避免每个 .cpp 都重复解析。若你打开工程发现Cannot open include file: stdafx.h八成就不是源码缺文件而是工程原本用的是旧版预编译头命名换到 VS2019 没同步迁移。提示把压缩包解压到纯英文路径不要放在“桌面/新建文件夹”这种带中文或空格的目录下。VS 对中文路径的兼容性虽比旧版好但遇到资源的相对引用或命令行参数时仍可能翻车。2.2 编译前必改的三个配置字符集、MFC 使用方式、平台工具集VS2019 能打开老工程但老工程的默认配置经常和新编译器打架。我拿到这类包后习惯先打开“项目属性 → 配置属性”按下面这一套过一遍能少编译三次。第一项是字符集。在“常规 → 项目默认值 → 字符集”里选“使用 Unicode 字符集”。老包常见的是“使用多字节字符集”代码里若用了TCHAR宏还好说要是直接写char str[100]配合中文字符串资源编译时容易在MessageBox这类宽字符 API 上调不过去出现cannot convert argument 1 from const char [..] to LPCWSTR的报错。第二项是MFC 的使用方式。“高级 → MFC 的使用”里有两个方向在“共享 DLL 中使用 MFC”和“在静态库中使用 MFC”。调试阶段选共享 DLL编译速度快、生成包小发布给没装 VC 运行库的机器时改成静态库更省心。代价是静态链接下 exe 体积会涨到几 MB这是正常的。第三项是平台工具集。VS2019 对应的是 v142。如果你机器上还装了 VS2022v143打开老工程会提示“需要 v142 工具集”此时两个选择装 v142 组件或直接在属性里把工具集改到 v143。多数计算器这类小工程v142 与 v143 改完后直接能编译过不需要动代码。2.3 首次构建路径Debug 方便看日志Release 才能测真实行为控制台小程序和窗口程序调试习惯不一样。窗口程序里最常见的调试手法是在关键分支弹AfxMessageBox或者在输出窗口用TRACE打印信息。TRACE只在 Debug 构建下生效Release 构建里会被预处理掉。所以第一轮排错建议用 Debug要做性能或最终验证再切 Release。下面是一组我在 VS2019 里手动完成配置的路径新手按顺序点即可项目菜单 → 属性 → 配置: (选择“所有配置”) → 配置属性 → 常规 → 字符集 → 使用 Unicode 字符集 → 配置属性 → 高级 → MFC 的使用 → 在共享 DLL 中使用 MFC → 配置属性 → C/C → 预编译头 → 预编译头 → 使用 (/Yu) → 确定这组配置里预编译头选项最容易忽略。VS2019 新建工程默认生成pch.h和pch.cpp老包若没有这两个文件得在“预编译头”里改成“不使用”否则编译器找不到pch.h直接报fatal error C1083: Cannot open precompiled header file。配置完成后按CtrlShiftB构建。第一次构建会在输出窗口刷出一大串1...前缀的日志看到结尾的 生成: 成功 就代表过了。若构建时提示LNK2019 无法解析的外部符号先别急着怀疑源码回看 MFC 使用方式是不是被改乱的这是 LNK 错误最常见来源之一。3. 简单计算器状态机、按钮消息与连续运算逻辑3.1 状态机设计三个成员变量撑起一次完整运算简单计算器的核心不是 UI而是背后那个状态机。这个包里的简单版说穿了就是三个关键变量当前输入值、暂存值、待执行的运算符。MFC 对话框工程里一般会把它们声明成对话框类的成员变量的成员变量。我按常见的写法还原一下核心结构// CSimpleCalcDlg.h 中的核心成员 class CSimpleCalcDlg : public CDialogEx { protected: double m_dblAccumulator; // 上一次运算结果/第一操作数 double m_dblCurrent; // 当前正在输入的数 TCHAR m_chOperator; // 待执行的运算符如 _T() BOOL m_bNewInput; // 标志位下一次按键是否应清空显示框 CString m_strDisplay; // 编辑框当前显示文本 };m_bNewInput是理解整套逻辑的钥匙。用户按完运算符再输数字时显示框必须从零开始不能把12追加成125而当用户连续按数字时又要把新数字追加到末尾。状态位判断比直接解析字符串要稳定得多。我见过不少半成品计算器把“连续按运算符”和“按完等号再输数字”这两个边界情况处理反根源就是没放这个标志位。3.2 按钮消息映射WM_COMMAND 到处理函数VS2019 里的 MFC 对话框按钮点击底层走的是WM_COMMAND消息。消息里LOWORD(wParam)装的是按钮控件 ID。资源文件里把每个按钮定义成一个 IDC 常量当用户按下某个按钮时MFC 通过消息映射宏把消息路由到对应处理函数。BEGIN_MESSAGE_MAP(CSimpleCalcDlg, CDialogEx) ON_BN_CLICKED(IDC_BUTTON_0, CSimpleCalcDlg::OnButtonNumber) ON_BN_CLICKED(IDC_BUTTON_ADD, CSimpleCalcDlg::OnButtonAdd) ON_BN_CLICKED(IDC_BUTTON_EQUAL, CSimpleCalcDlg::OnButtonEqual) END_MESSAGE_MAP()这里ON_BN_CLICKED宏的意义是“把某个按钮的点击事件绑定到某个成员函数”。理解它有一个好处想新增一个按钮比如加上1/x倒数键对应的动作有三步——在 resource.h 里新增 IDC 常量、在 .rc 文件里加一个按钮控件并用该 ID 标识、在源码里加一个处理函数并绑定宏。漏掉哪一步按钮要么不出现要么点了没反应。3.3 数字拼接与四则运算一个可运行的极简实现下面这段代码是这个工程里数字按键处理逻辑的典型写法去掉了具体 Dialg 类壳保留核心void CSimpleCalcDlg::OnButtonNumber(UINT nID) { // 由控件ID反推出数字字符IDC_BUTTON_0 到 IDC_BUTTON_9 按顺序排 int iDigit nID - IDC_BUTTON_0; if (iDigit 0 || iDigit 9) return; if (m_bNewInput) { // 刚按完运算符或等号开启一段全新输入 m_strDisplay _T(); m_bNewInput FALSE; } // 防止 double 精度丢失最多追加到 15 位有效数字 if (m_strDisplay.GetLength() 15) m_strDisplay (TCHAR)(_T(0) iDigit); SetDlgItemText(IDC_EDIT_DISPLAY, m_strDisplay); m_dblCurrent _ttof(m_strDisplay); }这段代码里两个细节值得多说一句。第一_ttof是 CRT 的宽字符版字符串转浮点函数Unicode 字符集下正好匹配CString内部存储。第二15 位限制是故意为之——用户按 20 个数字double也存不下精确值不如早点截断避免出现“输入 0.1234567890123456789显示出来变成 0.12345678901234567”的精度惊吓。运算符与等号的处理逻辑围绕m_dblAccumulator展开void CSimpleCalcDlg::OnButtonAdd() { // 若已经输入过运算符则先把前面的数算完连续运算 if (!m_bNewInput m_chOperator ! _T(\0)) { CalculateCurrent(); } else { // 第一次按运算符时当前输入就是这个运算的首操作数 m_dblAccumulator m_dblCurrent; } m_chOperator _T(); m_bNewInput TRUE; } void CSimpleCalcDlg::CalculateCurrent() { switch (m_chOperator) { case _T(): m_dblAccumulator m_dblAccumulator m_dblCurrent; break; case _T(-): m_dblAccumulator m_dblAccumulator - m_dblCurrent; break; case _T(*): m_dblAccumulator m_dblAccumulator * m_dblCurrent; break; case _T(/): // 除零保护浮点除以零在 Windows 上返回 INF不抛异常必须手动挡 if (fabs(m_dblCurrent) 1e-12) { SetDlgItemText(IDC_EDIT_DISPLAY, _T(除数不能为0)); m_chOperator _T(\0); m_bNewInput TRUE; return; } m_dblAccumulator m_dblAccumulator / m_dblCurrent; break; } m_dblCurrent m_dblAccumulator; SetDlgItemText(IDC_EDIT_DISPLAY, FormatDouble(m_dblAccumulator)); }CalculateCurrent是简单版的心脏。设计上刻意让它在两种场景下复用用户按运算符时算一次为了支持1 2 3这种连续输入用户按等号时再算一次。如果不希望引入m_chOperator参与判断则会出现“按完1 2再按结果把2又加一遍”的经典 bug。注意fabs(m_dblCurrent) 1e-12这种阈值判断方式适合“除零”这种极端场景但对“0.3 / 0.1”这种用户主观认为可整除的数不适用。浮点数没有精确整除一说展示层要靠格式化舍入来兜底。4. 复杂计算器表达式解析、优先级与函数扩展4.1 为什么复杂版不用状态机启用无优先级的计算器一眼就露馅如果你把“3 5 × 2”输进简单版计算器它会按顺序先算 358再算 8×216结果是错的。复杂计算器要正确处理这类表达式就得把整个式子拆成 token按优先级和括号重新规划计算次序这个过程的业界标准方案是调度场算法Shunting Yard Algorithm。这套算法核心思路两句话数字直接进输出队列运算符要看栈顶运算符的优先级栈顶优先级不低于自己就把栈顶弹出到输出然后再压栈。括号另作处理。网上讲调度场的教程不少但大多数只到“能算加减乘除”为止真正工程化要额外处理负号、函数调用、隐式乘法下面按这个包里的复杂度逐块展开。4.2 词法分析把字符串切成 Token 流表达式解析第一步是“分词”C 里通常定义一个 Token 结构体enum class TokenType { NUMBER, PLUS, MINUS, MUL, DIV, POW, LPAREN, RPAREN, FUNC_SIN, FUNC_COS, FUNC_SQRT, FUNC_LOG, CONST_PI, END }; struct Token { TokenType type; double number; // type NUMBER 时的值 int position; // 在原始字符串中的位置用于报错 };分词代码的骨架大致是读字符 → 判断类型 → 数值用std::strtod累加读取 → 函数名用strncmp逐个比对。工程化要特别注意两点第一点是负号处理。-3 5开头的减号和2 - 3里的减号含义完全不同前者是一元负号后者是二元减法。常见处理法是记录前一个 token 的类型若该 token 是运算符或左括号则当前减号视为一元负号转换为NUMBER -1和MUL的组合或直接生成NEG指令。第二点是隐式乘法。2(34)和3sin(30)在数学上合理但分词器若严格按字符读会在2和(之间判为“未知字符”。工程做法是在 token 生成阶段补一条规则前一个 token 是数字或右括号、当前 token 是左括号或函数名时自动插入一个MULtoken。这个特性对“用户随手输入”的体验提升非常明显。分词器完成后可以用一个简单循环验证正确性std::vectorToken tokens tokenize(2(34)*sin(30)); for (const auto tok : tokens) { std::cout tokenTypeToString(tok.type); if (tok.type TokenType::NUMBER) std::cout ( tok.number ); std::cout ; } // 预期输出: NUMBER(2) MUL LPAREN NUMBER(3) ADD NUMBER(4) RPAREN MUL FUNC_SIN LPAREN NUMBER(30) RPAREN能看到2和(之间自动补了MUL这一条就能确认分词器的隐式乘法生效了。4.3 调度场与后缀表达式求值优先级表决定一切调度场算法需要一张优先级表。定义如下// 返回运算符优先级数字越大越优先 static int opPriority(TokenType t) { switch (t) { case TokenType::ADD: return 1; case TokenType::MINUS: return 1; case TokenType::MUL: return 2; case TokenType::DIV: return 2; case TokenType::POW: return 3; // 幂运算右结合处理时要单列 default: return -1; // 非运算符 } }调度场核心代码std::vectorToken toRPN(const std::vectorToken tokens) { std::vectorToken output; std::stackToken opStack; for (const auto tok : tokens) { switch (tok.type) { case TokenType::NUMBER: case TokenType::CONST_PI: output.push_back(tok); break; case TokenType::FUNC_SIN: case TokenType::FUNC_COS: case TokenType::FUNC_SQRT: case TokenType::FUNC_LOG: opStack.push(tok); // 函数名按运算符处理 break; case TokenType::MINUS: case TokenType::ADD: case TokenType::MUL: case TokenType::DIV: case TokenType::POW: { int curPri opPriority(tok.type); while (!opStack.empty()) { Token topTok opStack.top(); if (topTok.type TokenType::LPAREN) break; int topPri opPriority(topTok.type); // 左结合时栈顶大于等于当前就弹出 if (topPri curPri) break; if (topPri curPri tok.type TokenType::POW) break; // 幂是右结合这里不弹 output.push_back(topTok); opStack.pop(); } opStack.push(tok); break; } case TokenType::LPAREN: opStack.push(tok); break; case TokenType::RPAREN: while (!opStack.empty() opStack.top().type ! TokenType::LPAREN) { output.push_back(opStack.top()); opStack.pop(); } opStack.pop(); // 弹出左括号 break; default: break; } } while (!opStack.empty()) { output.push_back(opStack.top()); opStack.pop(); } return output; }这段代码里最容易翻车的是幂运算。加减乘除都是左结合1-2-3等于(1-2)-3但2^3^2数学意义是2^(3^2)属于右结合。若统一用“栈顶优先级大于等于当前就弹出”的规则会把2 3 2 ^ ^算成(2^3)^2。上面的代码用topPri curPri tok.type POW时不弹栈保证右结合正确。后缀表达式求值是栈的经典应用数字入栈遇到运算符弹出两个数计算再压回。函数调用只弹一个参数sqrt(16)弹 16计算后得 4 入栈。表达式2(34)*sin(30)转后缀是2 3 4 * 30 sin *求值后应为 7。注意这里sin(30)的入参单位问题——C 的sin默认弧度制用户输入 30 一般想的是角度。工程里要在函数处理处统一加一个角度转弧度判断提供“角度/弧度”切换按钮或者默认按角度算。我看到不少源码死在“sin(30) 不是 0.5”这条线上属于规格没定清楚的锅不算代码错误。4.4 错误处理解析到一半崩了怎么办复杂计算器比简单版多出的工作量一半在解析另一半在错误处理。用户不会规规矩矩按数学规则输入(12、3*/4、sin、空表达式这些都是日常。工程做法是定义自定义异常或错误码解析和求值遇到问题立即停止并在计算器显示框给出可读提示class CalcException : public std::exception { public: int position; // 出错位置在原表达式中的下标 CalcException(int pos, const char* msg) : std::exception(msg), position(pos) {} };在语法检查阶段有一类错误要重点拦括号匹配。在分词器里记录左括号入栈、右括号出栈结束时若栈非空报“缺少右括号”遇到右括号时栈顶不是左括号报“缺少左括号”。这类检查必须在调度场之前做否则后缀表达式可能生成一个缺少操作数的畸形序列求值阶段从栈中取数时直接抛std::underflow_error现场很难看。错误提示的另一层作用是给 UI 反馈。简单版除零可以弹MessageBox复杂版解析错误用SetDlgItemText把错误字符串写进显示框更温和用户直接修改表达式就能继续不需要点掉弹窗。5. 避坑清单VS2019 下编译计算器工程的五个典型问题5.1 现象按钮点击后没有任何反应或者“数字键失灵”原因多半出在资源 ID 重复或消息映射缺失。同一个 IDC 常量在 resource.h 里被误定义成同一个值两个按钮共用 ID结果窗口过程只能路由到其中一个处理函数另一种情况是新增控件后在 resource.h 里忘了加#define编辑器自动生成的符号没有同步到代码中。解决先打开 resource.h把所有IDC_*宏扫一遍确认没有一个数字被两个名字共用再在 .rc 文件里逐个比对控件的 ID 与宏定义是否一致。最后看消息映射表ON_BN_CLICKED的第二个参数是函数名函数定义有没有写全函数签名是不是void CCalcDlg::OnButtonAdd()少一个C类前缀都会导致编译期映射失败。5.2 现象Debug 下正常Release 下一启动就崩或界面显示不对原因Release 与 Debug 差异里最坑的是未初始化变量。Debug 构建的栈内存被编译器自动填充0xCCCCCCCC这个值作为指针会立刻导致访问违例反而暴露问题Release 下栈内存是随机残留值m_dblAccumulator可能被初始成某个垃圾浮点算着算着出了 NaN。另一个常见诱因是TRACE日志在 Release 下被清空代码路径里“唯一能看见执行痕迹”的东西没了误以为程序没动。解决把对话框构造函数里的所有成员变量显式初始化m_dblAccumulator 0.0;、m_chOperator _T(\0);、m_bNewInput TRUE;一条条写清楚。对计算器这类状态型程序未初始化成员是头号可疑点排查时盯着构造函数看比在消息处理函数里打日志高效得多。5.3 现象除零后显示 “1.#INF” 或 “nan”程序不崩但结果不可用原因C 浮点除零不抛异常。1.0 / 0.0在 x86 上给出INFINITY0.0 / 0.0给出NANprintf输出为1.#INFWindows 的CString::Format也照显不误。不做前置拦截用户看到的是天书。解决在除法分支里对除数做容差判断。上面的代码用了fabs(m_dblCurrent) 1e-12更严格一点可以定义DBL_EPSILON约 2.2e-16作为边界。但注意计算器里用户按了0.0000000001 / 0.0000000001这种极小值也不该判定为除零容差设太大反而误伤。折中办法是直接判 0.0配合显示层的格式化函数把非精确结果修约到合理位数两段配合不冲突。5.4 现象LNK2019 无法解析的外部符号 “public: virtual __cdecl CCalcDlg::~CCalcDlg(void)”原因这基本不是源码问题是工程配置问题。在“MFC 的使用”为“不使用 MFC”时CDialogEx的析构无法链接因为对应的 MFC 库没有参与链接。VS2019 里新建 MFC 对话框工程默认已配好但从别处拷来的工程换个环境后配置被冲掉是这条报错的高发场景。解决右键项目 → 属性 → 高级 → MFC 的使用设置成“在共享 DLL 中使用 MFC”确认配置下拉框选的是“所有配置”不只调了 Debug。改完清理解决方案再重新生成别直接增量编译旧的对象文件会继续引用缺失符号。若还报错把“配置类型”确认成“应用程序 (.exe)”不要误改成动态库。5.5 现象中文按钮文字变成乱码或对话框标题挤成一团原因源码文件以 UTF-8 无 BOM 保存而 VS2019 默认按当前系统代码页读取中文字符串被误解析成 GBK 再加一层转码就乱了。另一个因素是 .rc 文件若保留为 GB2312 编码与 .cpp 的 UTF-8 混着读编译出的资源表就乱了。解决工程属性里把字符集设为 Unicode 后源码文件统一用 UTF-8 with BOM 保存VS 自带“文件 → 高级保存选项”可指定并在每个 .cpp 头部加#pragma execution_character_set(utf-8)——这个指令告诉编译器源文件里的宽字符常量按 UTF-8 处理否则 Visual C 默认按执行字符集转换源码里写_T(÷)也可能出问题。.rc 文件的编码则跟随系统语言即可不用强求 UTF-8。6. 让计算器变成自己的加按钮、加函数、加对拍脚本6.1 给复杂版加一个阶乘按钮从 UI 到引擎的四步二次开发走一遍完整链路能把这个工程的底摸透。以“阶乘”为例标准步骤是先在 resource.h 里新增IDC_BUTTON_FACT宏再在 .rc 对话框模板里加一个值为该 ID 的按钮然后把处理逻辑挂到消息映射上最后在计算引擎里加 Token 类型和计算公式。阶乘的计算实现对整数有意义对小数要拦截。常见策略是取整判断误差double factorial(double n) { if (n 0 || floor(n) ! n) throw CalcException(0, 阶乘仅支持非负整数); double result 1.0; for (int i 2; i (int)n; i) result * i; return result; }这个函数可以挂在后缀表达式求值的函数分支里和sin、sqrt走同一个弹栈路径。唯一要注意的是阶乘的优先级——数学上3! × 2应先算阶乘所以在调度场阶段把阶乘定义为后缀一元运算符不能按普通函数先压栈否则会排出3 × 2 !的错误顺序。简化处理是在词法层把!与前面的数字绑定成一个 FACT token一步到位。6.2 自动对拍让程序和 Python 计算 5000 个随机表达式手工输几十条用例输到后面自己都懒得看结果这时候写对拍脚本最靠谱。思路很简单用 Python 生成一批随机表达式和对应答案再让计算器核心跑同一批表达式最后用脚本比对输出。复杂计算器一般是 GUI 程序不能直接命令行调用。工程实践是把解析器和求值引擎拆成一个独立的CalcEngine类再写一个控制台测试壳这样既能被 GUI 调也能被脚本直接跑# 生成随机表达式一行一个 python gen_expr.py 5000 expr.txt # 用控制台版计算器批量计算 calc_console.exe exprs.txt out.txt # 和 Python 精确结果对比 python diff_result.py expr.txt out.txt控制台壳的主函数示意int main(int argc, char* argv[]) { std::ifstream in(argv[1]); std::ofstream out(argv[2]); std::string line; while (std::getline(in, line)) { try { double result CalcEngine::evaluate(line); out std::fixed std::setprecision(10) result \n; } catch (const CalcException e) { out ERR\n; } } }对拍脚本里最需要把握的是误差阈值。浮点运算结果和 Python 的Decimal不能逐位相等我一般允许相对误差1e-9def judge(expected, actual): if actual ERR: return expected is None if expected is None: return False return abs(expected - actual) 1e-9 * max(abs(expected), abs(actual))随机生成的表达式中要刻意掺入sin(90)、sqrt(2)、2^0.5这类容易暴露函数处理和优先级问题的用例纯整数加减乘除对拍意义不大。跑完 5000 条如果只挂三五条基本就能定位到是某个特定写入的处理分支。从那以后我每改一版计算器都强制先把这 5000 条回归跑一遍再谈 UI 的事。分析和展示分头验证排查问题的速度能快一大截。希望这篇拆解能帮你少走几趟弯路把这份源码真正变成顺手的东西。本文还有配套的精品资源点击获取
返回列表