MFC学习

MFC开发流程

MFC - Microsoft Foundation Classes 的缩写,这是一个架构在Windows API 之上的C++ 类别库(C++ Class Library),意图使Windows 程序设计过程更有效率,更符合物 件导向的精神。

Windows 程序分为「程序代码」和「UI(User Interface)资源」两大部份,两部份最后以RC 编译器整合为一个完整的EXE 文件(图1-1)。所谓UI 资源是指功能菜单、对话框外貌、程序图标、光标形状等等东西。这些UI 资源的实际内容(二进制代码)系借助各 种工具产生,并以各种扩展名存在,如.ico、.bmp、.cur 等等。程序员必须在一个所谓 的资源描述档(.rc)中描述它们。RC 编译器(RC.EXE)读取RC 档的描述后将所有UI 资源档集中制作出一个.RES 档,再与程序代码结合在一起,这才是一个完整的Windows 可执行档。

链接库

应用程序所调用的Windows API 函数是 在「执行时期」才联结上的。那么,「联结时期」所需的函数库做什么用?有哪些? 并不是延伸档名为.dll 者才是动态联结函数库(DLL,Dynamic Link Library),事实 上.exe、.dll、.fon、.mod、.drv、.ocx 都是所谓的动态联结函数库。

Windows 程序调用的函数可分为C Runtimes 以及Windows API 两大部份。

以下是它们的命名规则与使用时机: ■ LIBC.LIB-这是CRuntime函数库的静态联结版本。

■ MSVCRT.LIB-这是CRuntime函数库动态联结版本(MSVCRT40.DLL)的 import 函数库。如果联结此一函数库,你的程序执行时必须有MSVCRT40.DLL

在场。

另一组函数,Windows API,由操作系统本身(主要是Windows 三大模块GDI32.DLL 和 USER32.DLL 和KERNEL32.DLL)提供(注)。虽说动态联结是在执行时期才发生「联结」事实,但在联结时期,联结器仍需先为调用者(应用程序本身)准备一些适当的信 息,才能够在执行时期顺利「跳」到DLL 执行。如果该API 所属之函数库尚未加载, 系统也才因此知道要先行加载该函数库。这些适当的信息放在所谓的「import 函数库」 中。32 位Windows 的三大模块所对应的import 函数库分别为GDI32.LIB 和USER32.LIB 和KERNEL32.LIB。

Windows 发展至今,逐渐加上的一些新的API 函数(例如Common Dialog、ToolHelp) 并不放在GDI 和USER 和KERNEL 三大模块中,而是放在诸如COMMDLG.DLL、 TOOLHELP.DLL 之中。如果要使用这些APIs,联结时还得加上这些DLLs 所对应的 import 函数库,诸如COMDLG32.LIB 和TH32.LIB。

很快地,在稍后的范例程序¡ §Generic¡ ̈ 的makefile 中,你就可以清楚看到联结时期所需 的各式各样函数库(以及各种联结器选项)。

头文件

所有Windows 程序都必须包含WINDOWS.H。早期这是一个巨大的头文件,大约有5000 行左右,Visual C++ 4.0 已把它切割为各个较小的文件,但还以WINDOWS.H 总括之。 除非你十分清楚什么API 动作需要什么头文件,否则为求便利,单单一个WINDOWS.H 也就是了。

不过,WINDOWS.H 只照顾三大模块所提供的API 函数,如果你用到其它system DLLs, 例如COMMDLG.DLL 或MAPI.DLL 或TAPI.DLL 等等,就得包含对应的头文件,例如 COMMDLG.H 或MAPI.H 或TAPI.H 等等。

以消息为基础,以事件驱动之 (message based, event driven)

Windows 程序的进行系依靠外部发生的事件来驱动。换句话说,程序不断等待(利用一 个while 回路),等待任何可能的输入,然后做判断,然后再做适当的处理。上述的「输 入」是由操作系统捕捉到之后,以消息形式(一种数据结构)进入程序之中。

如果把应用程序获得的各种「输入」分类,可以分为由硬件装置所产生的消息(如鼠标 移动或键盘被按下),放在系统队列(system queue)中,以及由Windows 系统或其它 Windows 程序传送过来的消息,放在程序队列(application queue)中。以应用程序的眼 光来看,消息就是消息,来自哪里或放在哪里其实并没有太大区别,反正程序调用 GetMessage API 就取得一个消息,程序的生命靠它来推动。所有的GUI 系统,包括UNIX 的X Window 以及OS/2 的Presentation Manager,都像这样,是以消息为基础的事件驱 动系统。

可想而知,每一个Windows 程序都应该有一个回路如下: MSG msg;

while (GetMessage(&msg, NULL, NULL, NULL)) {

TranslateMessage(&msg);

DispatchMessage(&msg);

}

// 以上出现的函数都是Windows API 函数

消息,也就是上面出现的MSG 结构,其实是Windows 内定的一种资料格式:

/* Queued message structure */

typedef struct tagMSG

{

HWND hwnd;

UINT message; // WM_xxx,例如WM_MOUSEMOVE,WM_SIZE... WPARAM wParam;

LPARAM lParam;

DWORD time;

POINT pt;

}MSG;

接受并处理消息的主角就是窗口。每一个窗口都应该有一个函数负责处理消息,程序员 必须负责设计这个所谓的「窗口函数」(window procedure,或称为window function)。 如果窗口获得一个消息,这个窗口函数必须判断消息的类别,决定处理的方式。 以上就是Windows 程序设计最重要的观念。至于窗口的产生与显示,十分简单,有专 门的API 函数负责。稍后我们就会看到Windows 程序如何把这消息的取得、分派、处 理动作表现出来。

Windows 程序的本体与操作系统之间的关系。

Makefile

所谓makefile,就是让你能够设定某个文件和某个文件相比-- 比较其产生日期。由其比较结果来决定要不要做某些你所指定的动作。例如:

generic.res : generic.rc generic.h

rc generic.rc

意思就是拿冒号(:)左边的generic.res 和冒号右边的generic.rc 和generic.h 的文件日 期相比。只要右边任一文件比左边的文件更新,就执行下一行所指定的动作。这动作可 以是任何命令列动作,本例为rc generic.rc。 因此,我们就可以把不同文件间的依存关系做一个整理,以makefile 语法描述,以产生必要的编译、联结动作。makefile 必须以NMAKE.EXE(Microsoft 工具)或MAKE.EXE (Borland 工具)处理之,或其它编译器套件所附的同等工具(可能也叫做MAKE.EXE) 处理之。

程序进入点 WinMain

main 是一般C 程序的进入点:

int main(int argc, char *argv[ ], char *envp[ ]);

{ ... }

WinMain 则是Windows 程序的进入点:

int CALLBACK WinMain(HINSTANCE hInstance, HINSTANCE hPrevInstance,

LPSTR lpCmdLine, int nCmdShow)

{ ... }

// 在Win32 中CALLBACK 被定义为__stdcall,是一种函数调用习惯,关系到 // 参数挤压到堆栈的次序,以及处理堆栈的责任归属。其它的函数调用习惯还有 // _pascal 和_cdecl

当Windows 的「外壳」(shell,例如Windows 3.1 的程序管理员或Windows 95 的文件 总管)侦测到使用者意欲执行一个Windows 程序,于是调用加载器把该程序加载,然后 调用C startup code,后者再调用WinMain,开始执进程序。WinMain 的四个参数由操作系统传递进来。

窗口类别之注册与窗口之诞生

一开始,Windows 程序必须做些初始化工作,为的是产生应用程序的工作舞台:窗口。 这没有什么困难,因为API 函数CreateWindow 完全包办了整个巨大的工程。但是窗口 产生之前,其属性必须先设定好。所谓属性包括窗口的「外貌」和「行为」,一个窗口 的边框、颜色、标题、位置等等就是其外貌,而窗口接收消息后的反应就是其行为(具 体地说就是指窗口函数本身)。程序必须在产生窗口之前先利用API 函数RegisterClass 设定属性(我们称此动作为注册窗口类别)。RegisterClass 需要一个大型数据结构 WNDCLASS 做为参数,CreateWindow 则另需要11 个参数。

从图可以清楚看出一个窗口类别牵扯的范围多么广泛,其中wc.lpfnWndProc 所指

定的函数就是窗口的行为中枢,也就是所谓的窗口函数。注意,CreateWindow 只产生窗 口,并不显示窗口,所以稍后我们必须再利用ShowWindow 将之显示在屏幕上。又,我 们希望先传送个WM_PAINT 给窗口, 以驱动窗口的绘图动作, 所以调用 UpdateWindow。消息传递的观念暂且不表,稍后再提。

请注意,在Generic 程序中,RegisterClass 被我包装在InitApplication 函数之中, CreateWindow 则被我包装在InitInstance 函数之中。这种安排虽非强制,却很普遍。

窗口类别只需注册一次,即可供同一程序的后续每一个 执行实例(instance)使用(之所以能够如此,是因为所有进程共在一个地址空间中),所以我们把RegisterClass 这个动作安排在「只有第一个执行个体才会进入」的InitApplication 函数中。至于此一进程是否是某个程序的第一个执行 实例,可由WinMain 的参数hPrevInstance 判断之;其值由系统传入。

■ 产生窗口, 是每一个执行实例( instance ) 都得做的动作, 所以我们把 CreateWindow 这个动作安排在「任何执行实例都会进入」的InitInstance 函数中。 以上情况在Windows NT 和Windows 95 中略有变化。由于Win32 程序的每一个执行实

例(instance)有自己的地址空间,共享同一窗口类别已不可能。但是由于Win32 系统令 hPrevInstance 永远为0,所以我们仍然得以把RegisterClass CreateWindow 按旧习惯 安排。既符合了新环境的要求,又兼顾到了旧源代码的兼容。

InitApplication InitInstance 只不过是两个自定函数,为什么我要对此振振有词呢?原 因是MFC 把这两个函数包装成CWinApp 的两个虚拟成员函数。第6章「MFC 程序的 生与死」对此有详细解释。

消息循环

初始化工作完成后,WinMain 进入所谓的消息循环: while (GetMessage(&msg,...)) {

TranslateMessage(&msg); // 转换键盘消息 DispatchMessage(&msg); // 分派消息

}

其中的TranslateMessage 是为了将键盘消息转化,DispatchMessage会将消息传给窗口函 数去处理。没有指定函数名称,却可以将消息传送过去,岂不是很玄?这是因为消息发 生之时,操作系统已根据当时状态,为它标明了所属窗口,而窗口所属之窗口类别又已 经明白标示了窗口函数(也就是wc.lpfnWndProc 所指定的函数),所以DispatchMessage 自有脉络可寻。请注意图1-2 所示,DispatchMessage 经过USER 模块的协助,才把消息交到窗口函数手中。

消息循环中的GetMessage 是Windows 3.x 非强制性(non-preemptive)多任务的关键。应 用程序藉由此动作,提供了释放控制权的机会:如果消息队列上没有属于我的消息,我 就把机会让给别人。透过程序之间彼此协调让步的方式,达到多任务能力。Windows 95 和Windows NT 具备强制性(preemptive)多任务能力,不再非靠GetMessage 释放CPU 控 制权不可,但程序写法依然不变,因为应用程序仍然需要靠消息推动。它还是需要抓消 息!

窗口的生命中枢:窗口函数

消息循环中的DispatchMessage 把消息分配到哪里呢?它透过USER 模块的协助,送到 该窗口的窗口函数去了。窗口函数通常利用switch/case 方式判断消息种类,以决定处置 方式。由于它是被Windows 系统所调用的(我们并没有在应用程序任何地方调用此函 数),所以这是一种call back 函数,意思是指「在你的程序中,被Windows 系统调用」 的函数。这些函数虽然由你设计,但是永远不会也不该被你调用,它们是为Windows 系 统准备的。 程序进行过程中,消息由输入装置,经由消息循环的抓取,源源传送给窗口并进而送到 窗口函数去。窗口函数的体积可能很庞大,也可能很精简,依该窗口感兴趣的消息数量

多寡而定。至于窗口函数的形式,相当一致,必然是:

LRESULT CALLBACK WndProc(HWND hWnd,

UINT message,

WPARAM wParam,

LPARAM lParam)

注意,不论什么消息,都必须被处理,所以switch/case 指令中的default: 处必须调用 DefWindowProc,这是Windows 内部预设的消息处理函数。

窗口函数的wParam lParam 的意义,因消息之不同而异。wParam 在16 位环境中是 16 位,在32 位环境中是32 位。因此,参数内容(格式)在不同操作环境中就有 了变化。我想很多人都会问这个问题:为什么Windows Programming Modal 要把窗口函数设计为 一个call back 函数?为什么不让程序在抓到消息(GetMessage)之后直接调用它就好 了?原因是,除了你需要调用它,有很多时候操作系统也要调用你的窗口函数(例如当某个消息产生或某个事件发生)。窗口函数设计为callback 形式,才能开放出一个接口 给操作系统叫用。

消息映射(Message Map)的雏形

有没有可能把窗口函数的内容设计得更模块化、更一般化些?下面是一种作法。请注意, 以下作法是MFC「消息映射表格」(第9章)的雏形,我所采用的结构名称和变量名称, 都与MFC 相同,藉此让你先有个暖身。

首先,定义一个MSGMAP_ENTRY 结构和一个dim 宏: struct MSGMAP_ENTRY {

UINT nMessage;

LONG (*pfn)(HWND, UINT, WPARAM, LPARAM);

};

#define dim(x) (sizeof(x) / sizeof(x[0]))

请注意MSGMAP_ENTRY 的第二元素pfn 是一个函数指针,我准备以此指针所指之函 数处理nMessage 消息。这正是对象导向观念中把「资料」和「处理资料的方法」封装 起来的一种具体实现,只不过我们用的不是C++ 语言。 接下来,组织两个数组_messageEntries[ ] 和_commandEntries[ ],把程序中欲处理的消 息以及消息处理例程的关联性建立起来:

// 消息与处理例程之对照表格

struct MSGMAP_ENTRY _messageEntries[] = {

WM_CREATE, OnCreate,

WM_PAINT, OnPaint,

WM_SIZE, OnSize,

WM_COMMAND, OnCommand,

WM_SETFOCUS, OnSetFocus,

WM_CLOSE, OnClose,

WM_DESTROY, OnDestroy,

};↑↑

这是消息 这是消息处理例程

// Command-ID 与处理例程之对照表格

struct MSGMAP_ENTRY _commandEntries = {

IDM_ABOUT, OnAbout, IDM_FILEOPEN, OnFileOpen, IDM_SAVEAS, OnSaveAs,

};↑↑ 这是WM_COMMAND 命令项这是命令处理例程

这么一来,WndProc OnCommand 永远不必改变,每有新要处理的消息,只要在 _messageEntries[ ] 和_commandEntries[ ] 两个数组中加上新元素,并针对新消息撰写新 的处理例程即可。

这种观念以及作法就是MFC 的Message Map 的雏形。MFC 把其中的动作包装得更好 更精致(当然因此也就更复杂得多),成为一张庞大的消息地图;程序一旦获得消息, 就可以按图上溯,直到被处理为止。我将在第3章简单仿真MFC 的Message Map,并在 第9章「消息映射与绕行」中详细探索其完整内容。

对话框的运作

Windows 的对话框依其与父窗口的关系,分为两类:

1. 「令其父窗口除能,直到对话框结束」,这种称为modal 对话框。

2. 「父窗口与对话框共同运行」,这种称为modeless 对话框。 比较常用的是modal 对话框。我就以Generic 的¡ §About¡ ̈ 对话框做为说明范例。 为了做出一个对话框,程序员必须准备两样东西:

1. 对话框模板(dialog template)。这是在RC 文件中定义的一个对话框外貌,以各 种方式决定对话框的大小、字形、内部有哪些控制组件、各在什么位置...等等。 2. 对话框函数(dialog procedure)。其类型非常类似窗口函数,但是它通常只处 理WM_INITDIALOG WM_COMMAND 两个消息。对话框中的各个控制组件也 都是小小窗口,各有自己的窗口函数,它们以消息与其管理者(父窗口,也就 是对话框)沟通。而所有的控制组件传来的消息都是WM_COMMAND,再由其 参数分辨哪一种控制组件以及哪一种通告(notification)。

Modal 对话框的激活与结束,靠的是DialogBox EndDialog 两个API 函数。请看图1-4

对话框处理过消息之后,应该传回TRUE;如果未处理消息,则应该传回F ALSE。这是 因为你的对话框函数之上层还有一个系统提供的预设对话框函数。如果你传回F ALSE, 该预设对话框函数就会接手处理。

对话框的诞生、运行、死亡

模块定义文件(.DEF)

Windows 程序需要一个模块定义文件,将模块名称、程序节区和资料节区的内存特性、

模块堆积(heap)大小、堆栈(stack)大小、所有callback 函数名称...等等登记下来。下

面是个实例:

NAME Generic

DESCRIPTION 'Generic Sample'

EXETYPE WINDOWS

STUB 'WINSTUB.EXE'

CODE PRELOAD DISCARDABLE

DATA PRELOAD MOVEABLE MULTIPLE

HEAPSIZE 4096

STACKSIZE 10240

EXPORTS

MainWndProc @1

AboutBox @2

在Visual C++ 整合环境中开发程序,不再需要特别准备.DEF 文件,因为模块定义文件中的 设定都有默认值。模块定义文件中的STUB 指令用来指定所谓的stub 程序(埋在Windows 程序中的一个DOS 程序,你所看到的This Program Requires Microsoft Windows 或This Program Can Not Run in DOS mode 就是此程序发出来的),Win16 允许程序员自设一个 stub 程序,但Win32 不允许,换句话说在Win32 之中Stub 指令已经失效。

资源描述档(.RC)

RC 文件是一个以文字描述资源的地方。常用的资源有九项之多,分别是ICON、CURSOR、 BITMAP、FONT、DIALOG、MENU、ACCELERATOR、STRING、VERSIONINFO。还 可能有新的资源不断加入,例如Visual C++ 4.0 就多了一种名为TOOLBAR 的资源。这 些文字描述需经过RC 编译器,才产生可使用的二进制代码。本例Generic 示范ICON、 MENU 和DIALOG 三种资源。

Windows 程序的生与死

我想你已经了解Windows 程序的架构以及它与Windows 系统之间的关系。对

Windows 消息种类以及发生时机的透彻了解,正是程序设计的关键。现在我以窗口的诞 生和死亡,说明消息的发生与传递,以及应用程序的兴起与结束,请看图1-5 及图1-6

窗口生命周期

为什么结束一个程序复杂如斯?因为操作系统与应用程序职司不同,二者是互相合作的 关系,所以必需各做各的份内事,并互以消息通知对方。如果不依据这个游戏规则,可 能就会有麻烦产生。你可以作一个小实验,在窗口函数中拦截WM_DESTROY,但不调 ÓÃPostQuitMessage。你会发现当选择系统菜单中的Close 时,屏幕上这个窗口消失了, (因为窗口摧毁及数据结构的释放是DefWindowProc 调用DestroyWindow 完成的),但 是应用程序本身并没有结束(因为消息循环结束不了),它还留存在内存中。

空闲时间的处理:OnIdle

所谓空闲时间(idle time),是指「系统中没有任何消息等待处理」的时间。举个例子, 没有任何程序使用定时器(timer,它会定时送来WM_TIMER),使用者也没有碰触键盘 和鼠标或任何外围,那么,系统就处于所谓的空闲时间。 空闲时间常常发生。不要认为你移动鼠标时产生一大堆的WM_MOUSEMOVE,事实上夹 杂在每一个WM_MOUSEMOVE 之间就可能存在许多空闲时间。毕竟,计算机速度超乎想 像。

背景工作最适宜在空闲时间完成。传统的SDK 程序如果要处理空闲时间,可以以下列

循环取代WinMain 中传统的消息循环: while (TRUE) {

if (PeekMessage(&msg, NULL, 0, 0, PM_REMOVE) { if (msg.message == WM_QUIT)

break;

TranslateMessage(&msg);

DispatchMessage(&msg);

}

else {

OnIdle();

}

}

原因是PeekMessage GetMessage 的性质不同。它们都是到消息队列中抓消息,如果 抓不到,程序的主执行线程(primary thread,是一个UI 执行线程)会被操作系统虚悬住。 当操作系统再次回来照顾此一执行线程,而发现消息队列中仍然是空的,这时候两个API 函数的行为就有不同了:

GetMessage 会过门不入,于是操作系统再去照顾其它人。

PeekMessage 会取回控制权,使程序得以执行一段时间。于是上述消息循环进

OnIdle 函数中。

第6章的HelloMFC 将示范如何在MFC 程序中处理所谓的idle time(p.403)。

Console 程序

说到Windows 程序,一定得有WinMain、消息循环、窗口函数。即使你只产生一个对 话窗(Dialog Box)或消息窗(Message Box),也有隐藏在Windows API(DialogBox MessageBox)内里的消息循环和窗口函数。

过去那种单单纯纯纯的C/C++ 程序,有着简单的main printf 的好时光到哪里去 了?夏天在阴凉的树荫下嬉戏,冬天在温暖的炉火边看书,啊,Where did the good times go?

其实说到Win32 程序,并不是每个都如Windows GUI 程序那么复杂可怖。是的,你可 以在Visual C++ 中写一个"DOS-like" 程序,而且仍然可以调用部份的、不牵扯到图形 使用者接口(GUI)的Win32 API。这种程序称为console 程序。甚至你还可以在console 程序中使用部份的MFC 类别(同样必须是与GUI 没有关连的),例如处理数组、串 行等数据结构的collection classes(CArrayCListCMap)、与文件有关的CFileCStdioFile

在Visual C++ 中写纯种的C/C++ 程序?当然可以!不牵扯任何窗口、对话窗、控制组 件,那就是console 程序! 虽然我这本书没有打算照顾 C++ 初学者,然而我还是决定 把console 程序设计的一些相关心得放上来,同时也是因为我打算以console 程序完成稍 后的多线程程序范例。第3章的MFC 六大技术仿真程序也都是console 程序。

其实,除了"DOS-like",console 程序还另有妙用。如果你的程序和使用者之间是以巨量 文字来互动,或许你会选择使用edit 控制组件(或MFC 的CEditView)。但是你知道, 计算机在一个纯粹的「文字窗口」(也就是console 窗口)中处理文字的显现与卷动比较

快,你的程序动作也比较简单。所以,你也可以在Windows 程序中产生console 窗口, 独立出来操作。

这也许不是你所认知的console 程序。总之,有这种混合式的东西存在。 这一节将以我自己的一个极简易的个人备份软件JBACKUP 为实例,说明Win32

console 程序的撰写,以及如何在其中使用Win32 API(其实直接调用就是了)。再以另 一个极小的程序MFCCON 示范MFC console 程序(用到了MFC 的CStudioFile CString)。对于这么小的程序而言,实在不需动用到整合环境下的什么项目管理。至于 复杂一点的程序,就请参考第4章最后一节「Console 程序的项目管理」。

Console 程序与DOS程序的差别

不少人把DOS 程序和console 程序混为一谈,这是不对的。以下是各方面的比较。

制造方式

在Windows 环境下的DOS Box 中,或是在Windows 版本的各种C++ 编译器套件的 整合环境(IDE)中(第4章「Console 程序项目管理」),利用Windows 编译器、联 结器做出来的程序,都是所谓Win32 程序。如果程序是以main 为进入点,调用C runtime 函数和「不牵扯GUI」的Win32 API 函数,那么就是一个console 程序,console 窗口将成为其标准输入和输出装置(cin cout)。

过去在DOS 环境下开发的程序,称为DOS 程序,它也是以main 为程序进入点,可以调用C runtime 函数。但,当然,不可能调用Win32 API 函数。

程序能力

过去的DOS 程序仍然可以在Windows 的DOS Box 中跑(Win95 的兼容性极高, WinNT 的兼容性稍差)。

Console 程序当然更没有问题。由于console 程序可以调用部份的Win32 API(尤其是 KERNEL32.DLL 模块所提供的那一部份),所以它可以使用Windows 提供的各种高级 功能。它可以产生进程(processes),产生执行线程(threads)、取得虚拟内存的信息、 刺探操作系统的各种资料。但是它不能够有华丽的外表-- 因为它不能够调用与GUI 有 关的各种API 函数。

DOS 程序和console 程序两者都可以做printf 输出和cout 输出,也都可以做scanf

入和cin 输入。

可执行档格式

DOS 程序是所谓的MZ 格式(MZ 是Mark Zbikowski 的缩写,他是DOS 系统的一位 主要构造者)。Console 程序的格式则和所有的Win32 程序一样,是所谓的PE(Portable Executable)格式,意思是它可以被拿到任何Win32 平台上执行。

Visual C++ 附有一个DUMPBIN 工具软件,可以观察PE 文件格式。拿它来观察本节

的JBACKUP 程序和MFCCON 程序(以及第3章的所有程序),得到这样的结果: H:\u004\prog\jbackup.01>dumpbin /summary jbackup.exe

Microsoft (R) COFF Binary File Dumper Version 5.00.7022

Copyright (C) Microsoft Corp 1992-1997. All rights reserved.

Dump of file jbackup.exe

File Type: EXECUTABLE IMAGE

Summary

5000 .data

1000 .idata

1000 .rdata

5000 .text

拿它来观察DOS 程序,则得到这样的结果:

C:\UTILITY>dumpbin /summary dsize.exe

Microsoft (R) COFF Binary File Dumper Version 5.00.7022 Copyright (C) Microsoft Corp 1992-1997. All rights reserved.

Dump of file dsize.exe

DUMPBIN : warning LNK4094: "dsize.exe" is an MS-DOS executable;

use EXEHDR to dump it

Summary

MFCCON:MFC Console 程序设计

当你的进度还在第1章的Win32 基本程序观念,我却开始讲如何设计一个MFC console 程序,是否有点时地不宜? 是有一点!所以我挑一个最单纯而无与别人攀缠纠葛的MFC 类别,写一个40 行的小 程序。目标纯粹是为了做一个导入,并与Win32 console 程序做一比较。 我所挑选的两个单纯的MFC 类别是CStdioFile CString:

在MFC 之中,CFile 用来处理正常的文件I/O 动作。CStdioFile 衍生自CFile,一个 CStdioFile 对象代表以C runtime 函数fopen 所开启的一个stream 文件。Stream 文件 有缓冲区,可以文字模式(预设情况)或二进制模式开启。

CString 对象代表一个字符串,是一个完全独立的类别。

我的例子用来计算小于100 的所有费伯纳契数列(Fabonacci sequence)。费伯纳契数列 的计算方式是:

1. 头两个数为1。

2. 接下来的每一个数是前两个数的和。

  1. #0007 #include <afx.h>

  1. #0008 #include <stdio.h>

#0009

  1. #0010 int main()

  1. #0011 {

  1. #0012 int lo, hi;

  1. #0013 CString str;

  1. #0014 CStdioFile fFibo;

#0015

  1. #0016 fFibo.Open("FIBO.DAT", CFile::modeWrite |

  1. #0017 CFile::modeCreate | CFile::typeText);

#0018

  1. #0019 str.Format("%s\n", "Fibonacci sequencee, less than 100 :");

  1. #0020 printf("%s", (LPCTSTR) str);

  1. #0021 fFibo.WriteString(str);

#0022

#0023 lo = hi = 1; #0024

  1. #0025 str.Format("%d\n", lo);

  1. #0026 printf("%s", (LPCTSTR) str);

  1. #0027 fFibo.WriteString(str);

#0028

  1. #0029 while (hi < 100)

  1. #0030 {

  1. #0031 str.Format("%d\n", hi);

  1. #0032 printf("%s", (LPCTSTR) str);

  1. #0033 fFibo.WriteString(str);

  1. #0034 hi = lo + hi;

  1. #0035 lo = hi - lo;

  1. #0036 }

  1. #0038 fFibo.Close();

  1. #0039 return 0;

  1. #0040 }

这么简单的例子中,我们看到MFC Console 程序的几个重点:

1. 程序进入点仍为main

2. 需包含所使用之类别的头文件(本例为AFX.H)

3. 可直接使用与GUI 无关的MFC 类别(本例为CStdioFile CString)

4. 编辑时需指定/MT,表示使用多执行线程版本的C runtime 函数库。 第4点需要多做说明。在MFC console 程序中一定要指定多线程版的C runtime 函数库, 所以必须使用/MT 选项。如果不做这项设定,会出现这样的联结错误:

C Runtime函数库的多线程版本

当C runtime 函数库于1970s 年代产生出来时,PC 的内存容量还很小,多任务是 个新奇观念,更别提什么多执行线程了。因此以当时产品为基础所演化的C runtime 函 数库在多线程(multithreaded)的表现上有严重问题,无法被多线程程序使用。 利用各种同步机制(synchronous mechanism)如critical section、mutex、semaphore、 event,可以重新开发一套支持多执行线程的runtime 函数库。问题是,加上这样的能 力,可能导至程序代码大小和执行效率都遭受不良波及-- 即使你只激活了一个执行 线程。

Visual C++ 的折衷方案是提供两种版本的C runtime 函数库。一种版本给单线程程序 使用,一种版本给多线程程序使用。多线程版本的重大改变是,第一,变量如errno 者 现在变成每个执行线程各拥有一个。第二,多线程版中的数据结构以同步机制加以保护。 Visual C++ 一共有六个C runtime 函数库产品供你选择:

◆Single-Threaded(static) libc.lib 898,826

◆ Multithreaded(static) libcmt.lib 951,142

◆ Multithreaded DLL msvcrt.lib 5,510,000

◆ Debug Single-Threaded(static) libcd.lib 2,374,542

◆ Debug Multithreaded(static)libcmtd.lib 2,949,190 ◆ Debug Multithreaded DLL msvcrtd.lib 803,418

Visual C++ 编译器提供下列选项,让我们决定使用哪一个C runtime 函数库: ◆ /ML Single-Threaded(static)

◆ /MT Multithreaded(static)

◆ /MD Multithreaded DLL(dynamic import library)

◆ /MLd Debug Single-Threaded(static)

◆ /MTd Debug Multithreaded(static)

◆ /MDd Debug Multithreaded DLL(dynamic import library)

进程与执行线程(Process and Thread)

OS/2、Windows NT 以及Windows 95 都支持多执行线程,这带给PC 程序员一股令人兴 奋的气氛。然而它带来的不全然是利多,如果不谨慎小心地处理执行线程的同步问题,程 序的错误以及除错所花的时间可能使你发誓再也不碰「执行线程」这种东西。 我们习惯以进程(process)表示一个执行中的程序,并且以为它是CPU 排程单位。事 实上执行线程才是排程单位。

核心对象

首先让我解释什么叫作「核心对象」(kernel object)。「GDI 对象」是大家比较熟悉的 东西,我们利用GDI 函数所产生的一支笔(Pen)或一支刷(Brush)都是所谓的「GDI 对象」。但什么又是「核心对象」呢? 你可以说核心对象是系统的一种资源(噢,这说法对GDI 对象也适用),系统对象一 旦产生,任何应用程序都可以开启并使用该对象。系统给予核心对象一个计数值(usage count)做为管理之用。核心对象包括下列数种:

前三者用于执行线程的同步化:file-mapping 对象用于内存映射文件(memory mapping file),process 和thread 对象则是本节的主角。这些核心对象的产生方式(也就是我们所使用的API)不同,但都会获得一个handle 做为识别;每被使用一次,其对应的计 数值就加1。核心对象的结束方式相当一致,调用CloseHandle 即可。

「process 对象」究竟做什么用呢?它并不如你想象中用来「执进程序代码」;不,程序代码 的执行是执行线程的工作,「process 对象」只是一个数据结构,系统用它来管理进程。

一个进程的诞生与死亡

执行一个程序,必然就产生一个进程(process)。最直接的程序执行方式就是在shell (如 Win95 的文件总管或Windows 3.x 的文件管理员)中以鼠标双击某一个可执行文件图标 (假设其为App.exe),执行起来的App 进程其实是shell 调用CreateProcess 激活的。 让我们看看整个流程:

  1. shell 调用CreateProcess 激活App.exe。

  1. 系统产生一个「进程核心对象」,计数值为1。

  1. 系统为此进程建立一个4GB 地址空间。

  1. 加载器将必要的码加载到上述地址空间中,包括App.exe 的程序、资料,以及

所需的动态联结函数库(DLLs)。加载器如何知道要加载哪些DLLs 呢?它

们被记录在可执行文件(PE 文件格式)的.idata section 中。

  1. 系统为此进程建立一个执行线程,称为主执行线程(primary thread)。执行线程才是

CPU 时间的分配对象。

  1. 系统调用C runtime 函数库的Startup code。

  1. Startup code 调用App 程序的WinMain 函数。

  1. App 程序开始运作。

  1. 使用者关闭App 主窗口,使WinMain 中的消息循环结束掉,于是WinMain 结束。

10. 回到Startup code。

11. 回到系统,系统调用ExitProcess 结束进程。

可以说,透过这种方式执行起来的所有Windows 程序,都是shell 的子进程。本来,母 进程与子进程之间可以有某些关系存在,但shell 在调用CreateProcess 时已经把母子之间 的脐带关系剪断了,因此它们事实上是独立实例。稍后我会提到如何剪断子进程的脐带。

产生子进程

你可以写一个程序, 专门用来激活其它的程序。关键就在于你会不会使用 CreateProcess。这个API 函数有众多参数:

CreateProcess(

LPCSTR lpApplicationName,

LPSTR lpCommandLine,

LPSECURITY_ATTRIBUTES lpProcessAttributes,

LPSECURITY_ATTRIBUTES lpThreadAttributes,

BOOL bInheritHandles,

DWORD dwCreationFlags,

LPVOID lpEnvironment,

LPCSTR lpCurrentDirectory,

LPSTARTUPINFO lpStartupInfo,

LPPROCESS_INFORMATION lpProcessInformation

);

第一个参数lpApplicationName 指定可执行档档名。第二个参数lpCommandLine 指定欲 传给新进程的命令列(command line)参数。如果你指定了lpApplicationName,但没有 扩展名,系统并不会主动为你加上.EXE 扩展名;如果没有指定完整路径,系统就只在 目前工作目录中寻找。但如果你指定lpApplicationName NULL 的话,系统会以 lpCommandLine 的第一个「段落」(我的意思其实是术语中所谓的token)做为可执行档 档名;如果这个档名没有指定扩展名,就采用预设的".EXE" 扩展名;如果没有指定路 径,Windows 就依照五个搜寻路径来寻找可执行文件,分别是:

1. 调用者的可执行文件所在目录 2. 调用者的目前工作目录

3. Windows 目录

4. Windows System 目录

5. 环境变量中的path 所设定的各目录

让我们看看实例:

CreateProcess("E:\\CWIN95\\NOTEPAD.EXE", "README.TXT",...);

系统将执行E:\CWIN95\NOTEPAD.EXE,命令列参数是"README.TXT"。如果我们这

样子调用:

CreateProcess(NULL, "NOTEPAD README.TXT",...);

系统将依照搜寻次序,将第一个被找到的NOTEPAD.EXE 执行起来,并转送命令列参 数"README.TXT" 给它。 建立新进程之前,系统必须做出两个核心对象,也就是「进程对象」和「执行线程对象」。 CreateProcess 的第三个参数和第四个参数分别指定这两个核心对象的安全属性。至于第 五个参数(TRUE FALSE)则用来设定这些安全属性是否要被继承。关于安全属性及 其可被继承的性质,碍于本章的定位,我不打算在此介绍。

第六个参数dwCreationFlags 可以是许多常数的组合,会影响到进程的建立过程。这些 常数中比较常用的是CREATE_SUSPENDED,它会使得子进程产生之后,其主执行线程立 刻被暂停执行。

第七个参数lpEnvironment 可以指定进程所使用的环境变量区。通常我们会让子进程继 承父进程的环境变量,那么这里要指定NULL

第八个参数lpCurrentDirectory 用来设定子进程的工作目录与工作磁盘。如果指定 NULL,子进程就会使用父进程的工作目录与工作磁盘。

第九个参数lpStartupInfo 是一个指向STARTUPINFO 结构的指针。这是一个庞大的结 构,可以用来设定窗口的标题、位置与大小,详情请看API 使用手册。 最后一个参数是一个指向PROCESS_INFORMATION 结构的指针:

typedef struct _PROCESS_INFORMATION {

HANDLE hProcess;

HANDLE hThread;

DWORD dwProcessId;

DWORD dwThreadId;

} PROCESS_INFORMATION;

当系统为我们产生「进程对象」和「执行线程对象」,它会把两个对象的handle 填入此结 构的相关字段中,应用程序可以从这里获得这些handles。

如果一个进程想结束自己的生命,只要调用:

VOID ExitProcess(UINT fuExitCode);

就可以了。如果进程想结束另一个进程的生命,可以使用:

BOOL TerminateProcess(HANDLE hProcess, UINT fuExitCode);

很显然,只要你有某个进程的handle,就可以结束它的生命。TerminateProcess 并不被 建议使用,倒不是因为它的权力太大,而是因为一般进程结束时,系统会通知该进程所 开启(所使用)的所有DLLs,但如果你以TerminateProcess 结束一个进程,系统不会 做这件事,而这恐怕不是你所希望的。

前面我曾说过所谓割断脐带这件事情,只要你把子进程以CloseHandle 关闭,就达到了

目的。下面是个例子:

PROCESS_INFORMATION ProcInfo;

BOOL fSuccess;

fSuccess = CreateProcess(...,&ProcInfo);

if (fSuccess) {

CloseHandle(ProcInfo.hThread);

CloseHandle(ProcInfo.hProcess);

}

一个执行线程的诞生与死亡

程序代码的执行,是执行线程的工作。当一个进程建立起来,主执行线程也产生。所以每一个 Windows 程序一开始就有了一个执行线程。我们可以调用CreateThread 产生额外的执行 线程,系统会帮我们完成下列事情:

1. 配置「执行线程对象」,其handle 将成为CreateThread 的传回值。

2. 设定计数值为1。

3. 配置执行线程的context。

4. 保留执行线程的堆栈。

5. 将context 中的堆栈指针缓存器(SS)和指令指针缓存器(IP)设定妥当。 看看上面的态势,的确可以显示出执行线程是CPU 分配时间的单位。所谓工作切换(context switch)其实就是对执行线程的context 的切换。

程序若欲产生一个新执行线程,调用CreateThread 即可办到: CreateThread(LPSECURITY_ATTRIBUTES lpThreadAttributes,

DWORD dwStackSize,

LPTHREAD_START_ROUTINE lpStartAddress,

LPVOID lpParameter,

DWORD dwCreationFlags,

LPDWORD lpThreadId

);

第一个参数表示安全属性的设定以及继承,请参考API 手册。Windows 95 忽略此一参 数。第二个参数设定堆栈的大小。第三个参数设定「执行线程函数」名称,而该函数的参 数则在这里的第四个参数设定。第五个参数如果是0,表示让执行线程立刻开始执行,如 果是CREATE_SUSPENDED , 则是要求执行线程暂停执行( 那么我们必须调用 ResumeThread 才能令其重新开始)。最后一个参数是个指向DWORD 的指针,系统会 把执行线程的ID 放在这里。

上面我所说的「执行线程函数」是什么?让我们看个实例:

VOID ReadTime(VOID);

HANDLE hThread;

DWORD ThreadID;

... //-------------------------------------------------------------------- // thread 函数。

// 不断利用 GetSystemTime 取系统时间,

// 并将结果显示在对话框 _hwndDlg 的 IDE_TIMER 栏位上。 //--------------------------------------------------------------------

hThread = CreateThread(NULL, 0, (LPTHREAD_START_ROUTINE)ReadTime, NULL, 0, &ThreadID);

VOID ReadTime(VOID) {

char str[50]; SYSTEMTIME st;

while(1) {

GetSystemTime(&st);

sprintf(str,"%u:%u:%u", st.wHour, st.wMinute, st.wSecond); SetDlgItemText (_hWndDlg, IDE_TIMER, str);

Sleep (1000); // 延迟一秒。

} }

CreateThread 成功,系统为我们把一个执行线程该有的东西都准备好。执行线程的主体在 哪里呢?就在所谓的执行线程函数。执行线程与执行线程之间,不必考虑控制权释放的问题, 因为Win32 操作系统是强制性多任务。

执行线程的结束有两种情况,一种是寿终正寝,一种是未得善终。前者是执行线程函数正常 结束退出,那么执行线程也就自然而然终结了。这时候系统会调用ExitThread 做些善后清 理工作(其实执行线程中也可以自行调用此函数以结束自己)。但是像上面那个例子,执 行线程根本是个无穷循环,如何终结?一者是进程结束(自然也就导至执行线程的结束), 二者是别的执行线程强制以TerminateThread 将它终结掉。不过,TerminateThread 太过毒 辣,非必要还是少用为妙(请参考API 手册)。

以_beginthreadex 取代CreateThread

别忘了Windows 程序除了调用Win32 API,通常也很难避免调用任何一个C runtime 函 数。为了保证多线程情况下的安全,C runtime 函数库必须为每一个执行线程做一些簿记工 作。没有这些工作,C runtime 函数库就不知道要为每一个执行线程配置一块新的内存, 做为执行线程的区域变量用。因此,CreateThread 有一个名为_beginthreadex 的外包函数, 负责额外的簿记工作。 请注意函数名称的底线符号。它必须存在,因为这不是个标准的ANSI C runtime 函数。 _beginthreadex 的参数和CreateThread 的参数其实完全相同,不过其型别已经被「净化」 了,不再有Win32 型别包装。这原本是为了要让这个函数能够移植到其它操作系统,因 为微软希望_beginthreadex 能够被实作于其它平台,不需要和Windows 有关、不需要 包含windows.h。但实际情况是,你还是得调用CloseHandle 以关闭执行线程, 而 CloseHandle 却是个Win32 API,所以你还是需要包含windows.h、还是和Windows 脱 离不了关系。微软空有一个好主意,却没能落实它。

把_beginthreadex 视为CreateThread 的一个看起来比较有趣的版本,就对了:

把_beginthreadex 视为CreateThread 的一个看起来比较有趣的版本,就对了: unsigned long _beginthreadex(

void *security,

unsigned stack_size,

unsigned (__stdcall *start_address)(void *),

void *arglist,

unsigned initflag,

unsigned* thrdaddr

);

_beginthreadex 所传回的unsigned long 事实上就是一个Win32 HANDLE,指向新执行 线程。换句话说传回值和CreateThread 相同,但_beginthreadex 另外还设立了errno 和 doserrno。

下面是一个最简单的使用范例:

#0001 #include <windows.h>

#0002 #include <process.h>

#0003 unsigned __stdcall myfunc(void* p); #0004

  1. #0005 void main()

  1. #0006 {

  1. #0007 unsigned long thd;

  1. #0008 unsigned tid;

#0009

  1. #0010 thd = _beginthreadex(NULL,

  1. #0011 0,

  1. #0012 myfunc,

  1. #0013 0,

  1. #0014 0,

  1. #0015 &tid );

  1. #0016 if (thd != NULL)

  1. #0017 {

  1. #0018 CloseHandle(thd);

  1. #0019 }

  1. #0020 }

#0021

  1. #0022 unsigned __stdcall myfunc(void* p)

  1. #0023 {

  1. #0024 // do your job...

  1. #0025 }

针对Win32 API ExitThread,也有一个对应的C runtime 函数:_endthreadex。它只需要 一个参数,就是由_beginthreadex 第6个参数传回来的ID 值。

关于_beginthreadex 和_endthreadex,以及执行线程的其它各种理论基础、程序技术、使 用技巧,可参考由Jim Beveridge & Robert Wiener 合着,Addison Wesley 出版的 Multithreading Applications in Win32 一书(Win32 多线程程序设计/ 侯俊杰译/ 峰出 版)。

执行线程优先权(Priority)

优先权是排程的重要依据。优先权高的执行线程,永远先获得CPU 的青睐。当然啦,操作 系统会视情况调整各个执行线程的优先权。例如前景执行线程的优先权应该调高一些,背 景执行线程的优先权应该调低一些。

执行线程的优先权范围从0(最低)到31(最高)。当你产生执行线程,并不是直接以数值 指定其优先权,而是采用两个步骤。第一个步骤是指定「优先权等级(Priority Class)」 给进程,第二步骤是指定「相对优先权」给该进程所拥有的执行线程。图1-7 是优先权等 级的描述,其中的代码在CreateProcess dwCreationFlags 参数中指定。如果你不指

定, 系统预设给的是NORMAL_PRIORITY_CLASS -- 除非父进程是 IDLE_PRIORITY_CLASS(那么子进程也会是IDLE_PRIORITY_CLASS)。

■ "idle"等级只有在CPU时间将被浪费掉时(也就是前一节所说的空闲时间) 才执行。此等级最适合于系统监视软件,或屏幕保护软件。

■ "normal"是预设等级。系统可以动态改变优先权,但只限于"normal"等级。 当进程变成前景,执行线程优先权提升为9,当进程变成背景,优先权降低为7。

■ "high" 等级是为了立即反应的需要,例如使用者按下Ctrl+Esc 时立刻把工作管 理器(task manager)带出场。

■ "realtime"等级几乎不会被一般应用程序使用。就连系统中控制鼠标、键盘、磁盘状态重新扫描、Ctrl+Alt+Del 等的执行线程都比"realtime" 优先权还低。这 种等级使用在「如果不在某个时间范围内被执行的话,资料就要遗失」的情况。 这个等级一定得在正确评估之下使用之,如果你把这样的等级指定给一般的 (并不会常常被阻塞的)执行线程,多任务环境恐怕会瘫痪,因为这个执行线程有如 此高的优先权,其它执行线程再没有机会被执行。

上述四种等级,每一个等级又映射到某一范围的优先权值。IDLE_ 最低,NORMAL_ 次 之,HIGH_ 又次之,REALTIME_ 最高。在每一个等级之中,你可以使用SetThreadPriority 设定精确的优先权,并且可以稍高或稍低于该等级的正常值(范围是两个点数)。你可 以把SetThreadPriority 想象是一种微调动作。

除了以上五种微调,另外还可以指定两种微调常数:

虚函数

从操作型定义来看,什么是虚拟函数呢?如果你预期衍生类别有可能重新定义某一个成 员函数,那么你就在基础类别中把此函数设为virtual。MFC 有两个十分十分重要的虚 拟函数:与document 有关的Serialize 函数和与view 有关的OnDraw 函数。你应该在 自己的CMyDoc CMyView 中改写这两个虚拟函数。

多态(Polymorphism)

你看,我们以相同的指令却唤起了不同的函数,这种性质称为Polymorphism,意思是"the ability to assume many forms"(多态)。编译器无法在编译时期判断pEmp->computePay 到底是调用哪一个函数,必须在执行时期才能评估之,这称为后期绑定late binding 或动 态绑定dynamic binding。至于C 函数或C++ 的non-virtual 函数,在编译时期就转换为 一个固定地址的调用了,这称为前期绑定early binding 或静态绑定static binding。

Polymorphism 的目的,就是要让处理「基础类别之对象」的程序代码,能够完全透通地继 续适当处理「衍生类别之对象」。

可以说,虚拟函数是了解多态(Polymorphism)以及动态绑定的关键。同时,它也是了 解如何使用MFC 的关键。

让我再次提示你,当你设计一套类别,你并不知道使用者会衍生什么新的子类别出来。 如果动物世界中出现了新品种名曰雅虎,类别使用者势必在CAnimal 之下衍生一个 CYahoo。饶是如此,身为基础类别设计者的你,可以利用虚拟函数的特性,将所有动物 必定会有的行为(例如哮叫roar),规划为虚拟函数,并且规划一些一般化动作(例如 「让每一种动物发出一声哮叫」)。那么,虽然,你在设计基础类别以及这个一般化动 作时,无法掌握使用者自行衍生的子类别,但只要他改写了roar 这个虚拟函数,你的 一般化对象操作动作自然就可以调用到该函数。

再次回到前述的Shape 例子。我们说CShape 是抽象的,所以它根本不该有display 这 个动作。但为了在各具象衍生类别中绘图,我们又不得不在基础类别CShape 加上 display 虚拟函数。你可以定义它什么也不做(空函数):

class CShape

{

public:

virtual void display() { }

};

或只是给个消息:

class CShape

{

public:

virtual void display() { cout << "Shape \n"; }

};

这两种作法都不高明,因为这个函数根本就不应该被调用(CShape 是抽象的),我们根

本就不应该定义它。不定义但又必须保留一块空间(spaceholder)给它,于是C++ 提供

了所谓的纯虚拟函数:

class CShape

{

public:

virtual void display() = 0; // 注意"= 0" };

纯虚拟函数不需定义其实际动作,它的存在只是为了在衍生类别中被重新定义,只是为 了提供一个多态接口。只要是拥有纯虚拟函数的类别,就是一种抽象类别,它是不能够 被具象化(instantiate)的,也就是说,你不能根据它产生一个对象(你怎能说一种形状 为'Shape' 的物体呢)。如果硬要强渡关山,会换来这样的编译消息:

error : illegal attempt to instantiate abstract class.

关于抽象类别,我还有一点补充。CCircle 继承了CShape 之后,如果没有改写CShape 中 的纯虚拟函数,那么CCircle 本身也就成为一个拥有纯虚拟函数的类别,于是它也是一 个抽象类别。

是对虚拟函数做结论的时候了:

■ 如果你期望衍生类别重新定义一个成员函数,那么你应该在基础类别中把此函

数设为virtual

■ 以单一指令唤起不同函数,这种性质称为Polymorphism,意思是"the ability to

assume many forms",也就是多态。

■ 虚拟函数是C++ 语言的Polymorphism 性质以及动态绑定的关键。

  • 既然抽象类别中的虚拟函数不打算被调用,我们就不应该定义它,应该把它设 为纯虚拟函数(在函数声明之后加上"=0" 即可)。

  • 我们可以说,拥有纯虚拟函数者为抽象类别(abstract Class),以别于所谓的 具象类别(concrete class)。

  • 抽象类别不能产生出对象实体,但是我们可以拥有指向抽象类别之指针,以便 于操作抽象类别的各个衍生类别。

  • 虚拟函数衍生下去仍为虚拟函数,而且可以省略virtual 关键词。

Object slicing 与 虚 拟 函 数

由于CMyDoc 自己没有func 函数,而它继承了CDocument 的所有成员,所以main 之中 的四个调用动作毫无问题都是调用CDocument::func。但,CDocument::func 中所调用的 Serialize 是哪一个类别的成员函数呢?如果它是一般(non-virtual)函数,毫无问题应该 是CDocument::Serialize。但因为这是个虚拟函数,情况便有不同。以下是执行结果:

#1 testing

CDocument::func()

CMyDoc::Serialize()

#2 testing

CDocument::func()

CMyDoc::Serialize()

#3 testing

CDocument::func()

CMyDoc::Serialize()

#4 testing

CDocument::func() CDocument::Serialize() <-- 注意

前三个测试都符合我们对虚拟函数的期望:既然衍生类别已经改写了虚拟函数Serialize, 那么理当调用衍生类别之Serialize 函数。这种行为模式非常频繁地出现在application framework 身上。后续当我追踪MFC 源代码时,遇此情况会再次提醒你。 第四项测试结果则有点出乎意料之外。你知道,衍生对象通常都比基础对象大(我是指 内存空间),因为衍生对象不但继承其基础类别的成员,又有自己的成员。那么所谓 的upcasting(向上强制转型): (CDocument)mydoc,将会造成对象的内容被切割(object slicing):

当我们调用:

((CDocument)mydoc).func();

mydoc 已经是一个被切割得剩下半条命的对象,而func 内部调用虚拟函数Serialize;后 者将使用的「mydoc 的虚拟函数指针」虽然存在,它的值是什么呢?你是不是隐隐觉得 有什么大灾难要发生?

幸运的是,由于((CDocument)mydoc).func() 是个传值而非传址动作,编译器以所谓 的拷贝构造式(copy constructor)把CDocument 对象内容复制了一份,使得mydoc 的 vtable 内容与CDocument 对象的vtable 相同。本例虽没有明显做出一个拷贝构造式, 编译器会自动为你合成一个。

说这么多,总结就是,经过所谓的data slicing,本例的mydoc 真正变成了一个完完全全

静态成员(变量与函数)

我想你已经很清楚了,如果你依据一个类别产生出三个对象,每一个对象将各有一份成

员变量。有时候这并不是你要的。假设你有一个类别,专门用来处理存款帐户,它至少

应该要有存户的姓名、地址、存款额、利率等成员变量:

class SavingAccount

{

private:

char m_name[40]; // 存戶姓名 char m_addr[60]; // 存戶地址 double m_total; // 存款額 double m_rate; // 利率

... };

这家行库采用浮动利率,每个帐户的利息都是根据当天的挂牌利率来计算。这时候 m_rate 就不适合成为每个帐户对象中的一笔资料,否则每天一开市,光把所有帐户内容

叫出来,修改m_rate 的值,就花掉不少时间。m_rate 应该独立在各对象之外,成为类 别独一无二的资料。怎么做?在m_rate 前面加上static 修饰词即可:

class SavingAccount

{

private:

char m_name[40]; // 存户姓名 char m_addr[60]; // 存户地址 double m_total; // 存款额 static double m_rate; // 利率 ...

};

static 成员变量不属于对象的一部份,而是类别的一部份,所以程序可以在还没有诞生任

何对象的时候就处理此种成员变量。但首先你必须初始化它。

不要把static 成员变量的初始化动作安排在类别的构造式中,因为构造式可能一再被调

用,而变量的初值却只应该设定一次。也不要把初始化动作安排在头文件中,因为它可

能会被包含许多地方,因此也就可能被执行许多次。你应该在实作档中且类别以外的任 何位置设定其初值。例如在main 之中,或全域函数中,或任何函数之外:

double SavingAccount::m_rate = 0.0075; // 设立static 成员变量的初值 void main() { ... }

这么做可曾考虑到m_rate 是个private 资料?没关系,设定static 成员变量初值时, 不受任何存取权限的束缚。请注意,static 成员变量的型别也出现在初值设定句中,因为 这是一个初值设定动作,不是一个数量指定(assignment)动作。事实上,static 成员变 量是在这时候(而不是在类别声明中)才定义出来的。如果你没有做这个初始化动作, 会产生联结错误:

error LNK2001: unresolved external symbol "private: static double SavingAccount::m_rate"(?m_rate@SavingAccount@@2HA)

关于static 成员的使用实例,第6章的HelloMFC 有一个,附录D的「自制DBWIN 工 具(MFC 版)」也有一个。第3章的「RTTI(执行时期型别辨识)」一节仿真MFC 的 CRuntimeClass,也有一个static 应用实例。

下面是存取static 成员变量的一种方式,注意,此刻还没有诞生任何对象实体:

// 第一种存取方式 void main()

{

SavingAccount::m_rate = 0.0075; // 欲此行成立,须把 m_rate 改为public }

下面这种情况则是产生一个对象后,透过对象来处理static 成员变量:

// 第二种存取方式 void main()

{

SavingAccount myAccount;

myAccount.m_rate = 0.0075; // 欲此行成立,须把 m_rate 改为public }

你得搞清楚一个观念,static 成员变量并不是因为对象的实现而才得以实现,它本来就存 在,你可以想象它是一个全域变量。因此,第一种处理方式在意义上比较不会给人错误 的印象。

只要access level 允许,任何函数(包括全域函数或成员函数,static 或non-static)都可 以存取static 成员变量。但如果你希望在产生任何object 之前就存取其class 的private static 成员变量,则必须设计一个static 成员函数(例如以下的setRate):

class SavingAccount

{

private:

char m_name[40]; // 存户姓名 char m_addr[60]; // 存户地址 double m_total; // 存款额 static double m_rate; // 利率 ...

public:

static void setRate(double newRate) { m_rate = newRate; }

... };

double SavingAccount::m_rate = 0.0075; // 设置 static 成员变量的初值 void main()

{

myAccount.setRate(0.0074); //通过对象调用stati c成员函数 }

由于static 成员函数不需要借助任何对象,就可以被调用执行,所以编译器不会为它暗 加一个this 指针。也因为如此,static 成员函数无法处理类别之中的non-static 成员变 量。还记得吗,我在前面说过,成员函数之所以能够以单一一份函数码处理各个对象的 资料而不紊乱,完全靠的是this 指针的指示。

static 成员函数「没有this 参数」的这种性质,正是我们的MFC 应用程序在准备 callback 函数时所需要的。第6章的Hello World 例中我就会举这样一个实例。

C++ 程序的生与死:兼谈构造式与析构式

C++ 的new 运算子和C 的malloc 函数都是为了配置内存,但前者比之后者的优点

是,new 不但配置对象所需的内存空间时,同时会引发构造式的执行。 所谓构造式(constructor),就是对象诞生后第一个执行(并且是自动执行)的函数,它

的函数名称必定要与类别名称相同。

相对于构造式,自然就有个析构式(destructor),也就是在对象行将毁灭但未毁灭之前 一刻,最后执行(并且是自动执行)的函数,它的函数名称必定要与类别名称相同,再 在最前面加一个~ 符号。

一个有着阶层架构的类别群组,当衍生类别的对象诞生之时,构造式的执行是由最基础 类别(most based)至最尾端衍生类别(most derived);当对象要毁灭之前,析构式的执 行则是反其道而行。第3章的frame1 程序对此有所示范。

我以实例展示不同种类之对象的构造式执行时机。程序代码中的编号请对照执行结果。

对于全域对象(如本例之GlobalObject),程序一开始,其构造式就先被执行

(比程序进入点更早);程序即将结束前其析构式被执行。MFC 程序就有这

样一个全域对象,通常以application object 称呼之,你将在第6章看到它。

对于区域对象,当对象诞生时,其构造式被执行;当程序流程将离开该对象的

存活范围(以至于对象将毁灭),其析构式被执行。

对于静态(static)对象,当对象诞生时其构造式被执行;当程序将结束时(此

对象因而将遭致毁灭)其析构式才被执行,但比全域对象的析构式早一步执

行。

对于以new 方式产生出来的区域对象,当对象诞生时其构造式被执行。

四种不同的对象生存方式(in stack、in heap、global、local static)

既然谈到了static 对象,就让我把所有可能的对象生存方式及其构造式调用时机做个整 理。所有作法你都已经在前一节的小程序中看过。

在C++ 中,有四种方法可以产生一个对象。第一种方法是在堆栈(stack)之中产生它:

void MyFunc() {

CFoo foo; // 在堆栈(stack)中产生foo 对象

... }

第二种方法是在堆积(heap)之中产生它: void MyFunc()

{

...

CFoo* pFoo = new CFoo(); // 在堆(heap)中产生对象 }

第三种方法是产生一个全域对象(同时也必然是个静态对象): CFoo foo; // 在任何函数范围之外做此动作

第四种方法是产生一个区域静态对象:

void MyFunc() {

static CFoo foo; // 在函数范围(scope)之内的一个静态对象 ...

}

不论任何一种作法,C++ 都会产生一个针对CFoo 构造式的调用动作。前两种情况,C++ 在配置内存-- 来自堆栈(stack)或堆积(heap)-- 之后立刻产生一个隐藏的(你的原 代码中看不出来的)构造式调用。第三种情况,由于对象实现于任何「函数活动范围 (function scope)」之外,显然没有地方来安置这样一个构造式调用动作。

是的,第三种情况(静态全域对象)的构造式调用动作必须靠startup 码帮忙。startup 码 是什么?是更早于程序进入点(main 或WinMain)执行起来的码,由C++ 编译器提供, 被联结到你的程序中。startup 码可能做些像函数库初始化、进程信息设立、I/O stream 产 生等等动作,以及对static 对象的初始化动作(也就是调用其构造式)。

当编译器编译你的程序,发现一个静态对象,它会把这个对象加到一个串行之中。更精确地说则是,编译器不只是加上此静态对象,它还加上一个指针,指向对象之构造式及 其参数(如果有的话)。把控制权交给程序进入点(main 或WinMain)之前,startup 码 会快速在该串行上移动,调用所有登记有案的构造式并使用登记有案的参数,于是就初 始化了你的静态对象。

第四种情况(区域静态对象)相当类似C 语言中的静态区域变量,只会有一个实体 (instance)产生,而且在固定的内存上(既不是stack 也不是heap)。它的构造式在 控制权第一次移转到其声明处(也就是在MyFunc 第一次被调用)时被调用。

所谓 "Unwinding "

C++ 对象依其生存空间,适当地依照一定的顺序被析构(destructed)。但是如果发 生异常情况(exception),而程序设计了异常情况处理程序(exception handling), 控制权就会截弯取直地「直接跳」到你所设定的处理例程去,这时候堆栈中的C++ 对象有没有机会被析构?这得视编译器而定。如果编译器有支持unwinding 功能,就 会在一个异常情况发生时,将堆栈中的所有对象都析构掉。

关于异常情况(exception)及异常处理(exception handling),稍后有一节讨论之。

执行时期型别信息(RTTI)

我们有可能在程序执行过程中知道某个对象是属于哪一种类别吗?这种在C++ 中称为 执行时期型别信息(Runtime Type Information,RTTI)的能力,晚近较先进的编译器如 Visual C++ 4.0 和Borland C++ 5.0 才开始广泛支持。

这个程序与RTTI 相关的地方有三个:

1. 编译时需选用/GR 选项(/GR 的意思是enable C++ RTTI)

2. 包含typeinfo.h

3. 新的typeid 运算子。这是一个多载(overloading)运算子,多载的意思就是拥

有一个以上的型式,你可以想象那是一种静态的多态(Polymorphism)。typeid 的参数可以是类别名称(如本例#58 左),也可以是对象指针(如本例#58右)。它传回一个type_info&。type_info 是一个类别,定义于typeinfo.h 中:

class type_info {

public:

virtual ~type_info();

int operator==(const type_info& rhs) const; int operator!=(const type_info& rhs) const; int before(const type_info& rhs) const; const char* name() const;

const char* raw_name() const;

private:

...

};

虽然Visual C++ 编译器自从4.0 版已经支持RTTI,但MFC 4.x 并未使用编译器的能力 完成其对RTTI 的支持。MFC 有自己一套沿用已久的办法(从1.0 版就开始了)。喔, 不要因为MFC 的作法特殊而非难它,想想看它的悠久历史。

MFC 的RTTI 能力牵扯到一组非常神秘的宏( DECLARE_DYNAMIC IMPLEMENT_DYNAMIC)和一个非常神秘的类别(CRuntimeClass)。MFC 程序员都知 道怎么用它,却没几个人懂得其运作原理。大道不过三两行,说穿不值一文钱,下一章 我就仿真出一个RTTI 的DOS 版本给你看。

动态生成(Dynamic Creation)

对象导向术语中有一个名为persistence,意思是永续存留。放在RAM 中的东西,生命 受到电力的左右,不可能永续存留;唯一的办法是把它写到文件去。MFC 的一个术语 Serialize,就是做有关文件读写的永续存留动作,并且实做作出一个虚拟函数,就叫作 Serialize

看起来永续存留与本节的主题「动态生成」似乎没有什么干连。有!你把你的资料储存 到文件,这些资料很可能(通常是)对象中的成员变量 我把它读出来后,势必要依据 文件上的记载,重新new 出那些个对象来。问题在于,即使我的程序有那些类别定义(就 算我的程序和你的程序有一样的内容好了),我能够这么做吗:

char className[30] = getClassName(); // 从文件(或使用者输入)获得一个类别名称 CObject* obj = new classname; // 这一行行不通

首先,new classname 这个动作就过不了关。其次,就算过得了关,new 出来的对象究 竟该是什么类别类型?虽然以一个指向MFC 类别老祖宗(CObject)的对象指针来容纳 它绝对没有问题,但终不好总是如此吧!不见得这样子就能够满足你的程序需求啊。 显然,你能够以Serialize 函数写档,我能够以Serialize 函数读档,但我就是没办法恢复 你原来的状态-- 除非我的程序能够「动态生成」。

MFC 支持动态生成, 靠的是一组非常神秘的宏( DECLARE_DYNCREATE IMPLEMENT_DYNCREATE)和一个非常神秘的类别(CRuntimeClass)。第3章中我将 把它抽丝剥茧,以一个DOS 程序仿真出来。

异常处理(Exception Handling)

Exception(异常情况)是一个颇为新鲜的C++ 语言特征,可以帮助你管理执行时期的错 误,特别是那些发生在深度巢状(nested)函数调用之中的错误。Watcom C++ 是最早支 持ANSI C++ 异常情况的编译器,Borland C++ 4.0 随后跟进,然后是Microsoft Visual C++ 和Symantec C++。现在,这已成为C++ 编译器必需支持的项目。

C++ 的exception 基本上是与C 的setjmp longjmp 函数对等的东西,但它增加了一 些功能,以处理C++ 程序的特别需求。从深度巢状的例程调用中直接以一条快捷方式撤回 到异常情况处理例程(exception handler),这种「错误管理方式」远比结构化程序中经 过层层的例程传回一系列的错误状态来的好。事实上exception handling 是MFC 和

OWL 两个application frameworks 的防弹中心。 C++ 导入了三个新的exception 保留字:

1. try。之后跟随一段以{ } 圈出来的程序代码,exception 可能在其中发生。

2. catch。之后跟随一段以{ } 圈出来的程序代码,那是exception 处理例程之所在。

catch 应该紧跟在try 之后。

3. throw。这是一个指令,用来产生(抛出)一个exception。

Template

这并不是一本C++ 书籍,我也并不打算介绍太多距离「运用MFC」主题太远的C++ 论 题。Template 虽然很重要,但它与「运用MFC」有什么关系?有!第8章当我们开始 设计Scribble 程序时,需要用到MFC 的collection classes,而这一组类别自从MFC 3.0 以来就有了template 版本(因为Visual C++ 编译器从2.0 版开始支持C++ template)。 运用之前,我们总该了解一下新的语法、精神、以及应用。

好,到底什么是template?重要性如何?Kaare Christian 在1994/01/25 的PC-Magazine 上有一篇文章,说得很好: 无性生殖并不只是存在于遗传工程上,对程序员而言它也是一个由来已久的动作。过去, 我们只不过是以一个简单而基本的工具,也就是一个文字编辑器,重制我们的程序代码。 今天,C++ 提供给我们一个更好的繁殖方法:template。 复制一段既有程序代码的一个最平常的理由就是为了改变数据类型。举个例子,假设你写 了一个绘图函数,使用整数x, y 坐标;突然之间你需要相同的程序代码,但坐标值改采long。你当然可以使用一个文字编辑器把这段码拷贝一份,然后把其中的数据类型改变 过来。有了C++,你甚至可以使用多载(overloaded)函数,那么你就可以仍旧使用相同的函数名称。函数的多载的确使我们有比较清爽的程序代码,但它们意味着你还是必须 在你的程序的许多地方维护完全相同的算法。

C 语言对此问题的解答是:使用宏。虽然你因此对于相同的算法只需写一次程序代码, 但宏有它自己的缺点。第一,它只适用于简单的功能。第二个缺点比较严重:宏不 提供资料型别检验,因此牺牲了C++ 的一个主要效益。第三个缺点是:宏并非函数, 程序中任何调用宏的地方都会被编译器前置处理器原原本本地插入宏所定义的那一 段码,而非只是一个函数调用,因此你每使用一次宏,你的执行文件就会膨胀一点。 Templates 提供比较好的解决方案,它把「一般性的算法」和其「对资料型别的实作 部份」区分开来。你可以先写算法的程序代码,稍后在使用时再填入实际资料型别。新 的C++ 语法使「资料型别」也以参数的姿态出现。有了template,你可以拥有宏「只 写一次」的优点,以及多载函数「类型检验」的优点。

C++ 的template 有两种,一种针对function,另一种针对class。

  • 0
    点赞
  • 4
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值