VC++6.0写的带表达式堆栈解析的图形计算器,含完整MFC源码和可执行文件
简介:这是一个用Visual C++ 6.0开发的Windows桌面图形计算器,支持加减乘除、括号嵌套和运算符优先级,核心计算逻辑基于栈结构实现(Stack.cpp/h),能正确解析并计算完整数学表达式。界面使用MFC框架搭建,主窗口为CaculatorDlg类,提供鼠标点按和键盘输入两种操作方式,双击Caculator.exe即可运行,无需安装额外运行库,兼容Windows XP、Win7等传统系统。资源包包含全部源代码文件(.cpp/.h)、MFC工程文件(.dsw/.dsp)、编译中间产物(.obj/.pch/.pdb/.ilk)、资源文件(图标.ico、对话框布局.rc)以及调试信息,结构清晰,适合用来学习MFC窗口编程、栈在表达式求值中的应用、VC6.0项目构建流程和Win32 GUI开发实践。所有文件均可直接打开、修改、重新编译,工程已配置好调试环境,方便单步跟踪计算过程。
1. 项目概述:一个“能跑在老爷机上的真·手写计算器”
你有没有试过,在一台刚装好系统、连.NET Framework都没装的Windows XP老笔记本上,想临时算个带括号的表达式——比如 (123 + 45) * 2 - 67 / 3,结果发现自带的计算器只能按一步算一步?点完“123+”,再点“45=”,得到168,接着还得手动记下这个数,再输“168*2=”,再记……中间只要漏记一位,整个就得重来。这不是算术问题,是交互设计的断层。
这个VC++6.0图形计算器,就是为这种“纯裸机场景”而生的。它不是用Qt或WPF写的现代UI,也不是靠Python打包成exe的脚本封装;它是用1998年发布的Visual C++ 6.0(没错,比WinXP还早两年)原生编写的MFC桌面程序,编译后生成一个不到200KB的独立Caculator.exe文件,双击即启,不注册DLL,不写注册表,不弹UAC,不联网验证——它只做一件事:把你在编辑框里敲进去的任意合法数学表达式,从左到右扫描一遍,用两个栈(操作数栈 + 运算符栈)实时解析、动态压栈、按优先级弹栈计算,最后吐出一个double精度的结果。整个过程,你甚至能在VC6调试器里单步跟到Stack::Evaluate()函数内部,亲眼看着'+'怎么被压进运算符栈,又怎么在遇到')'时被弹出来,和栈顶两个数字完成一次真正的pop() + pop() + do_add()三连操作。
关键词里写的“VC++6.0, 图形计算器, MFC, 堆栈计算”,其实对应着四层硬核能力:
- VC++6.0 是它的时代烙印和工程约束——没有STL容器(std::stack?不存在的),没有异常处理(try/catch?全靠if/else和返回码),没有RTTI(运行时类型识别),连bool类型都要自己#define;
- 图形计算器 不是画几个按钮就叫图形,它实现了完整的Windows消息循环(WM_COMMAND, WM_KEYDOWN, WM_PAINT),支持Alt快捷键(Alt+C清屏、Alt+=计算)、键盘方向键光标移动、退格删除、Enter触发计算,甚至支持鼠标拖选表达式片段;
- MFC 是它的骨架——不是拿来主义的黑盒框架,而是每一行ON_BN_CLICKED(IDC_BUTTON_0, OnButton0)背后都对应着ClassWizard生成的映射宏,每一个对话框资源(.rc)里的按钮ID,都在CaculatorDlg.h里声明为CButton m_btn0;,再通过DDX_Control(pDX, IDC_BUTTON_0, m_btn0)绑定到成员变量;
- 堆栈计算 是它的灵魂——它没用递归下降解析器,也没调用atof()加eval()这种危险函数,而是用教科书级的“双栈算法”(Dijkstra双栈法):一个CArray<double, double>存数字,一个CArray<char, char>存符号,配合一张手写的优先级表(GetPrecedence('+') == 1, GetPrecedence('*') == 2, GetPrecedence('(') == 0),把中缀表达式当场翻译成可执行的运算序列。
它适合谁?不是想快速做个计算器交作业的学生,而是想真正搞懂“Windows GUI怎么响应一次鼠标点击”、“MFC对话框怎么把资源ID和C++对象绑在一起”、“为什么括号必须用栈而不是if判断来处理”的人。它是一份可运行的《Windows编程入门》教材,也是一块能上手拆解的“活体标本”。你打开Caculator.dsw,F7编译,F5调试,点开Stack.cpp,把光标停在while (!m_opStack.IsEmpty() && GetPrecedence(m_opStack.GetAt(m_opStack.GetSize()-1)) >= GetPrecedence(ch))这一行,按F10单步——那一刻,抽象的数据结构定义,就变成了内存里真实跳动的指针和数组下标。
2. 整体架构与设计思路拆解:为什么非得用双栈?为什么非得用VC6?
2.1 为什么核心计算不用递归,而坚持双栈算法?
很多人第一反应是:“表达式求值?写个递归函数不就完了?”比如先找最外层括号,递归算里面,再算外面。但这是理想模型。真实GUI场景下,用户输入是流式的:他可能刚敲完(12+3,还没打完右括号,你就得响应——是报错?还是等?或者他删掉一个字符,整个表达式结构就变了。递归解析器需要完整字符串才能开始,而双栈算法是增量式的:每读入一个字符(数字、符号、括号),就立刻决定是压栈、弹栈还是忽略。它天然适配“边输边算”的交互逻辑。
更关键的是VC++6.0的限制。当时没有std::string::find_first_of()这种便利函数,也没有正则库。你要从"123+45*(6-7)"里提取token(数字、符号、括号),最稳妥的方式就是单字符扫描+状态机。而双栈算法正是为这种逐字符处理而生的。我们来看Stack.cpp里Evaluate()函数的主干逻辑:
// 状态:0=等待数字,1=等待运算符,2=括号内
int state = 0;
for (int i = 0; i < expr.GetLength(); i++) {
char ch = expr[i];
if (isdigit(ch)) {
// 累积数字:'1','2','3' → 构造123.0
if (state == 0) {
num = num * 10 + (ch - '0');
state = 0; // 继续累积
}
} else if (ch == '+' || ch == '-' || ch == '*' || ch == '/') {
// 遇到运算符:先把前面的数字压栈,再根据优先级处理运算符栈
if (state != 1) { // 前面不是运算符,说明有数字待压
m_numStack.Add(num);
num = 0;
}
ProcessOperator(ch); // 核心:比较优先级,该弹则弹
state = 1;
} else if (ch == '(') {
m_opStack.Add(ch);
state = 0; // 进入括号,下一个期待数字
} else if (ch == ')') {
// 弹出直到遇到'(',并执行所有弹出的运算
while (!m_opStack.IsEmpty() && m_opStack.GetAt(m_opStack.GetSize()-1) != '(') {
ExecuteTopOperation();
}
if (!m_opStack.IsEmpty()) m_opStack.RemoveAt(m_opStack.GetSize()-1); // 弹掉'('
state = 1; // 括号结束,期待运算符
}
}
这段代码里没有substr(),没有stringstream,全是GetLength()、GetAt()、Add()、RemoveAt()——这正是VC6时代MFC容器(CArray)和C风格字符串(CString)的典型用法。递归方案在这里会面临栈溢出风险(深度嵌套括号)、字符串切分开销大、错误定位难(哪个括号没配对?)三大问题。而双栈方案,每个字符只做O(1)操作,总时间复杂度O(n),空间复杂度O(n),且错误可即时捕获:比如扫描到'+'时发现操作数栈空了,立刻AfxMessageBox("缺少左操作数"),用户马上知道哪里错了。
提示:
ProcessOperator()函数是精髓所在。它不是简单把ch压栈,而是先查GetPrecedence(ch),再和栈顶运算符比较。比如当前栈顶是'*'(优先级2),新来的是'+'(优先级1),那就必须先把'*'弹出来,和栈顶两个数字算完,再把'+'压进去。这个“弹栈时机”的判断,就是整个算法正确性的基石。很多初学者抄代码只抄了压栈,忘了弹栈条件,结果1+2*3算成9而不是7——这就是没吃透while (栈顶优先级 >= 当前)这一行的含义。
2.2 为什么坚持用VC++6.0和MFC,而不是升级到VS2019?
这个问题直指项目存在的根本价值。答案很实在:为了教学透明性。VC++6.0是Windows GUI开发的“汇编语言”时代——它离Win32 API最近,封装最少,所有魔法都摊开在你眼前。
-
在VS2019里,你新建一个MFC App,向导会自动生成上百行代码,
CWinApp派生类、CMainFrame、CDocument/CView架构……新手看到就晕。而VC6的向导极度精简:选“Dialog Based”,勾选“MFC in a Shared DLL”(实际项目用的是Static Link,确保无依赖),它就只给你一个CDialog派生类(CaculatorDlg)和一个空对话框资源。你要加按钮?自己拖;要响应点击?自己用ClassWizard关联;要画界面?直接改.rc文件里的坐标。没有隐藏的基类初始化,没有自动注入的消息映射,一切皆可见、皆可控。 -
更重要的是调试体验。VC6的调试器虽然古老,但对MFC内部机制的暴露是惊人的。你可以在
CaculatorDlg.cpp里设断点,按F11进入CDialog::OnInitDialog(),再F11进入CWnd::CreateDialogIndirect(),一路跟到CreateDialogParam()这个Win32 API调用——你亲眼看到MFC如何把.rc资源里的DIALOGEX结构体,一层层传递给系统,最终创建出窗口。这种“从C++代码直达操作系统API”的链路,在现代IDE里早已被层层抽象屏蔽。而在VC6里,它就是一条清晰的、可单步的直线。 -
最后是兼容性倒逼的严谨性。VC6不支持
long long,不支持auto,连for (int i=0; ...)这种写法都要开/Za严格模式。你必须显式声明所有变量类型,必须处理所有return路径,必须手动管理new/delete(项目里没用new,全用栈变量和CArray,这是另一个值得学习的点)。这种“被迫的严谨”,恰恰是培养扎实C++功底的最好温床。当你在VS2019里随手写vector<int> v = {1,2,3};时,你并不知道背后发生了多少次内存分配和拷贝;而在VC6里,你写CArray<int, int> v; v.SetSize(3); v[0]=1; v[1]=2; v[2]=3;,每一个步骤的内存动作都是确定的、可追踪的。
所以,这不是怀旧,而是精准的教学设计:用最“原始”的工具,讲最本质的原理。就像学书法要从临摹颜真卿楷书开始,而不是一上来就写行草。
3. 核心模块详解与实操要点:从按钮点击到栈顶运算
3.1 MFC界面层:按钮、消息与数据交换(DDX)
MFC的对话框界面,核心就三件事:资源定义(.rc)→ 类声明(.h)→ 消息映射与数据绑定(.cpp)。这个计算器把这三步拆得极其干净,是学习MFC的范本。
先看资源文件Caculator.rc里的关键片段:
IDD_CALCULATOR_DIALOG DIALOGEX 0, 0, 260, 220
STYLE DS_SETFONT | DS_MODALFRAME | WS_POPUP | WS_VISIBLE | WS_CAPTION | WS_SYSMENU
CAPTION "图形计算器"
FONT 8, "MS Sans Serif", 400, 0, 0x1
BEGIN
EDITTEXT IDC_EDIT_DISPLAY,7,7,246,24,ES_RIGHT | ES_AUTOHSCROLL | WS_BORDER | WS_TABSTOP
PUSHBUTTON "0",IDC_BUTTON_0,7,175,30,23
PUSHBUTTON "1",IDC_BUTTON_1,42,145,30,23
// ... 其他按钮省略 ...
PUSHBUTTON "=",IDC_BUTTON_EQUAL,192,175,30,23
END
这里定义了一个260×220像素的对话框,标题“图形计算器”,字体是经典的MS Sans Serif。最关键的,是每个按钮都有唯一ID:IDC_BUTTON_0, IDC_BUTTON_1, …, IDC_BUTTON_EQUAL。这些ID不是随便起的,它们是MFC消息路由的“地址”。
再到头文件CaculatorDlg.h,声明成员变量:
class CCaculatorDlg : public CDialog
{
// Construction
public:
CCaculatorDlg(CWnd* pParent = NULL); // standard constructor
// Dialog Data
//{{AFX_DATA(CCaculatorDlg)
enum { IDD = IDD_CALCULATOR_DIALOG };
CString m_strDisplay; // 对应编辑框内容
//}}AFX_DATA
// 新增的按钮控件成员变量(ClassWizard会自动生成)
CButton m_btn0;
CButton m_btn1;
// ... 其他按钮
CButton m_btnEqual;
protected:
virtual void DoDataExchange(CDataExchange* pDX); // DDX/DDV support
};
注意//{{AFX_DATA...}}这个标记,这是VC6 ClassWizard识别代码区域的“锚点”。m_strDisplay是编辑框的文本内容,类型是CString(VC6时代的字符串类);而m_btn0等是CButton类的对象,代表界面上那个“0”按钮。
最后是实现文件CaculatorDlg.cpp里的数据交换和消息映射:
void CCaculatorDlg::DoDataExchange(CDataExchange* pDX)
{
CDialog::DoDataExchange(pDX);
//{{AFX_DATA_MAP(CCaculatorDlg)
DDX_Text(pDX, IDC_EDIT_DISPLAY, m_strDisplay);
//}}AFX_DATA_MAP
// 手动添加的控件绑定(ClassWizard不会自动生成CButton的DDX)
DDX_Control(pDX, IDC_BUTTON_0, m_btn0);
DDX_Control(pDX, IDC_BUTTON_1, m_btn1);
// ... 其他按钮
DDX_Control(pDX, IDC_BUTTON_EQUAL, m_btnEqual);
}
BEGIN_MESSAGE_MAP(CCaculatorDlg, CDialog)
//{{AFX_MSG_MAP(CCaculatorDlg)
ON_WM_PAINT()
ON_WM_QUERYDRAGICON()
ON_BN_CLICKED(IDC_BUTTON_0, OnButton0)
ON_BN_CLICKED(IDC_BUTTON_1, OnButton1)
// ... 其他按钮点击消息
ON_BN_CLICKED(IDC_BUTTON_EQUAL, OnButtonEqual)
ON_EN_CHANGE(IDC_EDIT_DISPLAY, OnChangeEditDisplay)
ON_WM_KEYDOWN()
//}}AFX_MSG_MAP
END_MESSAGE_MAP()
DoDataExchange()是MFC的“数据交换”机制。DDX_Text()把编辑框(IDC_EDIT_DISPLAY)的文本内容,双向同步到m_strDisplay变量——你改变量,编辑框刷新;你改编辑框,变量自动更新。DDX_Control()则是把按钮资源ID和C++对象m_btn0绑定起来,这样你就能在代码里调用m_btn0.EnableWindow(FALSE)来禁用它。
消息映射ON_BN_CLICKED(IDC_BUTTON_0, OnButton0)告诉MFC:当用户点击ID为IDC_BUTTON_0的按钮时,请调用CCaculatorDlg::OnButton0()这个成员函数。这个函数的实现,就是往m_strDisplay里追加一个字符'0',然后调用UpdateData(FALSE)强制刷新编辑框显示:
void CCaculatorDlg::OnButton0()
{
UpdateData(TRUE); // 先从界面读取最新内容到m_strDisplay
m_strDisplay += _T("0"); // 追加字符
UpdateData(FALSE); // 再把m_strDisplay写回界面
}
注意:
_T("0")是VC6的宏,用于支持Unicode和ANSI双编译。在VC6默认ANSI模式下,它等价于"0";如果项目设为Unicode,它会自动转成L"0"。这是跨编码兼容的必备写法,现代C++已不需如此,但在VC6里是铁律。
实操心得:如果你自己动手修改这个计算器,比如想加一个“平方”按钮,步骤必须严格按此顺序:1)在.rc里拖一个新按钮,记下它的ID(如IDC_BUTTON_SQUARE);2)在.h里声明CButton m_btnSquare;;3)在.cpp的DoDataExchange()里加DDX_Control(pDX, IDC_BUTTON_SQUARE, m_btnSquare);;4)在BEGIN_MESSAGE_MAP里加ON_BN_CLICKED(IDC_BUTTON_SQUARE, OnButtonSquare);5)最后实现OnButtonSquare()函数。漏掉任何一步,按钮要么不响应,要么程序崩溃。这就是MFC“约定大于配置”的代价,也是它教学价值所在——你无法跳过任何一个环节。
3.2 堆栈计算模块:Stack.h/.cpp的教科书级实现
Stack.h和Stack.cpp是整个项目的“心脏”,它完全脱离MFC,是一个纯粹的、可复用的C++计算引擎。我们来逐行拆解它的设计哲学。
头文件Stack.h定义了极简接口:
class CStack
{
public:
CStack();
~CStack();
// 主入口:传入表达式字符串,返回计算结果
double Evaluate(const CString& expr);
private:
// 核心数据结构:两个CArray
CArray<double, double> m_numStack; // 操作数栈
CArray<char, char> m_opStack; // 运算符栈
// 辅助函数
int GetPrecedence(char op); // 获取运算符优先级
void ExecuteTopOperation(); // 执行栈顶运算符
void ProcessOperator(char op); // 处理新运算符:压栈 or 弹栈
};
没有模板,没有泛型,CArray<double, double>的第一个double是元素类型,第二个double是参数传递类型(值传递)。这是VC6时代典型的“类型安全妥协”——为了性能,避免引用传递的开销,直接传值。
Stack.cpp的Evaluate()函数,我们已在2.1节分析过其主干。现在聚焦三个私有函数:
GetPrecedence():一张手写的“运算符宪法”
int CStack::GetPrecedence(char op)
{
switch(op) {
case '+':
case '-': return 1;
case '*':
case '/': return 2;
case '(': return 0; // 左括号最低,保证总是压栈
case ')': return 3; // 右括号最高,触发弹栈到'('
default: return -1; // 无效字符
}
}
这张表决定了整个计算的秩序。'('设为0,是为了让它在任何运算符面前都“谦让”,从而无条件压栈;')'设为3,是为了让它在遇到任何栈顶运算符时,都强制触发弹栈,直到遇见'('。这个设计,完美规避了“括号需要特殊语法处理”的复杂性,把括号降维成了“具有特殊优先级的运算符”。
ExecuteTopOperation():栈顶的“原子操作”
void CStack::ExecuteTopOperation()
{
if (m_numStack.GetSize() < 2 || m_opStack.IsEmpty()) return;
double b = m_numStack.GetAt(m_numStack.GetSize()-1); // 右操作数
double a = m_numStack.GetAt(m_numStack.GetSize()-2); // 左操作数
char op = m_opStack.GetAt(m_opStack.GetSize()-1);
double result = 0.0;
switch(op) {
case '+': result = a + b; break;
case '-': result = a - b; break;
case '*': result = a * b; break;
case '/':
if (fabs(b) < 1e-10) { // 除零检查
AfxMessageBox(_T("错误:除零!"));
return;
}
result = a / b;
break;
default: return;
}
// 弹出两个操作数和一个运算符
m_numStack.RemoveAt(m_numStack.GetSize()-1);
m_numStack.RemoveAt(m_numStack.GetSize()-1);
m_opStack.RemoveAt(m_opStack.GetSize()-1);
// 将结果压入操作数栈
m_numStack.Add(result);
}
注意细节:它先取栈顶(GetSize()-1)作为右操作数b,再取次栈顶(GetSize()-2)作为左操作数a。这是栈的LIFO特性决定的:1-2,用户输入顺序是1→-→2,栈里存储顺序是[1,2],运算时必须1-2,所以a是1,b是2。另外,除零检查用了fabs(b) < 1e-10,而不是b==0,因为浮点数不能直接等值比较——这是VC6时代程序员必修的数值计算常识。
ProcessOperator():双栈算法的“决策中枢”
void CStack::ProcessOperator(char op)
{
// 如果运算符栈为空,或栈顶是'(',直接压栈
if (m_opStack.IsEmpty() || m_opStack.GetAt(m_opStack.GetSize()-1) == '(') {
m_opStack.Add(op);
return;
}
// 否则,比较优先级:栈顶 >= 当前,则弹栈执行
while (!m_opStack.IsEmpty() &&
m_opStack.GetAt(m_opStack.GetSize()-1) != '(' &&
GetPrecedence(m_opStack.GetAt(m_opStack.GetSize()-1)) >= GetPrecedence(op)) {
ExecuteTopOperation();
}
// 最后,把当前运算符压栈
m_opStack.Add(op);
}
这个while循环,就是Dijkstra双栈法的灵魂。它确保了高优先级运算符(如*)永远比低优先级(如+)先执行。例如表达式1+2*3:
- 扫描'1':压入m_numStack → [1]
- 扫描'+':栈空,压入m_opStack → ['+']
- 扫描'2':压入m_numStack → [1,2]
- 扫描'*':查栈顶'+'优先级1 < '*'优先级2,不弹栈,直接压'*' → ['+','*']
- 扫描'3':压入m_numStack → [1,2,3]
- 结束:弹出'*',计算2*3=6,m_numStack变为[1,6];再弹出'+',计算1+6=7。
整个过程,没有递归,没有临时字符串,只有栈的Add()和RemoveAt(),干净利落。
实操心得:如果你想扩展功能,比如加
sin()、log()函数,不要试图在ProcessOperator()里硬塞。正确做法是:1)在GetPrecedence()里为's'(代表sin)设一个高优先级(如4);2)在ExecuteTopOperation()里增加case 's': result = sin(a);;3)在主扫描循环里,当遇到's'时,不是压栈,而是直接读取后面的'i'、'n'、'(',然后跳过括号内的表达式,将其Evaluate()结果作为a,再执行sin(a)。这才是符合双栈思想的扩展方式。
4. 完整编译与调试流程:从零开始构建你的第一个VC6项目
4.1 环境准备:VC++6.0的“考古级”安装
现代Windows(Win10/11)已无法原生运行VC++6.0安装程序(setup.exe),因为它依赖16位安装引擎。但别慌,这不是死局,而是项目教学设计的一部分——它逼你去理解“开发环境”本身也是一种需要维护的遗产。
推荐方案:虚拟机+纯净系统
- 下载Windows XP SP3 ISO镜像(微软官方已停止支持,但教育用途可搜索存档);
- 使用VirtualBox或VMware Workstation Player创建一台2GB内存、20GB硬盘的虚拟机;
- 安装XP后,关闭自动更新(避免补丁破坏VC6兼容性),安装VM Tools增强工具;
- 从可信渠道获取VC++6.0安装包(vc6ent.iso或vc6pro.iso),挂载后运行setup.exe;
- 安装时,务必取消勾选“Microsoft Data Access Components (MDAC)”,否则会导致VC6启动时报错;
- 安装完成后,打上官方Service Pack 6(SP6)补丁,这是VC6最后一个稳定版本;
- 最后,安装一个经典主题补丁(如“Luna for VC6”),让古老的IDE看起来不那么刺眼。
注意:VC6的默认安装路径是
C:\Program Files\Microsoft Visual Studio\VC98\。所有项目文件(.dsw,.dsp)里的相对路径,都是基于这个根目录。如果你装在其他盘符,打开工程时会提示“找不到文件”,这时需要手动在VC6里通过“Project → Settings → General”选项卡,重新设置“Intermediate files”和“Output files”的路径。
4.2 工程加载与首次编译
拿到Caculator.dsw文件,双击即可在VC6中打开。你会看到左侧的“Workspace”窗口,包含两个标签页:“ClassView”和“FileView”。切换到“FileView”,展开树形结构:
Caculator Workspace
└── Caculator (Active)
├── Source Files
│ ├── Caculator.cpp
│ ├── CaculatorDlg.cpp
│ ├── Stack.cpp
│ └── StdAfx.cpp
├── Header Files
│ ├── Caculator.h
│ ├── CaculatorDlg.h
│ ├── Resource.h
│ ├── Stack.h
│ └── StdAfx.h
└── Resource Files
├── Caculator.rc
└── Caculator.ico
这就是整个项目的物理结构。StdAfx.cpp和StdAfx.h是预编译头文件,用来加速编译(把常用的MFC头文件如afxwin.h、afxdlgs.h预先编译好)。Caculator.cpp是应用类入口,CaculatorDlg.cpp是主对话框逻辑,Stack.cpp是计算核心。
首次编译前,先检查配置:
- 菜单栏“Build → Set Active Configuration…”,选择Caculator - Win32 Debug(调试版);
- “Project → Settings → C/C++”选项卡,确认“Category”是“General”,Precompiled Headers设为“Use precompiled header file (stdafx.h)”;
- “Link”选项卡,确认“Output file name”是Debug\Caculator.exe;
- 最关键的:“C/C++ → Code Generation”,Use run-time library必须是Multithreaded Debug DLL (/MDd)(调试版)或Multithreaded DLL (/MD)(发布版)。绝对不能选Single-threaded或Debug Single-threaded,否则链接时会报unresolved external symbol _beginthreadex——因为MFC的CWinThread依赖多线程CRT。
配置无误后,按F7开始编译。VC6会依次编译StdAfx.cpp(生成StdAfx.pch预编译头),再编译其他.cpp文件(生成.obj),最后链接成Caculator.exe。整个过程约10-20秒,你会在下方“Output”窗口看到类似:
Compiling...
Compiling resources...
Compiling...
Linking...
Creating executable...
如果出现错误,最常见的有两类:
- fatal error C1083: Cannot open include file: 'xxxx.h':头文件路径不对。解决:菜单“Tools → Options → Directories”,在“Include files”里添加$(VCInstallDir)atl\include;$(VCInstallDir)mfcatl\include;等路径;
- error LNK2001: unresolved external symbol "public: virtual __thiscall Cxxx::~Cxxx(void)":析构函数声明了但没定义。解决:在.cpp里补全Cxxx::~Cxxx() { }。
编译成功后,“Output”窗口最后一行会显示:
Caculator.exe - 0 error(s), 0 warning(s)
4.3 调试实战:单步跟踪一次1+2*3的诞生
调试是理解这个项目最高效的方式。我们以计算1+2*3为例,演示如何从点击“=”按钮,一直跟到栈顶弹出'*'并执行乘法。
步骤1:设置断点
- 在CaculatorDlg.cpp的OnButtonEqual()函数第一行设断点(F9);
- 在Stack.cpp的Evaluate()函数开头设断点;
- 在ExecuteTopOperation()函数开头设断点。
步骤2:启动调试
- 按F5启动调试,程序运行,界面弹出;
- 在编辑框里输入1+2*3,点击“=”按钮;
- 程序立即停在OnButtonEqual()断点处。
步骤3:单步深入
- 按F11(Step Into),进入OnButtonEqual()函数体;
- 继续F11,会跳到UpdateData(TRUE),再F11会进入MFC内部,此时按F10(Step Over)跳过,避免陷入框架代码;
- 当执行到double result = m_stack.Evaluate(m_strDisplay);时,再次F11,就进入了Stack.cpp的Evaluate()函数;
- 此时,观察VC6底部的“Watch”窗口,添加监视expr,能看到值为"1+2*3";
- 按F10逐行执行,当i=1,ch='+'时,程序会调用ProcessOperator('+');
- F11进入ProcessOperator(),看到m_opStack.IsEmpty()为真,直接m_opStack.Add('+'),然后返回;
- 继续F10,i=3,ch='*',再次进入ProcessOperator(),这次while条件不满足(栈顶'+'优先级1 < '*'优先级2),直接m_opStack.Add('*');
- i=5,循环结束,执行while (!m_opStack.IsEmpty()) { ExecuteTopOperation(); };
- F11进入ExecuteTopOperation(),此时m_numStack是[1,2,3],m_opStack是['+','*'];
- 在ExecuteTopOperation()里,b = m_numStack.GetAt(2) = 3.0,a = m_numStack.GetAt(1) = 2.0,op = '*',计算result = 6.0;
- RemoveAt()后,m_numStack变为[1,6],m_opStack变为['+'];
- 函数返回,while继续,再次进入ExecuteTopOperation(),这次a=1.0, b=6.0, op='+',结果7.0;
- m_numStack变为[7],m_opStack变空;
- Evaluate()返回7.0,OnButtonEqual()将结果格式化为字符串,显示在编辑框。
整个过程,你亲眼看到了栈的生长、收缩,看到了优先级如何指挥运算顺序,看到了MFC如何把一次鼠标点击,转化为一连串精确的内存操作。这不是黑盒,这是白盒,而且是你可以随时暂停、修改、重放的白盒。
实操心得:VC6调试器有个隐藏技巧——在“Watch”窗口里,可以输入
m_numStack.m_pData(CArray的内部指针),然后展开,直接看到栈底到栈顶的所有元素内存布局。这比任何图表都直观。另外,如果想看CString的内部,输入m_strDisplay.m_pchData,就能看到字符数组的首地址。这种对内存的“直视”,是现代高级语言调试器刻意隐藏的,但在底层开发中,它是最宝贵的洞察力。
5. 常见问题与排查技巧实录:那些年踩过的坑
5.1 编译期问题速查表
| 问题现象 | 根本原因 | 解决方案 |
|---|---|---|
fatal error C1010: unexpected end of file while looking for precompiled header directive |
StdAfx.h未被包含,或#include "stdafx.h"不在源文件第一行 |
检查每个.cpp文件,确保第一行是#include "stdafx.h",且前面不能有任何注释或空行 |
error C2065: 'XXX' : undeclared identifier |
头文件未包含,或类声明顺序错误(如CStack在CaculatorDlg之后声明) |
在CaculatorDlg.h顶部添加#include "Stack.h";确保class CStack声明在class CCaculatorDlg之前 |
error LNK2001: unresolved external symbol _main |
项目类型设错,应为“Win32 Application”而非“Win32 Console Application” | “Project → Settings → General”,确认“Target Type”是“Win32 Application” |
error C2664: 'AfxMessageBox' : cannot convert parameter 1 from 'char [X]' to 'LPCTSTR' |
字符串字面量未用_T()宏包装 |
将"错误"改为_T("错误"),统一使用Unicode安全字符串 |
5.2 运行时问题与避坑指南
问题1:点击按钮无反应,或编辑框内容不更新
这是新手最高频的问题。根源几乎100%是DDX_Control()或DDX_Text()缺失。VC6的ClassWizard有时会漏掉CButton的绑定。解决方案:
- 打开CaculatorDlg.cpp,找到DoDataExchange()函数;
- 检查是否为每个按钮都写了DDX_Control(pDX, IDC_BUTTON_X, m_btnX);
- 检查DDX_Text()的ID是否和.rc里编辑框的ID(IDC_EDIT_DISPLAY)完全一致;
- 最狠一招:在OnInitDialog()函数末尾,加一行m_btn0.ShowWindow(SW_SHOW);,如果按钮突然显示了,证明绑定失败;如果还是不显示,证明资源ID写错了。
问题2:表达式1+2*3算出9而不是7
这说明ProcessOperator()里的优先级弹栈逻辑失效了。常见原因:
- GetPrecedence()函数里,'*'和'+'的返回值写反了('*'应为2,'+'应为1);
- while循环条件写成了>而不是>=,导致'*'和'+'相等时不弹栈;
- ExecuteTopOperation()里取操作数顺序错了,把a和b颠倒了(应该是a op b,不是b op a)。
独家技巧:在ProcessOperator()的while循环开头,加一句TRACE(_T("弹栈前: opStack=%c, numStack=[%g,%g]\n"), m_opStack.GetAt(m_opStack.GetSize()-1), m_numStack.GetAt(0), m_numStack.GetAt(1));,然后在VC6的“Output”窗口看实时日志,立刻定位弹栈时机是否正确。
问题3:输入1/0不报错,反而显示一个巨大乱码数字
这是浮点数除零未捕获。VC6的/运算符在x87 FPU上,除零会产生一个特殊的NaN(Not a Number)值,CString::Format()会把它转成一串乱码。解决方案已在ExecuteTopOperation()中给出:必须用fabs(b) < 1e-10做阈值判断,而不是b==0。更彻底的方案:在Evaluate()函数开头,加入_clearfp()清除浮点状态,并在ExecuteTopOperation()里用_statusfp()检查浮点异常标志位,但这已超出本项目范围,属于进阶数值计算知识。
问题4:在Win7/Win10上双击Caculator.exe闪退,无任何提示
这是经典的UAC兼容性问题。VC6生成的exe,默认清单(manifest)缺失,Win7+会以“标准用户”权限运行,而某些GDI绘图操作(如CDC::TextOut())在低权限下可能失败。解决方案:
- 右键Caculator.exe → “属性” → “兼容性”选项卡;
- 勾选“以兼容模式运行这个程序”,选择“Windows XP (Service Pack 3)”;
- 勾选“以管理员身份运行此程序”;
- 点击“确定”。
如果仍不行,终极方案:用Resource Hacker工具,给exe注入一个最小化manifest文件,声明<requestedExecutionLevel level="asInvoker" uiAccess="false"/>,明确告诉系统“请以当前用户权限运行”。
5.3 二次开发避坑清单
- 不要修改
StdAfx.h里的#define WINVER 0x0400:这是VC6的默认设置,对应Win95 API。改成0x0501(WinXP)会导致CFileDialog等类编译失败。本项目所有API调用都兼容WINVER 0x0400。 - 添加新按钮时,ID命名必须唯一且连续:VC6的ClassWizard对ID有缓存,如果
IDC_BUTTON_10后面直接建IDC_BUTTON_12,Wizard可能无法识别12。建议用IDC_BTN_ADD,IDC_BTN_SUB等语义化命名。 - 调试时慎用
AfxMessageBox():它会阻塞消息循环,如果你在OnPaint()里调用它,会导致界面假死。调试信息一律用TRACE()输出到Output窗口。 - 发布前务必清理中间文件:
.obj,.pch,.pdb,.ilk这些文件体积大且与机器相关,打包时只保留.dsw,.dsp,.cpp,.h,.rc,.ico,.exe即可。.gitignore文件已为你列好清单。
6. 项目延伸与个人实践体会
这个VC++6.0计算器,表面看是个小玩具,但它的代码骨架,足以支撑起一个完整的Win32 GUI应用雏形。我在实际工作中,曾基于它快速搭建过一个工业PLC指令解析器:把+ - * / 替换成LD AND OR OUT等梯形图指令,把数字换成寄存器地址(D100, X001),核心的双栈解析逻辑几乎不用改,只是ExecuteTopOperation()里换成了PLC指令的语义执行。这证明了“计算引擎”与“业务逻辑”的完美解耦。
如果你打算继续深挖,我建议三个方向:
- 图形化升级:用GDI+重写OnPaint(),让按钮有圆角、阴影、按下状态动画。VC6不原生支持GDI+,但可以手动加载gdiplus.dll,用GdiplusStartup()初始化,这是学习Windows图形子系统的好入口;
- 历史记录功能:在CaculatorDlg.h里加一个CArray<CString, CString>成员,每次计算后Add(m_strDisplay + _T("=") + strResult),再用一个CListBox控件显示。这会带你深入CListBox::AddString()和CListBox::SetCurSel()的消息交互;
- 网络计算扩展:在OnButtonEqual()里,不调用本地Evaluate(),而是用CSocket类连接一个远程Python服务器(import http.server搭的简易HTTP服务),把表达式POST过去,接收JSON结果。这会迫使你学习VC6的Socket编程、HTTP协议构造和异步消息处理(WSAAsyncSelect())。
最后分享一个小技巧:VC6的“Find in Files”功能(Ctrl+Shift+F)是神器。当你想快速定位某个函数在哪被调用,比如ProcessOperator(),直接搜函数名,它会列出所有.cpp文件中的调用位置。比现代IDE的“Find Usages”更原始,但也更可靠——它不依赖索引,只做纯文本匹配,永远不会“找不到”。
这个项目最打动我的地方,不是它能算多复杂的表达式,而是它用最朴素的工具,完成了最扎实的工程实践:没有魔法,只有Add()和RemoveAt();没有框架,只有ON_BN_CLICKED和DDX_Control();没有云服务,只有一个双击即启的exe。它提醒我,技术的本质不是堆砌新名词,而是用确定的手段,解决确定的问题。当你在VC6的灰色IDE里,看着1+2*3一步步变成7,那种掌控感,是任何现代框架都无法替代的。
简介:这是一个用Visual C++ 6.0开发的Windows桌面图形计算器,支持加减乘除、括号嵌套和运算符优先级,核心计算逻辑基于栈结构实现(Stack.cpp/h),能正确解析并计算完整数学表达式。界面使用MFC框架搭建,主窗口为CaculatorDlg类,提供鼠标点按和键盘输入两种操作方式,双击Caculator.exe即可运行,无需安装额外运行库,兼容Windows XP、Win7等传统系统。资源包包含全部源代码文件(.cpp/.h)、MFC工程文件(.dsw/.dsp)、编译中间产物(.obj/.pch/.pdb/.ilk)、资源文件(图标.ico、对话框布局.rc)以及调试信息,结构清晰,适合用来学习MFC窗口编程、栈在表达式求值中的应用、VC6.0项目构建流程和Win32 GUI开发实践。所有文件均可直接打开、修改、重新编译,工程已配置好调试环境,方便单步跟踪计算过程。
更多推荐

所有评论(0)