第55章 MFC 与 Windows 桌面应用
第54章讨论了 MSVC 的二进制契约:调用约定、名称修饰与数据模型。这些契约决定了 Windows 原生程序如何与操作系统交互。本章进入建立在这一契约之上的第一代 C++ 桌面框架——MFC(Microsoft Foundation Classes)。MFC 的价值不在于它“过时”或“仍在使用”这样简单的判断,而在于它展示了一种特定的架构选择:如何在不修改 Win32 API 的前提下,用 C++ 的类继承、虚函数与宏,为过程式的消息驱动模型提供面向对象的封装,同时避免为数百种消息各自声明虚函数所带来的 vtable 膨胀。理解这一选择背后的约束与权衡,比记住消息映射宏的写法更有长期价值。
55.1 MFC 的定位:Win32 之上的间接层
55.1.1 直接虚函数方案为何不可行
Win32 的窗口过程是一个单一的回调函数,通过 switch 语句按消息 ID 分派。若用 C++ 虚函数直接建模,最自然的方案是为每一种窗口消息声明一个虚函数 OnPaint、OnLButtonDown、OnTimer 等。这一方案的问题在于 vtable 的规模。设系统定义的消息数为 NsysN_{\text{sys}}Nsys,应用程序自定义消息数为 NappN_{\text{app}}Napp,则每个 CWnd 派生类的 vtable 需要容纳
Nvtable=Nsys+Napp
N_{\text{vtable}} = N_{\text{sys}} + N_{\text{app}}
Nvtable=Nsys+Napp
个函数指针。在 MFC 诞生的年代,系统消息已有数百种,且数量随 Windows 版本持续增长。为每个消息声明虚函数意味着:任何新增消息都会改变 vtable 布局,破坏已有派生类的二进制兼容性;同时,绝大多数窗口只关心其中极少数的消息,为不关心的消息保留 vtable 槽位是纯粹的空间浪费。
55.1.2 消息映射的间接方案
MFC 的解决方案是用数据表查找替代虚函数分派。每个需要处理消息的类维护一张静态数组 _messageEntries,数组的每个元素是一个 AFX_MSGMAP_ENTRY 结构,记录消息标识与处理函数的对应关系。DECLARE_MESSAGE_MAP() 宏在类中声明三个成员:私有的 _messageEntries 数组、受保护的 messageMap 结构(指向该数组并持有指向基类 messageMap 的指针),以及虚函数 GetMessageMap() 用于返回 messageMap 的地址。
这一设计的核心是将分派决策从编译期的 vtable 布局转移到运行期的表查找。新增消息只需在数组中追加条目,不改变任何类的内存布局;不关心的消息不产生条目,没有空间开销。代价是每次消息分派需要一次查找,而非一次虚调用。
55.1.3 查找的复杂度与工程取舍
BEGIN_MESSAGE_MAP / END_MESSAGE_MAP 之间生成的数组是按书写顺序排列的,并不保证按消息 ID 有序。查找采用线性扫描:从当前类的数组开始,逐条比较消息 ID 与控件 ID,未命中则通过 messageMap 中的基类指针递归到父类,直到根类。设类继承深度为 ddd,第 kkk 层数组的条目数为 nkn_knk,则最坏情况下的比较次数为
Cworst=∑k=1dnk.
C_{\text{worst}} = \sum_{k=1}^{d} n_k.
Cworst=k=1∑dnk.
一个自然的优化思路是对数组排序后使用二分查找,将单层查找从 O(nk)O(n_k)O(nk) 降为 O(lognk)O(\log n_k)O(lognk)。但 MFC 并未这样做。原因在于实际条目数很小:一个典型的窗口类只处理十余条消息,线性扫描在常数因子上已经足够快,排序与二分的预处理成本以及维护有序性的约束反而不划算。MFC 选择了另一条优化路径:最近使用消息的哈希缓冲池。_afxMsgCache 是一个容量为 2 的幂的数组,以 (pMessageMap 地址 ^ nMsg) 的低位作为索引,缓存最近查找成功的消息映射条目。对于重复触发的同一消息(如鼠标移动、定时器),缓冲池将查找退化为 O(1)O(1)O(1) 的哈希命中。这一设计的启示是:在条目数小、访问局部性强的场景下,缓存热点比优化通用查找更有效。
55.2 程序结构:控制反转与生命周期
55.2.1 全局对象与框架接管
MFC 程序不显式书写 main 或 WinMain。入口由框架提供,框架在内部完成 AfxWinInit、调用全局 CWinApp 派生对象的 InitInstance、进入消息循环、最终调用 ExitInstance。全局对象 theApp 在 main 之前构造,其构造函数将自身注册为当前应用对象;框架的入口函数通过 AfxGetApp() 获取该指针并驱动整个生命周期。
这一结构是控制反转的早期实例:程序不再调用框架,而是由框架在预定的时点调用程序提供的钩子(InitInstance、ExitInstance)。C++ 全局对象的构造发生在 main 之前,恰好为这种“在入口之前完成注册”的模式提供了语言层面的支持。
55.2.2 消息循环的语义
消息循环的形式可以写为
while (GetMessage(m,null,0,0)) { TranslateMessage(m); DispatchMessage(m); }
\text{while } \big( \mathsf{GetMessage}(m, \text{null}, 0, 0) \big) \; \{ \; \mathsf{TranslateMessage}(m); \; \mathsf{DispatchMessage}(m); \;\}
while (GetMessage(m,null,0,0)){TranslateMessage(m);DispatchMessage(m);}
GetMessage 在队列为空时阻塞,将 CPU 让给其他线程;DispatchMessage 根据消息中的窗口句柄找到对应的窗口过程并调用。MFC 在这一层之上插入 CWnd::WindowProc,后者先处理命令消息的路由(见 55.6),再将非命令消息交给 OnWndMsg 进行消息映射查找。因此,从 Win32 消息到 MFC 处理函数的路径是:DispatchMessage → CWnd::WindowProc → OnWndMsg → 消息映射查找 → 处理函数。
55.2.3 最小程序的结构语义
class CMyApp : public CWinApp {
public:
BOOL InitInstance() override;
};
class CMyFrame : public CFrameWnd {
public:
CMyFrame() { Create(nullptr, _T("第一个 MFC 程序")); }
};
CMyApp theApp;
BOOL CMyApp::InitInstance() {
m_pMainWnd = new CMyFrame;
m_pMainWnd->ShowWindow(SW_SHOW);
return TRUE;
}
Create 在窗口类注册与 CreateWindowEx 调用之间完成了 MFC 对象的关联:窗口句柄 hWndhWndhWnd 被记录在 CWnd 对象的 m_hWnd 成员中,同时该 CWnd 指针被存入一个句柄到对象的映射表,使得后续窗口过程能够从 hWndhWndhWnd 反查回 C++ 对象。这一映射是 MFC 将 Win32 句柄世界与 C++ 对象世界连接起来的关键基础设施。
55.3 消息映射的数据结构与宏展开
55.3.1 AFX_MSGMAP_ENTRY 的字段语义
每个映射条目包含五个字段:nMessage(消息 ID)、nCode(通知码,用于控件通知)、nID(控件或命令 ID)、nLastID(ID 范围上界,用于范围命令)、nSig(函数签名标识)与 pfn(处理函数指针)。nSig 的作用是在运行期校验处理函数的参数类型是否与消息匹配:例如 WM_PAINT 的处理函数签名为 void (CWnd::*)(),而 WM_LBUTTONDOWN 的签名为 void (CWnd::*)(UINT, CPoint),nSig 记录了这些签名的编码,调用前据此进行类型转换。
55.3.2 DECLARE_MESSAGE_MAP 的展开
#define DECLARE_MESSAGE_MAP() \
protected: \
static const AFX_MSGMAP_ENTRY _messageEntries[]; \
static const AFX_MSGMAP messageMap; \
virtual const AFX_MSGMAP* GetMessageMap() const;
messageMap 是一个静态结构,其 pfnGetBaseMap 字段在 BEGIN_MESSAGE_MAP 中被初始化为基类的 GetMessageMap 调用结果。这一设计使得查找可以沿继承链向上回溯,而无需在运行期进行类型信息查询。
55.3.3 BEGIN_MESSAGE_MAP 与静态初始化
#define BEGIN_MESSAGE_MAP(theClass, baseClass) \
const AFX_MSGMAP* theClass::GetMessageMap() const \
{ return &theClass::messageMap; } \
const AFX_MSGMAP theClass::messageMap = \
{ &baseClass::messageMap, &theClass::_messageEntries[0] }; \
const AFX_MSGMAP_ENTRY theClass::_messageEntries[] = {
messageMap 的初始化引用了 baseClass::messageMap 的地址,这要求基类的 messageMap 在链接期已定义。由于所有映射结构都是静态的、编译期可知的,链接器可以解析这些跨翻译单元的引用。_messageEntries 数组以 END_MESSAGE_MAP 插入的哨兵条目(nMessage == 0 且 nSig == AfxSig_end)终止,查找循环以哨兵为边界条件。
55.3.4 常用映射宏的语义差异
| 宏 | 匹配条件 | 典型用途 |
|---|---|---|
ON_COMMAND(id, fn) | WM_COMMAND,nID == id,nCode == 0 | 菜单、工具栏、加速键 |
ON_BN_CLICKED(id, fn) | WM_COMMAND,nID == id,nCode == BN_CLICKED | 按钮点击 |
ON_MESSAGE(msg, fn) | nMessage == msg,签名 LRESULT (WPARAM, LPARAM) | 自定义消息 |
ON_WM_PAINT() | WM_PAINT,签名 void () | 重绘 |
ON_WM_TIMER() | WM_TIMER,签名 void (UINT_PTR) | 定时器 |
ON_COMMAND 与 ON_BN_CLICKED 在消息层面的区别仅在于 nCode 字段:按钮点击是 WM_COMMAND 的一种特定通知码。MFC 将它们拆分为不同宏,是为了在声明处表达意图,而非改变分派机制。
55.4 对话框、控件与 DDX
55.4.1 模态对话框的控制流
CDialog::DoModal() 创建模态对话框并启动一个局部消息循环。在对话框关闭之前,DoModal 不返回,但主消息循环仍然在处理其他窗口的事件——这并非因为主循环被暂停,而是因为模态循环拦截了本应发往父窗口的输入,并通过 EnableWindow(FALSE) 禁用了父窗口。返回值为 IDOK 或 IDCANCEL,表示对话框以确认还是取消结束。
55.4.2 DDX 的双向交换模型
DDX(Dialog Data Exchange)的核心是一个方向受控的批量同步操作。UpdateData(BOOL bSaveAndValidate) 创建一个 CDataExchange 对象,将其方向标志设为 bSaveAndValidate,然后调用对话框类的 DoDataExchange 重写。DoDataExchange 中每个 DDX_ 调用根据 CDataExchange 的方向决定数据流向:
UpdateData(TRUE):控件值 → 成员变量。UpdateData(FALSE):成员变量 → 控件值。
void CMyDialog::DoDataExchange(CDataExchange* pDX) {
CDialog::DoDataExchange(pDX);
DDX_Text(pDX, IDC_NAME, m_strName);
DDX_Text(pDX, IDC_SCORE, m_nScore);
DDV_MinMaxInt(pDX, m_nScore, 0, 100);
}
DDV(Dialog Data Validation)紧随 DDX 之后执行,仅在 bSaveAndValidate == TRUE(即数据从控件流向变量)时生效:它校验刚被写入成员变量的值是否满足约束,若不满足则弹出错误提示、将焦点设回违规控件,并抛出异常以中止交换。这一“交换后校验”的顺序保证了校验对象是即将被程序使用的值,而非即将被显示的值。
DoDataExchange 由框架在多个时点自动调用:OnInitDialog 的默认实现调用 UpdateData(FALSE) 把成员变量的初值刷到控件;OnOK 的默认实现调用 UpdateData(TRUE) 把用户输入收回成员变量。程序员通常只需在成员变量声明后、调用 DoDataExchange 之前设置初值,而无需手动同步。
55.4.3 控件包装与子类化
DDX_Control 把一个 CWnd 派生对象(如 CButton、CEdit)绑定到对话框中的控件 ID,使该对象成为控件句柄的 C++ 包装。此后可以通过对象调用控件方法(如 m_list.AddString(...)),而无需直接使用 SendMessage。这种绑定是永久性的:控件对象的生命周期与对话框对象一致,而非仅在单次数据交换期间有效。
55.5 资源文件与界面定义
.rc 文件是 Windows 资源编译器(rc.exe)的输入,定义对话框模板、菜单、图标、字符串表、加速键表等。资源在编译期被编码为二进制资源节,链接进 PE 文件;运行时通过 FindResource / LoadResource 按类型与名称查找。
一个对话框模板的文本形式如下:
IDD_DIALOG1 DIALOGEX 0, 0, 300, 200
STYLE DS_SETFONT | WS_DLGFRAME
FONT 9, "宋体"
BEGIN
LTEXT "请输入:", IDC_STATIC, 10, 10, 60, 20
EDITTEXT IDC_EDIT, 80, 10, 200, 20
DEFPUSHBUTTON "确定", IDOK, 100, 150, 80, 30
END
坐标以对话框单位(Dialog Unit, DU)表示,而非像素。对话框单位由系统字体决定:水平方向一个单位等于平均字符宽度的四分之一,垂直方向等于字符高度的八分之一。这一机制使得对话框在不同 DPI 与不同系统字体下保持比例一致,代价是像素坐标需要经过一次基于字体度量的换算。Resource.h 中的 ID 宏由资源编辑器生成,代码与资源通过数值 ID 关联,而非字符串名称。
55.6 文档/视图架构与命令路由
55.6.1 观察者模式的实例化
Document/View 是观察者模式在 MFC 中的具体实现:CDocument 是通知者,持有数据并维护一个指向其所有视图的指针列表;CView 是观察者,在 OnDraw 中从文档读取数据并绘制。当文档数据发生变化时,CDocument::UpdateAllViews 遍历视图列表,逐个调用视图的 OnUpdate,使所有视图同步刷新。
设文档关联的视图数为 kkk,一次数据变更的通知代价为
Cnotify=∑i=1kcupdate(i),
C_{\text{notify}} = \sum_{i=1}^{k} c_{\text{update}}^{(i)},
Cnotify=i=1∑kcupdate(i),
其中 cupdate(i)c_{\text{update}}^{(i)}cupdate(i) 是第 iii 个视图的刷新代价。这一设计使得同一份数据可以用表格、图表或任意自定义视图同时呈现,而无需在数据层感知视图的存在。与 Qt 的 Model/View 相比,MFC 的 Document/View 缺少索引抽象与按需查询:视图通常持有对文档数据的直接引用或整体重绘,而非通过行列索引逐格获取。这一差异在数据量大时表现为性能差距,但在文档规模有限的传统桌面场景中并不构成瓶颈。
55.6.2 命令路由的链式传播
命令消息(WM_COMMAND,来自菜单、工具栏、加速键)不沿普通消息映射直接分派,而是走命令路由。CCmdTarget::OnCmdMsg 的默认实现先在当前命令目标的类消息映射中查找处理函数;若未命中,则沿标准路由链将命令传递给下一个目标。典型的路由链为
View→Document→Frame→App.
\text{View} \to \text{Document} \to \text{Frame} \to \text{App}.
View→Document→Frame→App.
框架窗口(CFrameWnd)在收到命令后,先将其交给当前活动视图,视图未处理则交给文档,依此类推。这一机制使得命令处理可以放在最合适的层级:与数据相关的命令放在 CDocument,与界面布局相关的命令放在 CFrameWnd,全局命令放在 CWinApp,而无需在每一层重复注册。
55.6.3 命令路由的工程含义
命令路由的松耦合特性意味着菜单项的可用状态与命令处理函数的位置无关。一个“保存”命令无论在视图、文档还是应用层处理,菜单项的点击都会到达正确的处理函数。ON_UPDATE_COMMAND_UI 进一步将命令的启用/禁用状态与处理函数绑定:在菜单下拉或工具栏更新时,框架调用更新处理函数,后者根据当前文档状态决定是否置灰菜单项。这使得界面状态与业务状态之间不需要手动同步。
55.7 GDI 绘图与设备上下文
55.7.1 CDC 的封装语义
CDC 是 Win32 HDC 的 C++ 包装。OnDraw(CDC* pDC) 中的 pDC 指向一个已经与目标设备关联的设备上下文:在屏幕绘制时关联窗口,在打印时关联打印机。MFC 通过 CView::OnDraw 的参数抽象,使得同一段绘图代码可以输出到屏幕或打印机,而无需区分设备类型。
55.7.2 SelectObject 的句柄归还约束
GDI 对象(画笔、画刷、字体、位图)在被选入设备上下文后,设备上下文持有对该对象的引用。SelectObject 返回先前被选入的同类对象。若调用者不保存并最终恢复旧对象,设备上下文在销毁时仍引用着新对象,导致 GDI 句柄泄漏。设进程的 GDI 句柄配额为 QQQ(Windows 默认每个进程约 10,000 个),每次泄漏一个句柄,则经过 QQQ 次绘制周期后,CreatePen 或 SelectObject 将失败,绘制静默退化或程序崩溃。
正确的模式是:
CBrush br(RGB(255, 0, 0));
CBrush* pOld = pDC->SelectObject(&br);
pDC->Ellipse(30, 220, 120, 310);
pDC->SelectObject(pOld);
pOld 保存的是设备上下文的原始状态,恢复它是将设备上下文还原到调用前状态的必要条件。CDC::SaveDC() / RestoreDC() 提供了更粗粒度的状态保存与恢复,适用于一次保存多个对象属性的场景。在异常可能中断绘制的代码路径中,SaveDC/RestoreDC 比手动保存每个旧对象更可靠。
55.7.3 OnDraw 与 OnPaint 的分工
在 Document/View 程序中,绘制通常放在 CView::OnDraw(CDC*) 中,而非直接处理 WM_PAINT。框架在 OnPaint 中准备好 CPaintDC(封装 BeginPaint / EndPaint),然后调用 OnDraw,使 OnDraw 无需关心绘制是否发生在响应 WM_PAINT 的上下文中。CPaintDC 的构造函数调用 BeginPaint,析构函数调用 EndPaint;若在 OnPaint 中错误地使用 CClientDC 而非 CPaintDC,将导致更新区域不被清除,引发重复重绘。
55.8 综合实战:DDX 驱动的对话框计算器
以下对话框类演示了 DDX、命令映射与输入校验的组合。成员变量在 DoDataExchange 中绑定到控件,OnAdd 与 OnSub 通过 UpdateData(TRUE) 读取输入,计算后通过 UpdateData(FALSE) 写回结果。
class CCalcDlg : public CDialog {
DECLARE_MESSAGE_MAP()
public:
CCalcDlg(CWnd* p = nullptr) : CDialog(IDD_CALC, p) {}
CString m_strA, m_strB, m_strRes;
void DoDataExchange(CDataExchange* pDX) override {
CDialog::DoDataExchange(pDX);
DDX_Text(pDX, IDC_A, m_strA);
DDX_Text(pDX, IDC_B, m_strB);
DDX_Text(pDX, IDC_RES, m_strRes);
}
void DoCalc(int op) {
if (!UpdateData(TRUE)) return; // 读入失败则中止
int x = _ttoi(m_strA);
int y = _ttoi(m_strB);
int r = (op == 0) ? x + y : x - y;
m_strRes.Format(_T("%d"), r);
UpdateData(FALSE); // 写回显示
}
afx_msg void OnAdd() { DoCalc(0); }
afx_msg void OnSub() { DoCalc(1); }
};
BEGIN_MESSAGE_MAP(CCalcDlg, CDialog)
ON_BN_CLICKED(IDC_ADD, &CCalcDlg::OnAdd)
ON_BN_CLICKED(IDC_SUB, &CCalcDlg::OnSub)
END_MESSAGE_MAP()
UpdateData(TRUE) 的返回值表示交换与校验是否全部成功。若 DDV_ 校验失败,交换被异常中止,返回 FALSE;调用方应在此情况下放弃后续计算,而不是使用可能未更新的成员变量。_ttoi 对非数字字符串返回 0 且不报告错误,因此若要区分“输入 0”与“输入非法”,需要在 DoDataExchange 中添加 DDV_MinMaxInt 或自定义校验。
55.9 MFC 与 Qt 的架构差异
| 维度 | MFC | Qt |
|---|---|---|
| 事件分派 | 消息映射表 + 基类链查找 | 信号槽,元对象系统运行期解析 |
| 耦合方式 | 发送者与接收者通过消息 ID 间接关联 | connect 建立有向连接,发送者不感知接收者 |
| 命令处理 | 沿 View→Doc→Frame→App 路由 | 任意对象通过 connect 接收 |
| 界面描述 | .rc 资源,对话框单位 | .ui 或代码,像素/布局约束 |
| 绘图 | CDC + GDI 句柄 | QPainter + 设备抽象 |
| 跨平台 | 仅 Windows | Windows / Linux / macOS / 嵌入式 |
MFC 的消息映射与 Qt 的信号槽在分派时机上存在结构性差异:MFC 在消息到达窗口过程之后才查找处理函数,查找是拉取式的;Qt 在 connect 时建立连接关系,发射信号时按已建立的连接表推送调用。前者的优势是零运行期连接管理开销,后者的优势是发送者与接收者完全解耦、连接关系可以在运行期动态修改。在性能上,MFC 的直接消息处理通常比 Qt 的信号槽略快,因为后者需要经过元对象查找与参数打包;但这一差距在绝大多数界面交互中并非瓶颈,而 Qt 的跨平台与开发效率收益通常远超这一开销。
55.10 习题与答案
习题 55.1(概念题)
问:MFC 为什么用消息映射表而不是为每种消息声明虚函数?
答:为数百种系统消息声明虚函数会导致每个 CWnd 派生类的 vtable 极其庞大,且系统新增消息会改变 vtable 布局、破坏二进制兼容性。消息映射用静态数组存储“消息 ID → 处理函数”的映射,只有被处理的消息才产生条目,新增消息不改变类布局。代价是运行期需要一次表查找,MFC 通过最近消息哈希缓冲池将重复消息的查找降为 O(1)O(1)O(1)。
习题 55.2(概念题)
问:DECLARE_MESSAGE_MAP 与 BEGIN_MESSAGE_MAP 各自生成了什么?
答:DECLARE_MESSAGE_MAP 在类中声明 _messageEntries 静态数组、messageMap 静态结构和虚函数 GetMessageMap()。BEGIN_MESSAGE_MAP 定义 GetMessageMap 的实现、初始化 messageMap(其中基类指针指向 baseClass::messageMap),并开始 _messageEntries 数组的定义;END_MESSAGE_MAP 插入哨兵条目并闭合数组。
习题 55.3(概念题)
问:UpdateData(TRUE) 与 UpdateData(FALSE) 的数据流向分别是什么?DDV 校验在哪个方向生效?
答:UpdateData(TRUE) 把控件中的值读入成员变量;UpdateData(FALSE) 把成员变量的值写入控件。DDV 校验仅在 TRUE 方向生效,即在校验从控件读入的值,因为这是即将被程序使用的数据。
习题 55.4(概念题)
问:Document/View 中 UpdateAllViews 的作用是什么?它与观察者模式的对应关系如何?
答:UpdateAllViews 遍历文档关联的所有视图,调用每个视图的 OnUpdate,使它们依据文档的最新数据刷新。文档是观察者模式中的 Subject,视图是 Observer;UpdateAllViews 是 Subject 的状态变更通知,OnUpdate 是 Observer 的更新接口。
习题 55.5(概念题)
问:命令路由为什么要把命令沿 View→Document→Frame→App 传递,而不是直接在窗口过程中处理?
答:命令的处理逻辑可能依赖不同层级的状态:与数据相关的命令适合放在文档,与视图呈现相关的适合放在视图,全局命令适合放在应用。路由机制允许把处理函数放在最合适的层级,而无需在每个可能接收命令的对象上重复注册;菜单项的可用性也可以通过 ON_UPDATE_COMMAND_UI 在正确的层级动态决定。
习题 55.6(概念题)
问:SelectObject 返回的旧对象为什么必须恢复?不恢复会导致什么?
答:设备上下文在被销毁时仍引用着被选入的新对象,该 GDI 对象的引用计数不会归零,导致句柄泄漏。每个进程的 GDI 句柄配额有限(约 10,000 个),泄漏耗尽配额后,后续 CreatePen、SelectObject 将失败,绘制静默退化或程序崩溃。恢复旧对象是将设备上下文还原到调用前状态的必要步骤。
习题 55.7(概念题)
问:MFC 的消息映射与 Qt 的信号槽在“分派时机”上有何本质差异?
答:MFC 在消息到达窗口过程后按消息 ID 查找处理函数,是拉取式的、在接收端驱动的分派;Qt 在 connect 时建立连接关系,信号发射时按连接表推送调用,是发送端驱动的分派。前者无运行期连接管理开销,后者实现了发送者与接收者的完全解耦。
55.11 编程题
55.1 最小 MFC 窗口。 编写 CWinApp + CFrameWnd 的最小程序,验证 InitInstance 的调用时机与 ShowWindow 的必要性。在 InitInstance 中故意不调用 ShowWindow,观察程序行为。
55.2 消息映射的查找路径。 在派生类中只处理 WM_LBUTTONDOWN,在基类中处理 WM_PAINT。用调试器在 OnWndMsg 的查找循环处观察:点击鼠标时查找在派生类数组命中,重绘时查找回溯到基类数组。记录每次查找的比较次数。
55.3 DDX 双向同步。 编写对话框,包含两个编辑框绑定到 CString 成员。在按钮点击时先修改成员变量再调用 UpdateData(FALSE),观察控件内容是否更新;再调用 UpdateData(TRUE),观察成员变量是否被控件内容覆盖。
55.4 DDV 校验失败路径。 为分数编辑框添加 DDV_MinMaxInt(0, 100),输入 150 后点击确定。观察 UpdateData(TRUE) 的返回值、焦点位置与弹出消息。验证在 DoCalc 中检查返回值与不检查返回值的差异。
55.5 命令路由定位。 在 Document 与 View 中分别为同一个菜单命令添加处理函数,用调试器观察哪个先被调用。移除 View 中的处理函数,确认命令路由到 Document。
55.6 GDI 句柄泄漏观测。 编写一个自绘控件,在 OnPaint 中创建 CPen 并 SelectObject 但不恢复旧对象,以高频定时器触发重绘。用任务管理器的“GDI 对象”列观察句柄数持续增长;然后加入恢复代码,确认句柄数稳定。
55.12 算法题
55.1 消息映射表的模拟。 用 std::vector<Entry> 实现一个简化消息映射:Entry 包含消息 ID 与函数指针,find 从当前表线性扫描,未命中则沿 base 指针递归。比较线性扫描与哈希缓存两种实现在一万次随机查找中的耗时,验证缓存对重复消息的加速效果。
55.2 对话框布局的坐标换算。 给定对话框模板中的对话框单位坐标与系统字体的平均字符宽度 wcw_cwc 与字符高度 hch_chc,实现像素坐标换算:
xpx=xDU⋅wc4,ypx=yDU⋅hc8.
x_{\text{px}} = \frac{x_{\text{DU}} \cdot w_c}{4}, \qquad y_{\text{px}} = \frac{y_{\text{DU}} \cdot h_c}{8}.
xpx=4xDU⋅wc,ypx=8yDU⋅hc.
验证换算结果与 MapDialogRect 的输出是否一致。
55.3 命令路由的模拟。 用链式结构建模 View→Document→Frame→App 的命令路由:每个节点持有一个“命令 ID → 处理函数”的映射,OnCmdMsg 在当前节点查找,未命中则调用下一节点的 OnCmdMsg。实现该链并验证命令在正确层级被处理。
55.4 _ttoi 的安全实现。 实现一个跨平台的字符串转整数函数,要求:跳过前导空白、识别正负号、拒绝非数字后缀(而非静默返回 0)、报告溢出。给出与 _ttoi 在非法输入下的行为对比。
55.5 表达式求值接入对话框。 将第51章的双栈中缀求值器接入 MFC 对话框计算器,替换 _ttoi 的简单加减逻辑,支持括号与乘除。用 UpdateData(TRUE) 读取表达式字符串,求值后写回结果。
55.6 文档多视图同步。 设计一个包含表格视图与文本视图的文档:两个视图均派生自 CView,文档在数据变更时调用 UpdateAllViews。验证在一个视图中修改数据后,另一个视图是否同步更新;分析 OnUpdate 的参数 lHint 如何用于区分变更类型以避免不必要的全量刷新。
55.13 常见缺陷与工程对策
第一类:消息映射宏不完整。 DECLARE_MESSAGE_MAP 存在于类中但缺少 BEGIN_MESSAGE_MAP / END_MESSAGE_MAP,或反之。链接期报告未解析的 messageMap 或 GetMessageMap。对策是确保三个宏成组出现,且 BEGIN_MESSAGE_MAP 的第二个参数必须是正确的直接基类。
第二类:处理函数签名与消息不匹配。 将 ON_WM_LBUTTONDOWN 的处理函数声明为无参数形式,运行期 nSig 校验失败,处理函数不被调用或参数被错误解释。对策是按消息类型使用 MFC 规定的签名,afx_msg 仅是标记,不参与类型检查,签名正确性由程序员保证。
第三类:DDX 成员变量未初始化即 UpdateData(FALSE)。 在 OnInitDialog 之前调用 UpdateData(FALSE),成员变量的未定义值被写入控件。对策是在构造函数或 OnInitDialog 中先设置成员变量的初值,再依赖默认的 UpdateData(FALSE)。
第四类:UpdateData(TRUE) 失败后继续使用成员变量。 DDV 校验失败时交换被中止,成员变量可能保留旧值或部分更新。对策是检查 UpdateData(TRUE) 的返回值,失败时立即返回。
第五类:SelectObject 旧对象未恢复。 GDI 句柄逐次泄漏,最终耗尽配额。对策是每次 SelectObject 都保存返回值并在绘制完成后恢复;对异常可能中断的路径使用 SaveDC/RestoreDC。
第六类:在 OnPaint 中使用 CClientDC 而非 CPaintDC。 更新区域不被标记为已处理,窗口持续收到 WM_PAINT,CPU 占用飙升。对策是在 OnPaint 中始终使用 CPaintDC;若在 OnDraw 中绘图,框架已提供正确的 CDC*。
第七类:跨线程操作 MFC 窗口对象。 工作线程直接调用 CWnd 方法或 UpdateData,破坏窗口句柄到对象的线程局部映射。对策是通过 PostMessage 或自定义消息把操作投递到主线程,而非跨线程直接调用。
第八类:资源 ID 冲突或未定义。 .rc 中的 ID 与 Resource.h 不一致,或两个控件使用了同一 ID,导致 ON_BN_CLICKED 映射到错误的控件。对策是让资源编辑器统一管理 ID,避免手工编辑 Resource.h 造成不同步。
55.14 本章小结
本章以“间接层”为线索,分析了 MFC 在 Win32 之上所做的架构选择。消息映射用静态数组查找替代虚函数分派,以运行期查找的代价换取了 vtable 的稳定与空间的节省;线性扫描在条目数小的前提下足够快,最近消息哈希缓冲池则把重复消息的查找降为常数时间。DDX 把控件与成员变量之间的双向同步封装为方向受控的批量操作,DDV 在“控件 → 变量”方向上执行校验,二者的执行顺序由 DoDataExchange 的调用约定保证。Document/View 实例化观察者模式,使同一数据支撑多个视图;命令路由把命令沿 View→Document→Frame→App 传递,允许处理逻辑落在最合适的层级。GDI 绘制的核心约束是句柄的借用与归还:SelectObject 返回的旧对象必须恢复,否则设备上下文持有新对象的引用直至销毁,句柄配额被逐步耗尽。
这些机制之间的关联构成了 MFC 程序的约束网络:消息映射的宏必须成组且基类正确,DDX 的返回值必须被检查,GDI 旧对象必须恢复,命令处理的位置决定路由路径。理解这些约束,比记住 API 名称更能支撑对存量 MFC 代码的阅读、修改与迁移。MFC 的价值不在于它是否是“新项目的最优选择”,而在于它展示了一种在受限的历史条件下用 C++ 封装过程式 API 的完整方案;这一方案的得失,对理解后续任何 GUI 框架的设计都构成有益的参照。
下一章进入第十一篇,从二叉树与树结构开始讨论高级数据结构。
55.15 自检清单
- 能说明消息映射为何用表查找替代虚函数分派
- 能解释
DECLARE_MESSAGE_MAP/BEGIN_MESSAGE_MAP/END_MESSAGE_MAP各自的生成物 - 能描述消息映射沿基类链查找的过程与缓冲池的作用
- 能区分
ON_COMMAND与ON_BN_CLICKED在消息层面的差异 - 能说明 DDX 的双向数据流向与 DDV 的生效方向
- 能解释
UpdateData(TRUE)返回值的工程意义 - 能说明 Document/View 与观察者模式的对应关系
- 能描述命令路由的标准链与
OnUpdate_COMMAND_UI的作用 - 能解释
SelectObject旧对象恢复的必要性与 GDI 句柄配额的关系 - 能区分
OnPaint中CPaintDC与CClientDC的语义差异 - 能从分派时机与耦合方式两个维度对比 MFC 消息映射与 Qt 信号槽
本文深入剖析MFC作为Win32之上面向对象封装的核心设计:采用静态消息映射表替代虚函数分派,避免vtable膨胀与二进制不兼容;通过宏展开生成BEGIN_MESSAGE_MAP/END_MESSAGE_MAP结构,实现基于继承链的线性查找与哈希缓冲优化;阐述DDX/DDV双向数据交换与校验模型、Document/View观察者模式、命令路由链及GDI设备上下文资源管理约束。重点揭示其在历史约束下的工程权衡。
—— MFC 与 Windows 桌面应用&spm=1001.2101.3001.5002&articleId=169357442&d=1&t=3&u=ec71b64dc4ab4b53994800b83d03b683)
237

被折叠的 条评论
为什么被折叠?



