一、关于断言
所谓的断言就是可以肯定为正确的一个陈述语句。
假设某个函数需要一个指向文档对象的指针作为参数,但却错误地使用了一个视图指针来调用该函数。如果函数继续使用该错误地址,轻则程序运行得不到正确的结果,重则破坏视图数据。由于这种错误往往要到使用视图数据时才会发现,因而要找出导致错误的根本原因就要付出相当大的代价了。
只要在函数开始部分加入断言检查,检验指针是否真正指向一个文档对象,就可以完全地避免此类问题的产生。
二、ASSERT宏
ASSERT宏能够计算作为参数传递的表达式值。如果表达式为真,则执行继续。否则,程序显示一个消息并中断。此时可以选择忽略错误、终止程序或进入调试器。下面是如何在函数中应用ASSERT宏验证参数的一个例子:
void foo(char* p,int size)
{
ASSERT(p != 0); // 验证缓冲区指针
ASSERT((size >= 100); // 确认缓冲区大小至少为100字节
// foo 函数的其它计算过程
}
如果没有定义_DEBUG预处理符,则该语句不会真正生成代码。Visual C++会在调试模式编译时自动定义_DEBUG,而在发行模式下,该预处理符是不存在的。如果定义了_DEBUG,则上述两个断言生成的代码类如:
//ASSERT(p != 0);
do
{
if(!(p != 0) && AfxAssertFailedLine(__FILE__, __LINE__))
AfxDebugBreak();
} while(0);
//ASSERT((size >= 100);
do
{
if(!(size >= 100) && AfxAssertFailedLine(__FILE__,__LINE__))
AfxDebugBreak();
}while(0);
do-while结构在一个单独的语句块之内封装了整个断言操作。if语句计算断言表达式,如果值为0则调用AfxAssertFailedLine()。AfxAssertFailedLine()显示消息框并提供 “Abort,Retry,or Ignore”选择,如果选择Retry则调用AfxDebugBreak(),并由此激活调试器。
和AfxAssertFailedLine()的简单目的(即显示对话框以供选择)相比,它的实现显得非常复杂。该函数的源代码在afxasert.cpp内,可以发现它使用了一些特殊的函数以确保消息框正确显示。举个例子,如果断言失败是在程序发送WM_QUIT消息之后的某个位置,则AfxAssertFailedLine()为了显示消息框必须临时地从队列中删除这个消息。
传递给AfxAssertFailedLine()的参数__FILE__ 和 __LINE__分别为代表程序文件名和当前行号的预处理符号。它们由ANSI标准定义,由编译器自动生成具体数值。
三、VERIFY宏
由于ASSERT仅在程序的调试版起作用,测试表达式总是被动的。也就是说,它们不能包含赋值、增量、减量等真正改变数据的操作。但有时候我们需要验证一个主动表达式,比如赋值语句。这时可以使用VERIFY代替ASSERT。下面是一个例子:
void foo(char* p,int size)
{
char* q; // 指针的副本
VERIFY(q = p); // 拷贝指针并执行验证
ASSERT((size >= 100); // 确保缓冲区大小至少为100字节
// 执行 foo 的其它操作
}
在调试模式下ASSERT和VERIFY是相同的。但在发行模式下,VERIFY能够继续对表达式求值(但不再进行断言检验),而ASSERT语句在效果上就如同已经删除了一样。
尽管在MFC源代码中可以找到一些应用VERIFY的例子,但ASSERT用得更为普遍。一些程序员总是完全避免使用VERIFY,因为他们已经习惯于使用被动断言。请记住,如果在ASSERT语句中使用了主动表达式,编译器不会发出任何警告。在发行模式下编译时该表达式会被直接删除,从而导致程序运行的错误。由于发行版程序不含调试信息,这种类型的错误是很难找到原因的。
四、DEBUG_ONLY宏
可以认为DEBUG_ONLY宏是ASSERT宏的一种特殊格式,它用于计算表达式而不执行断言检查。这在只为调试目的而加入某语句时很有用,如:
void foo(char* p,int size,char fill)
{
char* q; // 指针副本
VERIFY(q = p); // 拷贝指针并执行验证
ASSERT((size >= 100); // 确认缓冲区大小至少为100字节
ASSERT(isalpha(fill)); // 确保fill为字母
DEBUG_ONLY(if(!isalpha(fill)) fill = 'X'); // 如果fill值非法,则指定为'X'
// 执行 foo 的其它操作
}
在上例中,即使第三个参数非法,调试过程仍可继续。事实上,很少有程序员喜欢用这个宏。他们更习惯于使用传统的方法,即使用_DEBUG预处理符使得调试代码只在调试时被编译:
void foo(char* p,int size,char fill)
{
char* q; // 指针副本
VERIFY(q = p); // 拷贝指针并执行验证
ASSERT((size >= 100); // 确认缓冲区大小至少为100字节
ASSERT(isalpha(fill)); // 确认fill为字母
#ifdef _DEBUG
if(!isalpha(fill)) fill = 'X'); // 如果fill值非法则使其为'X'
#endif
// 执行foo的其它操作
}
五、ASSERT_VALID宏
ASSERT在执行简单验证时很有用,但对于C++对象,特别是由CObject派生的对象,则有更好的方法来实现类似操作。作为一般规则,我们应在开始使用每一个对象之前检查数据讹误。ASSERT_VALID宏使得对CObject的派生类实现该操作非常简单,其过程如下所示:
void CMyView::foo(CYourView* pView) // CMyView 和 CYourView 从CObject 继承
{
ASSERT_VALID(this); // 验证本身
ASSERT_VALID(pView); // 验证pView
// 执行foo的其它操作
}
如下所示,这些宏直接调用AfxAssertValidObject():
void CMyView::foo(CYourView* pView)
{
//ASSERT_VALID(this); // 验证本身
AfxAssertValidObject(this,__FILE__,__LINE__);
//ASSERT_VALID(pView); // 验证pView
AfxAssertValidObject(pView,__FILE__,__LINE__);
// 执行foo的其它操作
}
AfxAssertValidObject()的定义可以在objcore.cpp文件找到,它是一个没有正式说明文档的MFC函数。AfxAssertValidObject()首先检查指针非空(NULL)且指向一个合法的内存地址,其大小符合由相关的CRuntimeClass对象指定的数值。如果这些测试失败,则AfxAssertValidObject()的行为就像一个普通断言错误- -即调用AfxAssertFailedLine(),有可能还要调用AfxDebugBreak();否则,AfxAssertValidObject()调用虚函数AssertValid(),可以覆盖后者以执行其它的完整性检查。
如果使用应用向导或类向导生成基于MFC的类,通常会得到AssertValid()的骨架,最好改写这些骨架代码以增加最基本的完整性检查。下面是一个典型的例子,类Sample从CObject继承,假定它含有职员名字及其薪水:
class Sample : public CObject
{
protected:
CString m_Name; // 职员名字
double m_Salary; // 薪水
public:
Sample(LPCTSTR name,double salary) : m_Name(name), m_Salary(salary) {}
#ifdef _DEBUG
virtual void AssertValid() const;
#endif
#ifdef _DEBUG
void Sample::AssertValid() const
{
CObject::AssertValid(); // 验证基类
ASSERT(!m_Name.IsEmpty()); // 验证职员名字
ASSERT(m_Salary > 0); // 验证薪水
}
#endif
显然,依赖于派生类的数据成员,这些AssertValid()函数可以更为复杂。由于该函数只在调试版起作用,因而无需担心由此产生的时间开销。然而,在某些热点如CView::OnDraw()函数,MFC会频繁地验证其CDocument指针,此时在文档对象内应该避免冗长的测试。当然,如果正确地使用了文档/视图结构,许多应用数据将会保存在CDocument的派生类对象中,此时妥协和折衷还是必要的。
六、ASSERT_KINDOF宏
许多时候只需要验证指针确实引用了所希望的对象类型,以确保可以安全地访问该对象的成员。ASSERT_KINDOF能够完成该检查,而且时间开销比ASSERT_VALID要少。其调用形式如下:
void Sample::DoSomething(CMyDocument* pDoc) const{ASSERT_KINDOF(CMyDocument,pDoc);}
ASSERT_KINDOF要求所检查的指针指向从CObject派生的对象,而且该对象实现MFC运行时类信息声明。这种声明通常是通过在类声明中包含DECLARE_DYNAMIC宏,在实现文件包含IMPLEMENT_DYNAMIC宏来实现的。应用向导(或类向导)能够为大多数文档和视图类自动生成该声明。
下面是由ASSERT_KINDOF生成的真正代码:
void Sample::DoSomething(CMyDocument* pDoc) const
{
//ASSERT_KINDOF(CMyDocument,pDoc) 生成以下代码
ASSERT(pDoc->IsKindOf(RUNTIME_CLASS(CMyDocument)));
// 现在可以确认 pDoc 指向 CMyDocument 对象
// 其它操作
}
记得我刚开始用VC编程时,多次遇到程序编译链接都没有任何错误提示时运行却发生意外错误或者陷入死机状态,对此我真是茫然不知所措。后来经过项目工程的“洗礼”,我学到了一些解决这类错误的方法同时也将自己积累的经验运用到开发工作中,从而避免了重蹈覆辙,编程效率得以明显提高。如果你用VC而不精通解决上述种种莫名其妙故障的调试技术,那么软件的进度和质量将无法保障。VC调试之重要不言而喻。
其实VC所依赖的开发平台Microsoft Developer Studio本身提供的调试功能并不弱,每当我们创建一个新的VC工程项目时,默认状态就是Debug(调试)版本,在"组建"(Build)菜单下的Configurations中可以看到除了调试版本还可以设置成发行(Release)版本。调试版本由于包含了大量信息,所以它生成的可执行程序容量会远远大于发行版。
具体地,调试版本主要增加了两个内容:其一,会执行编译命令_D_DEBUG,使头文件的调试语句ifdef及其代码附加到程序中;其二,在可执行文件中加入的调试信息使开发人员能够观察变量,进行单步执行等。在VC"组建"(Build)菜单下的"开始调试"中有4条专用的调试命令:Go,Step into,Run to Cursor,Attach to process…。在运行程序源代码时用Go命令(而不是Execute)才能处于调试状态, Go命令会使程序运行变得缓慢下来,但可以更好地控制运行程序,我们可以在任何时刻中断程序、单步执行、查看变量、检查调用栈。
有必要详细介绍一下VC的调试功能:首先,再次强调要用Go命令运行一个将要调试的程序;如果要中止调试状态下的运行程序可以点击Stop Debugging命令,还可以通过Break选项以可恢复方式中断调试程序的运行流程(用Restart选项可以重新开始运行程序);Step Into选项表示每次只执行一行语句(单步执行),但如果当前代码是调用一个函数,那么Step Into表示进入该函数,全部函数语句执行完后返回,而Step Over则是跳出这个函数;Step To Cursor选项表示程序将执行到光标所在的可执行语句行上;在调试多线程程序时,可以在线程函数或主应用程序线程中设置断点,还可以用Break选项结束线程后用Threads选项查看运行线程列表,也可以选择悬挂和恢复每个线程;在设置断点后,在VC "查看"菜单的"调试窗口"中可以查看变量、内存、调用堆栈、寄存器以及反汇编语句。在程序中设置断点的方法是,点击要设置的代码行并点击设置代码的工具栏按钮,会出现在代码行最左边的一个小黑点即是断点标志,这时再选Go程序会在执行到端点处停下来,如果要继续执行可以再选Go。
通过选择VC"工具"菜单下的"源浏览器"可以生成一个.BSC文件,使用浏览器可以从中发现多种信息:程序中任何一个变量、函数、类或宏在何处定义及引用;可以列出所有声明的函数类、变量、宏;可以发现调用一个指定函数的所有函数;可以找到一个指定类的派生来源或者它派生出哪些类。
在使用微软程序开发库MSDN时,我们会发现其中的VC示例经常采用看似多余的ASSERT语句,其作用就是使程序具有"维护"性。对于Debug版本的VC程序,在遇到布尔值为FALSE的ASSERT语句处停止,并显示Assertion Failed对话;如果设置为发布版,所有ASSERT语句都会被预处理程序删除。一个地道的VC编程员,应该有意在自己的代码中通过"维护"特征去检测任何设定,诸如输入参数、循环范围和变量值的设定。
在安装好VC系统之后,在VC之外的程序组中有一个程序Tracer是一个跟踪工具,在激活它后使用Go运行VC代码,在输出窗口就能够看到程序运行过程中的内部过程,包括DLL调用等,你如果看不到任何输出,可以转到菜单"查看"(View)点击"输出"(Output)。
其实,MFC自身就提供有错误查找和TRACE语句,而TRACE语句的语法与printf非常类似,所以我们可以在程序中直接加入这条跟踪命令,如下所示:
// Example for TRACE int i = 1; char sz[] = "one"; TRACE( "Integer = %d, String = %s/n", i, sz ); // Output: 'Integer = 1, String = one' |
在Developer Studio中还提供了一个ERRLOOK工具,程序员只要输入错误号就能得到系统出错信息或模块错误内容。
MFC从Cobject派生的每个类都包含一个Dump函数,该函数可把当前状态转储(Dumping)到输出窗口,这在某些调试过程中会有用,以下代码是Dump函数的用法:
// Example for CObject::Dump void CAge::Dump( CDumpContext &dc ) const { CObject::Dump( dc ); dc << "Age = " << m_years; } |
在MFC中还有一个非常有用的类是CMemoryState,我们可以在程序的任何部分使用这个类检测内存冲突,并得到内存冲突的确切位置。CMemoryState类有3个成员函数:CheckPoint可将堆的当前状态存入类的实体;Difference可以比较两个实体包含的堆之间的差异;DumpStatistics用于标准化转储所有被CheckPoint捕获后分配到堆的对象,如CheckPoint未被调用实体未被初始化时,该函数将转储当前堆的所有内容。以下代码表示了CMemoryState类的使用方法:
// Example for CMemoryState::CMemoryState, // Includes all CMemoryState functions CMemoryState msOld, msNew, msDif; msOld.Checkpoint(); CAge* page1 = new CAge( 21 ); CAge* page2 = new CAge( 22 ); msOld.DumpAllObjectsSince(); msNew.Checkpoint(); msDif.Difference( msOld, msNew ); msDif.DumpStatistics(); |
代码运行的结果为:
Dumping objects -> {2} a CObject at $190A {1} a CObject at $18EA Object dump complete. 0 bytes in 0 Free Blocks 8 bytes in 2 Object Blocks 0 bytes in 0 Non-Object Blocks Largest number used: 8 bytes Total allocations: 8 bytes |
在MFC类和VC中本身就有"异常情况"这个概念,并在此基础上形成它们处理系统错误和意外的主要机制。比如当系统内存分配殆尽时,你的运行程序就会收到内存异常的消息。这样就给了程序员消除异常的机会。
MFC中的异常情况主要有:CArchiveException表示档案文件载入或保存时出错,CDBException属于数据库错误,CFileException为文件错误,CMemoryException为调用new时发生分配错误,CNotSupportedException表示指定操作不被支持,COleException表示在调用OLE操作时出错,COleDispatchException表示在OLE自动操作时出错,CResourceException表示资源找不到或无法创建,CUserException用于通知用户错误。
MFC还包含一系列以Afx-为词头的调试函数:AfxAbort可以在发生致命错误时异常终止程序,AfxCheckMemory可以检查堆和剩余缓冲池的受损部分;AfxDoForAllClasses重声明所有CObject的派生类;AfxDoForAllObject重声明堆上所有CObject派生的对象;AfxEnableMemoryTracking启用或禁止内存追踪;AfxIsMemoryBlock用于确认指针所指内存有效;AfxIsValidAddress用于确认地址是驻留在程序的内存区域内;AfxIsValidString用于确认地址所指字符串有效;AfxSetAllocHook用于内存分配前进行检测;AfxTraceEnabled启动或禁止输出跟踪,AfxTraceFlags则进一步定制跟踪特征。
在我们随手编制的VC程序中,普遍存在着会发生内存泄漏的隐患,有些问题程序的痼疾症状是在处理数据量激增时陷入瘫痪,更糟的要发现内存泄漏并不容易。首先,我们要明确VC中内存泄漏的含义:简单说就是一个程序申请得到了一段内存却没有及时释放。比如用new在堆中分配了一个对象或对象组却并没有调用delete操作。灵活的指针技术使内存泄漏的原因变得复杂化,比如改变了保存在一变量中的指针的值后未能删除指针所指向的内存区;当内存泄漏是来自一个带有指针类型成员变量的类时会更加困难,因为当调用分配指针时并没有复制构造函数/析构函数或运算符。
为了防止发生内存泄漏这样棘手的故障,在VC编程时应当注意遵循几个规范:其一,如果一个类包含有指针并且分配了指针值,那么就需要构造相应的析构函数以删除该指针;其二,如果一个函数分配了一块内存并把该内存块返回给调用它的函数使用,那么它返回的必须是一个指针而非一个引用,因为引用不能被程序删除;其三,即使一个函数分配了一段内存并在同一函数的稍后部分删除了该内存段,也要尽可能将内存块分配到堆栈中;最后,就是决不要试图改变一个指针值,除非已经删除指针所指的对象或通过数组指向了该指针所指向的内存,而且也不要对new返回的指针进行加1运算。
每当编写VC程序时,我们都会处于一个琳琅满目的集成开发环境(IDE)中,现实的真相是我们很多人在这里编程多年,对开发环境了解并不全面和细微。记得王朔的小说中有句话说"穿了多年的外套在不穿时才发现它原来还有一个兜!"。我们在安装VC时,得到的IDE即Developer Studio, VC其实是Developer Studio下激活的一个组件而已,比如微软的VJ++也是基于Developer Studio。很少有技术书籍会一一介绍Developer Studio界面元素,也许聪明的程序员轻易就能识别其含义,全部猜对界面图符的含义并非易事。可是它们对我们了解开发信息很重要,也与调试程序有关联。
在Developer Studio下会生成多个文件去保存项目的所有信息:一个是以.DSW为扩展名的项目工作区文件,它包含项目中所有文件的名称、文件所在目录、编译器和连接器的选项以及项目工作的其它信息;以.DSP为扩展名的也是项目记录文件,.OPT是工作区选项文件,它包含Developer Studio的所有个人设置 - 包括颜色、字体、工具栏、哪个文件被打开以及MDI窗口如何被定位和最新调试中的断点等。在打开项目工作区文件时其它文件随即会自动打开。在Developer Studio下可以按类查看代码,其中的ClassView显示了应用程序中所有的类,每个类下显示了成员函数和数据成员,在成员函数旁有粉红图标,数据成员旁是蓝绿色图标,保护类成员的图标旁有一枚钥匙,私有类成员则有一个挂锁图标。
当然,在开发环境下最主要的工作是输入编辑程序源代码,源代码会显示"语法着色"。在缺省情况下,代码为黑色,夹以绿色的注释和蓝色的关键字(指VC所保留的public、private、new和int等等)。--这些地球人都知道,但是为了调试需要,我们还可以指定颜色去显示字符串、数字和运算符。定义方法是通过Tools菜单下的Options对话框中的Format选项卡设置。
在Developer Studio提供的诸多菜单项中,我们往往对少数菜单避而不用,因为不了解它们的作用唯恐好奇心会造成乱子;还有多个菜单项都可以达到目的,至于它们之间的微小差别则不甚了了。比如在编译和调试时常用到Build菜单组,它具有和应用程序编译、运行调试相关的多项操作:其中的Compile菜单会编译当前的聚焦文件;Build菜单会编译和链接所有在项目中修改的文件;Build All会编译链接项目中所有文件,包括最近编译后没有修改过的文件;Batch Build用于包含有Debug和Release配置的项目;Clean会删除所有的中间和输出文件,因而项目目录下仅包含源文件;Debugger Remote Connection用于远程调试,即在一台机器运行程序而在另一台调试;Set Active Configuration可设置某个配置为激活状态(Debug或Release);Profiler能够识别应用程序的瓶颈,即找到降低程序执行速度的代码和有关模块,为此需要在"工程/设置/Link"下勾选Profiling。
VC中的警告信息是有级别的。在"工程/设置"下的"C/C++"选项卡中的警告级别Warning Level缺省值是3,如果改为更为严格的4级,往往会产生更多的警告类错误。在C/C++选项卡中还提供了代码优化栏Optimizations,在你完成漫漫调试之旅准备正式发布前夕,你应当改动此项,它提供了一些适合建立发行版应用程序的优化设置。
其实,在多年以前,在软件业中针对开发具有一定规模软件项目的情况就出现了软件工程理论,以此来指导软件人员树立团队协作意识,进而保证软件项目的协调性及进度质量等。笔者在从事VC开发中对此很有感触,而且觉得对于每个开发人员自己也应当具备一定的工程素养。比如,我们在开发初期时,应当对所编写的有价值的源代码及时备份,即使有些代码在后续阶段似乎没有用处了也不妨“敝帚自珍”。在VC编程中我经常遇到的问题是,一个不算小的编写了很久的程序,在稍微扩展一点功能时出现了故障,而最挠头的时花了很多时间也无法排故,所幸的是对原来的程序我还有备份,可以重新再来--功能还要加,只是还一种编程思路去实现相同的功能。说实在的,本人在VC开发中,如果要总结“解决了多少问题”,不如说很多时候是采用"游击战"巧妙地"绕过"了一些棘手的问题。--尤其在软件交工处于倒计时的开发后期出现的故障,往往你已经没有时间去找到故障,而是用前一个完好的VC Project去尝试另一条捷径。