Windows程序调试----第一部分 调试策略----第3章 使用断言

来源:互联网 发布:上海银行淘宝卡面签 编辑:程序博客网 时间:2024/06/01 07:39

第3章使用断言

    在第2章“编写便于调试的C++代码”中,我介绍了早期Windows程序中的调试。在ANSI C出现之前,C语言的类型功能并不强大,也不支持函数原型。Windows程序都是在16位存储介质基础上开发的,这里函数指针为长指针,而数据指针为短指针。程序员必须像人工编译器一样,一行行地检查代码,保证所有的函数参数的数据类型都能匹配,指针长度都正确,并且在必要的时候进行强制类型转换。如果没有发现这些错误,程序就会崩溃,更槽的是,还不得不用匈牙利命名法编程。

    现在,情况大大改善了。C++是一个具有很强的类型支持的语言,而且现代的C++编译器擅长发现数据类型方面的错误。如果一个书写良好的C++程序通过了编译和链接,就意味着不可能存在数据类型方面的错误了,想象一下我们的下一步:未来的编译器将会发现几乎所有编程上的错误,从无效的参数值到逻辑错误。这样的编译器使程序员的工作更有成效,开发出来的软件也会更加可靠。

    不幸的是,这种编译器是否存在还是一个疑问。问题就在于,尽管编译器能通过静态分析源代码来查错,但是仍然存在大量只有在动态分析的时候才能发现的错误。要找出这类错误,你必须运行程序。你不能让编译器在编译时刻自动发现这些问题,但是你可以在你的代码中间添加信息,让程序自己自动检测运行时刻错误。要达到这个目的,就要使用断言。

    这里是一个标准的断言语句:

    _ASSERTE(count >= 1 && count < MAXCOUNT);

    这条语句说明,如果代码正常工作,count变量的值就会大于等一,并且小于等于MAXCOUNT。如果count有其他的值,就说明代码出现了问题,这时就会弹出来一个消息框告诉你这个错误。

    Microsoft Press Computer Dictionary, ThirdEdition》中定义的断言就是“程序中使用的一个布尔表达式,用来检测在程序正常运行的时候,某个条件的值是否总为真”。断言具有以厂的特征:

    •断言是用来发现运行时刻错误的。断言发现的错误是关于程序实现方面的,而不是诸如用户输入错误、资源分配错误、文件系统错误或硬件错误等其他类型的错误。

    •断言中的布尔表达式显示的是某个对象或者状态的有效性,而不是正确性(详细解释见后)

    •断言在条件编译后只存在调试版本里,而不是在发布版本里。特别的是,断言只在_DEBUG符号定义后(ANSI C中,则是NDEBUG没有被定义时)才能被编译。由于断言在发布版本里没有开销,它们可以被大量使用而不影响性能。

    •断言不能包含程序代码,也不能有副作用。这样程序在发布版本里的行为才和在调试版本里的保持相同。断言不能修改程序变量,也不能调用能够修改程序变量的函数。

    •断言是为了给程序员而不是用户提供信息的。

    简而言之,断言是布尔调试语句,能让错误在运伃时刻暴露在程序员的面前

    这里有一个有趣的问题;在内存受保护的操作系统如Windows系统中开发软件时,断言是否真的有用?毕竟,如果一个程序中包含了一个错误的指针,当程序试图释放指针时,程序就会因为出现存取异常而崩溃。存取异常是一个让程序员在运行时刻发现错误的便利方法,并且不需要进行额外的编程方面的工作。

    使用断言能使问题早日发现。尽量在错误发源地附近就能够检测到错误。

    用一个没有被处理的异常使程序崩溃显然是暴露错误的一种方法。但是使用断言有很多好处,足以抵消我们为之付出的额外工作。一个优点就是你可以通过断言发现多种多样的错误。一个简单的逻辑错误不会引发存取异常——除非错得太离谱了,但是你可以通过断言来发现任何一种逻辑错误。然而,使用断言的最大好处在于,如果你善于使用它们,错误的发现就更接近于错误的发源地,这就使问题易于理解,也更容易发现正确的解决方法。一个逻辑错误可能导致存取异常,但是在引起问题的地方和发现问题的地方之间会隔着大段的代码和时间。相反,你可以通过断言精确地查明引起逻辑错误的原因。调试的关键正是在于知道到哪里寻找错误。一旦你知道了出错的地方,解决问题通常都是很容易的,要想让工作变得容易,你需要早知道问题而不是晚知道。

3.1 断言的局限性

    尽管在代码中间添加的断言是一个功能强大的查错工具,但了解它们的局限性也很重要。让我们仔细复习一下断言的定义:程序中使用的一个布尔表达式,用来检测在程序正常运行的时候,某个条件的值是否总为真。这个定义中间有一个重要的漏洞需要详细解释。如果程序正常运行,那么断言的值就总为真。但是如果程序没有是常运行,事情又会变得怎样?理想状态下,断言就会出错。但是并没有保证一定会出错。断言出错说明出现了问题,但是断言没有出错并不说明没有问题。因此,你可以通过断言来得出程序是错误的结论,但是不能得出程序正确的结论,因为不正确的程序也可能出现所有的断言都正确的情况。

    使用断言来验证你的程序是否有效,但是记住,有效和正确并不相等

    这一点是不是降低了断言的价值呢?幸运的是,这正是它应该做的。使用断言的目的并不是测试或者证明程序的正确牲。要对所有可能出错的状态都用断言是不实际的。使用断言的目的应该是保证程序的状态是有效的,而不保证一定是正确的。比如说,考虑一个日期对象,如果你把122日加上10天,得到132日,对象的状态是无效的,因为32号不是一个有效的日期。这类问题就能被断言发现。如果你把122号加上10天得到22号,对象的状态就是有效的,因为22日是一个有效的日期,然而这个状态却是不正确的。这种问题就不是轻易能被断言发现的了,因为你必须通过另一种独立的方式来决定正确的值是什么。相似地,如果一个指针指向的内存能被某个进程访问,那么就是有效的,这并不意味着这个指针指向了正确的地址。

    一个标准的断言是一个简单的语句,既便于程序员书写,又便于程序计算。断言和测试代码不相同,使用断言并不能代替细致的测试。尽管断言能为测试提供方便,让你把注意力集中到高层的测试上,并且帮助你的测试掲露出更多的错误。虽然检査有效的值没有检查正确的值理想,但是如果你的代码运行的次数足够多,不正确的值也有可能变成无效的值。因此,断言能发现很多错误,但是不能发现所有的错误。

    断言不能代替细致的测试。

    检査的是有效性而不是正确性,这一点意味着不需要使用过于复杂的断言,这样的断言甚至不能达到预期目的。由于不可能通过断言检査程序的正确性,就不要尝试这么做了。只需要把断言看作一种设置障碍的方式,错误不可能通过这种障碍而不被发现。通过断言把错误限制在一个有限的范围内。这种围堵策略需要很好的覆盖,因此使用大量的简单断言比使用少量的复杂断言要好得多。由于书写检查有效性的代码比检査正确性的代码要简单和高效,因此“不需要检查正确性”这一点使你的断言能够简单和便于书写。

    保持断言的简单性,好的覆盖比复杂的检测更重要。

3.2 断言的类型

    Visual C++提供了几类断言,这一个部分,复习一下ANSI C提供的几种断言、Visual C++C运行时刻函数库(Run_timeLibrary)以及MFCActive Template Library(ATL)应用程序基本结构。

    ANSI C断言

    ANSI C中的一个断言就是assert函数,使用这个函数要求你包含assert.h头文件,并且链接C运行时刻函数库:

    void assert(int expression);

    这种类型的断言使用起来最方便,因为所有C++编译器都包含了标准ANSI C运行时刻函数库,而这个语句正是这个库中的一部分。在这个函数的Visual C++实现中,如果断言为假,则会出现图3.1所示的断言失效消息框。注意怎样显示一个带有后继选顶的消息框比把断言结果简单输出到stderr中并且立即终土程序的一般技术要好得多。断言失效消息框对程序员和那些使用调试版本的人,比如说测试人员显得更加友好。总是终止程序对于检测错误来说是个很大的开销,从而使得这类断言的广泛使用受到影响。

    在图3.1所示的消息框中,单击Abort退出程序,单击Ignore允许程序继续运行,如果你正在运行Visual C++调试器,单击Retry激活调试器并且显示带有无效断言的出错源代码。如果你不是正在运行Visual C++调试器,而是Just-in-time(JIT)(要使用JIT调试,选中Tools菜单中的Options命令,在打开的Options对话框中,选择Debug标签和Just-in-time debugging选项),单击Retry就会运行Visual C++,把程序装载到调试器里,并且显示出错代码。不管哪种方法,通过Call Stack命令,并且检査栈里的调用上下文、少量相关参数的值以及输出窗口中Debug表的内容,你通常能够检查出来导致断言失效的原因。

    Visual C++中提供的ANSI C函数有一个缺陷,就是当路径名太长的时候,它可能会裁减掉文件名,从而使你很难判断哪一个文件中的断言失效了。另一个缺陷就是,这个函数是由ANSI C NDEBUG符号驱动的(特别是,当定义了NDEGUB后,断言就被取消了),而其他的Visual C++不调试代码都是使用_DEBUG符号。另外,assert函数缺乏其它用_CrtDbgReprot实现的断言的灵活性,这些断言允许你重定向断言消息。由于这些问题,这个函数是我最不常使用的。

    C运行时刻函数库断言

    你可以使用_ASSERT宏或者_ASSERTE宏。这两个宏都需要你包含crtdbg.h头文件并且与C运行时刻函数库链接。

    _ASSERT(booleanExpression);

    _ASSERTE(booleanExpression);

    就像assert函数一样,这些断言宏也是C运行时库的一部分,但却是Visual C++中的特殊断言(这点最值得注意),因此不是Visual C++中自动携带的。_ASSERT宏在断言失效的时候显示出如图3.2所示的消息框:_ASSERTE宏在断言失效的时候显示出如图3.3所示的消息框。奇怪的是,_ASSERTE宏不支持Unicode

    要想比较这些断言的消息框,注意_ASSERT宏和_ASSERTE宏都不像assert函数那样裁减文件名。也要注意,_ASSERTE宏和assert函数都在消息框中显示失效的表达式

    _ASSERTE宏显示了失效断言,因此更加方便,并且能提供了一种保护机制,使你不会意外调试了不该调试的问题。

    由于显示了失效的表达式,_ASSERTE宏比_ASSERT宏更加方便。的确,在程序中存储失效表达式的字符串会使程序变大(比如说,有1000个断言的程序,平均每个断言表达式为32个字符,那么这个程序就会增大32KB),但是在32位的Windows(不像在16位的Windows,这些字符串都是存储在默认数据段里的)程序的大小并不特别重要。因此,由于这些方便之处,使得在C运行时刻函数库中的断言里,_ASSERTE宏是最好的选择。尽管你可以通过已知的文件名和行号来定位源代码,从而得到失效的断言,但是多余的信息提供了一种保护机制。使你不会意外调试了不该调试的问题。出于这点考虑,我在所有这些能够使用特殊断言的代码例子中都使用了_ASSERTE宏。

    MFC库中的断言

    如果你使用MFC,你可以使用ASSERT宏。

        ASSERT(booleanExpression);

    断言失效的时候,ASSERT宏和_ASSERT宏显示的消息框相同。还提供了ASSERT宏的几个变种,以及和断言布尔表达式一样有用的几个调试函数。我们将在这一章的后面介绍这些MFC调试程序。

    ATL断言

    如果你使用ATLctrdbg.h就会默认包含在Atlbase.h中,因此你可以直接使用_ASSERT宏和_ASSERTE宏。但是,如杲你看了任何ATL代码,就会发现,ATLASSERT才是你的选择。在Atldef.h中,ASSERT定义如下:

    #ifndef  ATLASSERT

    #define  ATLASSERT(expr)  _ASSERTE(expr)

    #endif

    正如你所见的那样,ATLASSERT只不过是_ASSERTE的一个别名。那为什么还要使用这个呢?因为ATL的目的是让用户方便地使用自己定义的断言代码。比如说,假设你正在调试一个没有用户界面的ATL系统组件,对于这样一个组件,让失效的断言显示出一个消息框是不方便的,因为这样会锁住系统直到某个人关闭这个消息框为止。通过定义你自己的ATLASSERT,你就可以让断言做任何你希望它们做的事情,比如说,调用OutputDebugString API函数,或者以线程安全的模式在日志文件里写入一条消息。注意由于ATL是基于模板的,并且所有的ATL代码都是直接编译进入程序,因此所有的ATLASSERT宏都会调用你自定义的断言。这个优点在预编译的非模板库如MFC中是不存在的。

    ATL程序中使用ATLASSERT可以让你使用自己的自定义断言

    断言的源代码

    现在让我们看看断言的源代码,这是C运行时刻函数库断言的源代码:

    ......

    下面是MFC中断言的源代码:

    ......

    快速浏览一下源代码,就能知道为什么不同的Visual C++中的断言都这么相似:因为它们的基本代码都是相同的。MFCASSERT宏有一个重要的优点:即使出现了WM_QUIT消息,它也能显示断言失效消息框。注意,Windows产生WM_QUIT消息可能出于多种原因,比如说,当Windows不能创建一个程序的主窗口,因此,用户没必要在出现WM_QUIT消息的时候退出程序。另一个小优点在于,ASSERT宏使用的是THIS_FILE变量,这个变量每个文件只定义一个,不像_FILE_符号每次使用ASSERT宏的时候都会得到一个文件名的字符串拷贝(如果你选择/Gf或者/GF编译选项的话就没有这个好处了)。因为这些原因,我建议你在使用MFC时使用ASSERT

3.3 更多的MFC断言宏

    MFCASSERT宏提供了几个变种,同时还有和断言布尔表达式一样有用的调试函数。VERIFY宏就是一个例子:

    VERIFY(booleanExpression);

    VERIFY宏可以让你把程序代码(不是调试代码)放入布尔表达式里面。VERFYASSERT的区别在于VERIFY中的布尔表达式在发布版本里被保留下来了,只有ASSERT本身被删除了。VERIFY宏简化了对函数返回值的检査。因此,不用书写下面的代码:

    CString str;

    int retVal = str.LoadString(IDS_STRING)

    ASSERT(retVal);

    你可以写:

    CString str;

    VERIFY (str.LoadString(IDS_STRING));

    VERIFY 宏般都用来检査Windows API函数的返回值,比如说SendMessage

    PostMessageGetMcssageDefWindowProcShowWmdowSetWindowPosLoadStingLoadBitmapGetObjectReleaseDCCloseHandle。你不能对没有包含程序代码的语句使用VERIFY宏,因为第一选择使用VERIFY宏的唯一原因就是因为它的保留程序代码的能力。因此,不要做下面的事:

    VERIFY(count >= 1 && count <= MAXCOUNT); // never do this

    下面是VERIFY宏的源代码:

    // from Afx.h

    #ifdef_DEBUG

    #define VERIFY(f)    ASSERT(f)

    #else    //_DEBUG

    #define VERIFY(f)    ((void)(f))

    #endif   //_DEBUG

    使用VERIFY宏比其他断言更危险。因此,出于以下原因我建议要避免使用VERIFY宏:

    •最大的错误就是把将要在发布版本里删除的那些代码放入了断言的布尔表达式里面。VERIFY宏或者ASSERT宏的使用规则并不能防止这个错误的发生。我并不想调试我的调试代码,尤其不想在发布版本里调试我的调试代码。

    •断言宏实现了程序代码和调试代码的完全分离。这种分离使得代码易于阅读和理解。

    VERIFY本容易妨碍防御性编程。在这一章的后半段,防御性编程把一个if语句和一个断言结合起来,这种做法通常更好。VERIFY宏对于可能在发布版本里产生错误的函数没有提供任何保护。

    综上所述,我认为VERIFY宏带来的麻烦比好处更多

    另一个ASSERT宏的变种是MFC ASSERT_VALID宏,它被用来决定一个指向CObject派生类的对象的指针是否有效。

    ASSERT_VALID(pObjectDerivedFromCObject);

    记住有效和正确是两个不同的概念。一个不正确的对象一定不是有效的。ASSERT_VALID宏通过调用重载的AssertValid函数来决定CObject派生类的对象是否有效。尽管你可以绕过宏,直接调用AssertValid函数,但是ASSERT_VALID宏还进行了其他几个检査,大大增强了健壮性。下面是源代码;

    //参考afx.hASSERT_VALID宏和Objcore.cpp中的AfxAssertValidObject源码

    代码显示,直接调用AssertVatid函数的问题在于需要一个有效的CObject指针才能成功完成调用。ASSERT_VALID宏在调用AssertValid函数之前就通过检查指向对象的指针是否为空,保证给定的对象是一个有效对象。通过检查对象大小,保证它有一个有效地址;通过检査MFC运行时刻类对象,保证vtable的指针有效。无论你什么时候从CObject派生类中得到一个对象,在对这个对象做任何操作之前,都应该调用ASSERT_VALID宏,并且对这个对象做出其他断言。

    在使用CObject派生类的对象之前都要调用ASSERT_VALID宏。

    ASSERT宏的另一个变种是MFC ASSERT_KINDOF宏:

    ASSERT_KINDOF(cIassNanie, pObjectDerivedFromCObject)

    同样,这个宏也只用于CObject派生类的对象。下面是源代码:

    // from Afx.h

    #define ASSERT_KINDOF(class_name, object) \

        ASSERT((object)->IsKindOf(RUNTIME_CLASS(class_name)))

    这个宏是用来验证指向CObject派生类的对象的指针是否从个特殊类中派生来。ASSERT_KINDOF宏进行的运行时刻检査与C++ dynamic_cast所做的检査相似。后者在第2章讨论过了。由于IsKindOf函数需要类具有运行时刻类支持(注意MFC不使用C++运行时刻类型信息(RTTI)),这个类声明的时候必须包含DECLARE_DYNAMIC宏、DECLARE_DYNCREATE宏或者DECLARE_SERIAL宏,这样ASSERT_KINDOF宏才能工作。注意ASSERT_KINDOF宏并不调用ASSERT_VALID宏,因此你要在调用ASSERT_KINDOF宏之前调用ASSERT_VALID

    当你得到一个一般类型的CObject派生类的对象,霈要把它转换成一个特殊类的对象时,就需要调用ASSERT_KINDOF宏。比如说,我们看看标准CView:GetDocument函数:

    class CViewpublic CWnd

    {

        ...

    protected

        CDccument* m_pDocument;

        ...

    };

 

    CMyDocument* CMyView::GetDocument() {

        // ASSERT_VALID shauld be performed first

        ASSERT_VALID(m_pOocument);

        ASSERT_KINDOF(CMyDocument , m_pDocument);

        return (CMyDocument*)m_pDocument;

    }

    给定了CView基类的实现,编译器就很容易从CMyView::GetDocument函数中返回任何类型的CDocument指针(在使用了正确的强制类型转换后);但是只有CMyDocument对象是可以接受的,此时,ASSERT_KINDOF宏增加了这种限制。在理论上,你可以检査每个CObject派生类的对象的类型来避免发生无效类型转换(比如说所有的CObject派生的函数参数),但是我宁愿把这些例行的类型检查留给编译器做。ASSERT_KINDOF宏最适合于那些编译器可能错过的问题,比如说刚才的那个例子和与一般对象类如CoblistCobArrayCMapWordToObCMapStringToOb相关的问题。因此,我发现只有在很特殊的场合下才用到ASSERT_KINDOF

    使用ASSERT_KINDOF宏检测编译器可能错过的对象类型问题

    还有两个没有正式文件(undocumented)ASSERT宏的变种。

    ASSERT_POINTER(pointer, pointerType);

    ASSERT_NULL_OR_POINTER(pointer, pointerType);

    下面是它们的源代码:

    #define ASSERT_POINTER(p, type) \

           ASSERT(((p) != NULL) && AfxIsValidAddress((p), sizeof(type), FALSE))

    #define ASSERT_NULL_OR_POINTER(p, type) \

           ASSERT(((p) == NULL) || AfxIsValidAddress((p), sizeof(type), FALSE))

    正如你所见的,这些断言宏都是针对MFC AfxlsValidAddress函数的断言的简单便利的封装。当指针不能为空的时候,使用ASSERT_POINTER;否则,使用ASSERT_NULL_OR_POINTER。注意,不像使用没有正式文件的Windows API函数,使用没有正式文件的断言宏没有任何危险,因为你总是在必要的时候才自己定义它们。

    尽管你可以把任何布尔表达式放在你的断言语句里,这些布尔表达式中不包含任何程序代码或者副作用。但是MFC为断言提供了两个理想的函数:

    BOOL AfxIsValidAddress(const void* lp,

                     UINT_PTR nBytes, BOOL bReadWrite = TRUE);

    BOOL AfxIsValidString(LPCWSTR lpsz, int nLength = -1);

    BOOL AfxIsValidString(LPCSTR lpsz, int nLength = -1);

    AfxIsValidAddress函数用来决定调用进程对于某个给定大小的内存块是否具有读权限(1代表内存块的大小未知),或者在isWritable为真时是否具有写权限。AfxIsValidString函数决定一个调用进程对于某个给定长度的字符串是否具有读权限(使用默认值-1代表字符串还未确定)。下面是这些函数的源代码:

    ......

    这些函数都是对IsBadReadPtrIsBadWritePtrIsBadStringPtr这些API函数的简单便利的封装。另外,你也可以在断言里直接使用这些API函数和IsBadCodePtr API函数。也可以使用Visual C++ C运行时刻函数库_CrtIsValidPointer函数,这个函数除了只在调试版本里出现之外,其他都与AfxIsValidAddress函数相同.

3.4 自定义断言

    可能你会发现,有的情况下没有一个标准Visual C++断言合适。出于以下理由,你可能会创建自定义的断言:

    •为不同类型的Visual C++程序提供可移椬性,比如说Windows APIMFC或者ATL程序。

    •为了解释某个问题而提供问题的描。

    •为简化事后调试,提供一个调用栈(你可以通过StackWalk API函数得到一个调用栈)

    •为了在出现错误的时候抛出一个异常,使程序可以继续正确运行(可能在一种相对退步的状态)

    复习一下前面的几个断言的源代码,你就会发现一个重要的事实:书写自定义的断言是很容易的。因为Visual C++ C运行时刻函数库中的_CrtDbgReport_CrtDbgBreak函数完成了所有比较困难的工作。比如说,下面的自定义断言提供了可移植性(Visual C++内部)、问题的描述以及可选的异常:

    #ifndef _DEBUG

    #define  MCASSERT(expr, description)  ((void)0)

    #define  MCASSERTX(expr, description)  \

        do \

        {\

            if{!expr) throw exception; \

        } while(0)

    #else

    #define  MCASSERT(expr, description)  \

        do

        {

            if(!(expr) && (1 == _CrtDbgReport(_CRT_ASSERT, __FILE__, __LINE__,NULL, \

                     #expr ## "\n\nProblem: " ##description))) \

                _CrtDbgBreak();  \

        } while(0)

    #define  MCASSERTX(expr, description)  \

        do \

        {\

            if(!(expr)) \

            { \

                if( 1 == _CrtDbgReport(_CRT_ASSERT, __FILE__, __LINE__,NULL, \

                        #expr ## "\n\nProblem: " ##description)) \

                    _CrtDbgBreak(); \

                throw exception; \

            }  \

        }  while(0)

    #endif

    MCASSERT宏和MCASSERTX宏都允许你提供对问题的一个描述。这个描述作为问题的文档,使这些问题易于理解而不需要进入调试器和或者查看源代码。在远程调试的时候这一点帮助更大。当断言无效时,这些宏显示出如图3.4所示的消息框。

    MCASSERTX宏很有意思,它在调试版本和发布版本中都抛出于一个异常。在出现错误的时候抛出异常是一个有趣的想法,这使得书写代码更加容易,这些代码在出现错误的时候也能有良好的行为,只要代码是意外安全(ex,ceptionalsafe)的。但是,这个宏破坏了断言的一个基本特征:因为只存在于调试版本里,所以没有开销。在断言中抛出一个异常的优点是否大于缺点将留给你自己来决定。

3.5可移植的断言(PortableAssertions)

    在书写可移植代码的时候,你可以随心所欲地使用不可移植的断言,因为要移植这些断言是件容易的事情。比如说,你正在为MFC程序书写可移植代码,希望把这段代码移植到非MFC的环境中去。不用为了移植性而避免使用VERIFYASSERT_POINTERASSERT_NULL_OR_POINTER宏,你可以很容易地移植这些例程,甚至为这些宏创建你自己定义的断言。例如,你可以通过下面的宏把不同类型的断言宏放入Windows C++编译器或基本结构(framework)中:

    #ifdef  _DEBUG

    #define  _ASSERT(expr) assert(expr)

    #define  _ASSERTE(expr) assert(expr)

    #define  ASSERT(expr) assert(expr)

    #define  VERIFY(expr) assert(expr)

    #define  ASSERT_POINTER(p, type) assert((((p)!=0) && _CrtIsValidPointer((p), sizeof(type), FALSE))

    #define  ASSERT_NULL_OR_POINTER(p, type) assert((((p)==0) || _CrtIsValidPointer((p), sizeof(type), FALSE))

    #define  ASSERT_STRING(s) assert((((s)!=0) && !IsBadStringPtr(s), 0xFFFFFFFF))

    #else // _DEBUG

    #define  _ASSERT(expr)  ((void)0)

    #define  _ASSERTE(expr)  ((void)0)

    #define  ASSERT(expr)  ((void)0)

    #define  VERIFY(expr)  ((void)0)

    #define  ASSERT_POINTER(p, type)  ((void)0)

    #define  ASSERT_NULL_OR_POINTER(p, type)  ((void)0)

    #define  ASSERT_STRING(s)  ((void)0)

    #endif // _DEBUG

    当移植代码的时候,不用刪除那些不可移植的断言,相反,你也可以移植这些断言。

3.6使用断言的策略

    断言的正确使用需要一定的策略。你不可能只是随意分散一些断言在你的程序里,就指望它们自动揭示错误。采用系统的方法能得到更好的结果,比你可能在别处看到的“任意使用断言”的建议要好得多,我很怀疑自己是否曾经建议过任意地做某事,但是系统地使用断言使得书写断言代码更为容易,因为能使你效仿那些已经建立起来的模式。不要试图自己编造这些模式。

    让我们看看MFC的代码。我计算得出MFC有多于7500条断言。如果你曾经广泛应用过MFC,你就会知道这些断言是多么有效率。这些断言不仅仅帮助MFC自己的开发和调试,它们同样高效地揭示了由于不正确地使用MFC而产生的错误。比如说,如果你曾经尝试过以isotropic mapping模式使用MFC CScrolIView对象,那么你就会立刻发现这种映射模式是不允许的。让我们看看CScrolIView::SetScrollSizes的源代码。就知道为什么了:

    void CScrolIView::SetScrollSizes(int nMapMode, SIZE sizeTotal,

                    const SIZE& sizePage, const SIZE& sizeLine)

    {

        ASSERT(sizeTotal .cx >= 0 && sizeTotal.cy >= 0);

        ASSERT(nMapMode > 0);

        ASSERT(nWapMode != MM_ISOTHOPIC && nHapMode !=MM_ANTSOTROPIC);

        ...

    }

    如果你检査MFC源代码,你就会发现,断言都遵守某种规律性的模式。我提出的断言策略大致都遵从这些模式。

对整个世界都下断言(Assert the World)

    你怎么知道到底要断言什么呢?毕竟如果你必须知道自己要犯的错误是什么,才能使断言发现它。你就不会一开始就犯这些错误了。在David THieten的《No bugs! Delivering Error-Free Code in C and C++》中,他建议你“对整个世界都下断言”,我相信如果你能很好理解的话,这个建议倒是很不错。你可能把“对整个世界都下断言”解释为:对你能想得到的任何东西都下断言。这会导致一个膨胀的难以维护的代码,代码中每隔一行就是一个断言,就像;

    DotrivialAction();

    _ASSEFTE(DotrivialActionReallyHappened);

    对于这样的断言,它们的成本大大超过了所带来的好处。再次,请记住,断言不是测试的替代品,它们也不能检查程序的正确性。

    我对于“对整个世界都下断言”有另外一个解释。我相信它的意思是说,不要试图预测将会犯什么样的错误,也不要试图选择用哪一个断言来发现错误,而是根据断言策略来进行断言。比如说,假设你有一个日期的类,并且认为月份的值不应该大于12或者小1。你可能证明这里没有必要对此下断言,因为这个错误不可能存在,但是即使这样,也还是写一个断言,考虑下面的可能性:

    •一个日期类的成员函数被一个未曾预料的输入所调用,结果导致了无效的月份值。

    •在闰年的逻辑中出现了一个错误,导致出现13月中有366天的情况。

    •日期对象的内存被一个指针运算的错误破坏。

    •另一个不同类型的对象不正确地转换到日期对象。

    •日期对象是某一个不正确地从基类指针向下强制类型转换而来的对象的数据成员。

    •在维护代码的时候,你或者其他人犯了编程上的错误。

    •你错了——月份值发生错误是可能的。

    “对整个世界都下断言”,也就是说,不要试图选择哪一个断言要发现错误,而是根据你的断言策略断言一切。

什么需要断言

    正如前面所说的那样,把断言看作一种简单的制造栅栏的方法,这种栅栏能使错误在穿过的时候暴露自己。这样的断言做到了以下几点:

    •检查函数的输入。验证参数、相关数据成员、相关全局变量,也验证从别的变量得到的变量。比如说从父对话框得到子对话框的控件。

    •检査函数的输出,特别当输出是某个复杂运算的结果时。断言对于空输出没有多少好处。

    •检査对象的当前状态,。比如说,检査对象是否被正确初始化,检査当前状态下是否允许某个给定的函数调用或者输入。

    •检查逻辑变量的合理性和一致性。这样的检査包括计数器、偏移变量、循环不变量(循环逻辑正确时,循环不变量一定要为真)、不可能出现的值(比如说非法或者越界的值)、不可能出现的情况(如不可能成真的switch分支)以及那些与其他变量或者常量有固定关系的变量。任何关于这些数据和程序状态的假设也都要进行检查。

    •检査类中的不变量。类中的对象如果有效的话,这些语句必须成真(这一章的后面部分将做进一步介绍)

什么不需要断言

    在某些情况下,断言永远都用不上。这些情况包括:

    •断言不能检查那些可能正确也可能错误的情况。根据断言的定义,正确运行的程序不会使任何断言失效。如果一个断言失效了,就肯定发生了错误,而不是一个潜在的错误。第4章将介绍使用Trace语句检查潜在错误。

    •断言不是防御性编程的替代品,断言不能防止程序的发布版本崩溃。

    •断言不能检査那些不是实现方面的错误。这些非实现方面的错误包括用户输入错误、资源分配错误、文件系统错误以及硬件错误。断言不是异常处理、返回值或者其他形式的错误处理的替代品。

    •断言不能用来向用户报告错误。断言给程序员而不是给用户提供信息。由于你装载了发布版本,用户看不到任何断言。断言也不是错误消息的替代品。

    正如前面我们所说的,断言不能包含程序代码或者负面影响,这样发布版本才会和原来的程序有相同的行为。断言不能修改程序变量,也不能调用修改程序变量的函数。

3.7不变关系

    尽管你能通过一些简单的断言(比如检査指针参数是否为空)来发现很多种错误,值是最有力的断言是用来验证变量之间的固定关系。这些关系称为不变关系(invariants)。其中,类不变关系和循环不变关系最为普遍。类不变关系是某个数据成员和其他数据成员或者常量之间的固定关系。在公有成员函数的结尾出现的无效的类不变关系能够显示出成员函数逻辑上的错误。相似地,循环不变关系就是一个循环变量与其他循环变量或者常量之间的固定关系。循环结束时出现的无效的循环不变关系表明循环逻辑中出现了错误。

    类的构造函数建立了类不变关系,其他的公有成员函数必须维持这个关系。显然,类不变关系不可能在构造函数之前有效,也不可能在析构函数之后有效。再次以日期类为例,下面的函数检查简单的日期类不变关系:

    void CData::CheckInvariants() const {

        //100 is the first valid year given the representation

        _ASSERTE(m_Year >= 100);

        _ASSERTE(m_Month >= 1 && m_ Month <= 12);

        _ASSERTE(m_Day >= 1 && m_Day <= 31);

        _ASSRRTE(m_Hour >= 0 && m_Hour <= 23);

        _ASSEKTE(m_Minute >=0 && m_Minute <= 59);

        _ASSEKTE(m_Second >=0 && m_Second <= 59);

    }

    在这个例子里,所有的数据成员都和常量进行了比较。注意,在CheckInvariants函数里对每个不变关系分别测试,比让一个函数返回布尔变量并且断言这个函数的方式(就像_ASSERTE(date.CheckInvariants())要好得多,这是因为对不变关系分别断言能在不变关系失效的时候,在断言消息框中精确显示是哪一个断言失效了。

    这些不变关系的问题在于它们不检查数据成员之间的关系,因此会使二月三十一日为有效的日期。你可以通过在CheckInvariants函数里面增加下面的代码来解决这个问题。

    int daysInMonth = DaysInMonth(m_Month, m_Year);

    _ASSERTE(m_Day <= daysInMonth);

    这里DaysInMonth返回某个月的天数,并且能够处理闰年。这个额外的检查并不是什么大事,但是值得我们多费点功夫,因为闰年比其他日期更容易出错。注意,如果CDate是从MFC CObject类中派生而来,CheckInvariants函数就应该称为AssertValid函数,在这一章的后面将给出对AssertValid函数实现方面的指导。

    尽管类不变关系一般都是基于现存的数据成员,但是有时候仍然值得增加一些包含冗余信息的数据成员,尤其是为了不变关系的验证。通常的例子就是类签名,比如说显示结构为多少字节的数据成员(在第2章里描述过的),或者校验和,这是从其他数据成员计算出来的值,用来检査错误

    类不变关系有时又称为表示不变量(representation invariants)。两个概念本质上是相同的,但是对于表示不变量的定义更加精确。表示不变量是对特定的具体数据表示的一个限制,这个限制对于正确实现抽象数据类型是必需的。其思想是任何类都能被更加基本的数据类型表示出来。比方说,CDate类用6个整数进行表示。因此,当某个整数是一个在INT_MININT_MAX之间的整型值的时候(limit.h中定义),对月份的数据抽象要求值在112之间,任何其他的值都是无效的月份表示。另一个例子,你可以把CStringList作为某个联系人姓名列表类的具体表示,这个表示不变量可能要求名字表是排好序的,入口唯一,并且区分大小写。所有这些限制都不是CStringList类的。表示不变量的概念帮助你通过检査表示法中每个数据成员得到类不变关系,并且得到要使数据成员正确表示一个有效对象需要什么样的限制。

    和类不变关系相比,循环不变关系十分简单。比如说,假设你正在写一个函数,要在联系人姓名表中插入一个名字。一个好的循环不变关系就是在新的名字被插入后,表仍然是排好序的。就像这个例子里的一样:

    CString itemName, prevItemName, nextItemName;

    int itemCount;

 

    // add new name;

    for(int item=0; i<itemCount; item++)

    {

        ...

        if(insertItem)

        {

            //insert item

            ...

            // loop invariants - make sure list is still sorted

            _ASSERTE(item <= 0 || prevItemName < itemName);

            _ASSERTE(item <= itemCount-1 || itemName < nextItemName);

            break;

        }

    }

    在你书写程序的时候就建立好不变关系。在代码中为它们建立文档,在实现和维护代码的时候要意识到它们的存在。实际上,不理解不变关系就很难正确书写代码,不变关系是代码设计中的完整的一个部分,而不是你为了断言而编造的东西。通过对不变关系进行断言,就很轻易地加强了设计约束。

    设计程序的同时就设计不变关系。在写代码之前理解它们,并且为它们建立文档。

公有函数和私有函数

    断言策略必须根据你书写的函数类型改变。公有成员函数比私有和保护的成员函数需要更全面的断言。这个不同之处是因为必须这样,而不是出于方便的考虑。问题在于——虽然简单,但是己经足够了——当类不变关系在公有成员函数的开头和结尾处成立的时候(构造函数和析构函数例外),不一定在公有成员函数中间并且对象正在被改变的时候也成立,考虑羡慕的CDate公有成员函数的例子:

    void CDate::SomePublicfundtion(int input) {

        CheckInvariants();  // the object is now va]id

        ASSERT(inpu <= MaxInput);

 

        //perform some sort of state change the object may temporarily be invalid

        privateMembetFundtion1(input);

        privateMembetFundtion2(input);

        privateMembetFundtion3(input);

        CheckInvariants(); // now the object is valid again

    }

    当对象正在改变状态的时候,可能会变得临时无效。此时如果任何一个私有成员函数调用CheckInvariants函数,断言就有可能失效。然而,这些私有成员函数可以断言任何函数要求的性质,比如说参数的有效性、相关数据成员的有效性以及假设的有效性。因此,使用能暴露错误的断言的时候,在公有成员函数中建立完备的屏障,并且在私有和保护成员函数中做最少的检査。

    公有成员函数比私有和保护的成员函数需要更全面的断言。

3.8 断言模式

    下面的断言模式展示了Windows程序里最常见的断言形式,同时还有这些断言使用时的一般上下文。这些模式展示了几种断言,但是你只需要使用最适合情况的那个断言,也就是最能匹配你的数据的断言。比如说,你可以使用:

    void ExampleFunction(CMyObject* pObject, MyStruct* pStruct, LPCTSTR string)  {

        _ASSERTE(pObject != 0);

        _ASSERTE(pStruct != 0);

        _ASSERTE(string != 0);

    }

    但是下面的方法更好:

    void ExampleFunction(CMyObject* pObject, MyStruct* pStruct, LPCTSTR string)  {

        ASSERT_VALID(pObject);

        ASSERT_POINTER(pStruct);

        ASSERT_STRING(string); // this is a custom assertion

    }

    在这个例子里,我使用在这一章前面“可移植的断言”中定义的基于MFC的宏,因为它们易于移植。

函数参数断言

    到目前为止,最常见的断言是用来检查函数参数的有效性的。你也可以对作为隐式参数的全局变量釆用同样的检査。下面是用来检査函数参数的最常见的断言模式(对“need to document”的解释在后一部分)

    void FunctionWithParameter(int value, int flagOrMode, BOOL boolean,

        LPTSTR string, HANDLE handle, char* pointer, MyStruct* pStruct,

        CMyObject* pObject, CNonMFCObject* pOtherOBject, FARPROC lpfnCallback)

    {

        // assertion for integers

        _ASSERTE(value <= maxValue); // need to document

        _ASSERTE(value >= minValue); // need to document

        _ASSERTE(value >= minValue && value <= maxValue); // document

        _ASSERTE(value == redundantValue); // need to document

        _ASSERTE(value == requiredValue); // need to document

        _ASSERTE(value != illegalValue); // need to document

 

        // assertions for flags or modes

        _ASSERTE(flagOrMode & REQUIRED_FLAG_OR_MODE); // document

        _ASSERTE((flagOrMode & ILLEGAL_FLAG_OR_MODE) == 0); // document

 

        // assertions for Booleans

        _ASSERTE(boolean); // need to document

        _ASSERTE(!boolean); // need to document

 

        // assertions for strings

        ASSERT_STRING(string); // this is a custom assertion

        ASSERT_NULL_OR_STRING(string); // this is a custom assertion

        // use if string buffer must be a certain size

        _ASSERTE(_CrtIsValidPointer(string, charsInString, FALSE));

        // use if string is uninitialized and buffer size is unknown

        ASSERT_POINTER(string, char);

 

        // assertions for handles

        _ASSERTE(handle != 0);

        _ASSERTE(::IsWindow(handle)); // for window handles

        _ASSERTE(::IsMenu(handle)); // for menu handles

        _ASSERTE(:GetObjectType(handle)); // for GDI handle

 

        // assertions for pointers

        _ASSERTE(pointer == 0); // need to document

        ASSERT_POINTER(pointer, pointerType);

        ASSERT_NULL_OR_POINTER(pointer, pointerType);

        // use 1 when size is unknown

        _ASSERTE(_CrtIsValidPointer(pointer, 1, FALSE));

 

        // assertions for structs

        ASSERT_POINTER(pStruct, MyStruct);

        ASSERT_NULL_OR_POINTER(pStruct, MyStruct);

 

        // assertions for objects derived from CObject

        ASSERT_VALID(pObject);

 

        // assertions for objects not derived from CObject

        _ASSERTE(pOtherObject != 0);

 

        // assertions for callback functions

        _ASSERTE(!IsBadCodePtr(lpfnCallback));

    }

CObject函数断言

    下面是对从MFC CObject基类派生的对象的断言:

    void CMyObject::PublicFunction()

    {

        // use only for public member functions, since private and

        // protected member functions may be called while the class

        // invariant is temporarily invalid.

        ASSERT_VALID(this); // make this assertion first

        ...

    }

 

    void CMyObject::PrivateFunction()

    {

        // for private and protected member function, assert only the

        // data member that are used by the function, not the whole

        // object

        ASSERT_VALID(m_pUsedMemberData1);

        _ASSERTE(m_pUsedMemberData2 != 0);

        ...

    }

 

    void CMyObject::ComplexStateChange()

    {

        // this is a complex function that is likely to have bugs.

        ASSERT_VALID(this); // make this assertion first

        ...

        // perform complex change

        ...

        ASSERT_VALID(this); // make sure object is still valid when done

    }

 

    void CMyObject::SomeOperation(CMyObject* pObject)

    {

        ASSERT_VALID(this); // make this assertion first

        // use if can't perform operation on itself

        _ASSERT(pObject != this);

        ...

    }

 

    void CMyObject::AssertValid()

    {

        // check the immediate base class first

        CMyObjectBaseClass::AssertValid();

 

        // check the data members not in the base class

        ASSERT_VALIDE(m_pObject1);

        ASSERT_VALIDE(m_pObject2);

 

        // now check the class invariants not checked by the base class

        // be sure to document the class invariants

        _ASSERTE(m_Value != illegalValue);

        _ASSERT(m_Object1.GetSize() == m_Size);

        ...

    }

派生数据断言

    下面MFC断言模式适用于检查派生数据(就是从其他数据派生的数据)。你也可以对非MFC的派生数据使用相同的断言。

    void CMyObject::MFCFunctionWithDerivativeData()

    {

        CWinApp* pApp = AfxGetApp();

        CWnd* pMainWnd = AfxGetMainWnd();

        CDoc* pDoc = GetDocument();

        CView* pView = GetActiveView();

        // check derivative data

        ASSERT_VALID(pApp);

        ASSERT_VALID(pMainWnd);

        ASSERT_KINDOF(CFrameWnd, pMainWnd); // use ASSERT_VALID first

        ASSERT_VALID(pDoc);

        ASSERT_VALID(pView);

 

        // then check data derived from the derivative data

        CFont* pFont = 0;

        if(pView != 0)

            pView->GetFont();

        ASSERT_VALIE(pFont);

        ...

    }

 

    void CMyObject::TypeicalDialogBoxFunction()

    {

        CButton* pButton;

        CEdit* pEdit;

 

        // check dialog box control pointers

        pButton = (CButton*)GetDlgItem(ID_BUTTON);

        pEdit = (CEdit*)GetDlgItem(ID_EDIT);

        ASSERT_VALIE(pButton);

        ASSERT_VALIE(pEdit);

        ...

    }

逻辑断言

    下面的断言模式适用于检查函数逻辑和内存破坏(corruption)

    BOOL FunctionWithLogic()

    {

        ...

        switch(value)

        {

        case ImpossibleCase: // often the default case

            _ASSERTE(FALSE);// need to document

            return FALSE;

        ...

        }

        ...

        while(someLoop)

        {

            ...

            _ASSERTE(loopInvariant); // check loop invariant

        }

        ...

 

        // after a complex calculation, assert the results

        _ASSERTE(results > 0);

        _ASSERTE(results == 0);

        _ASSERTE(results <= maxValue); // need to document

        _ASSERTE(results >= minValue); // need to document

        _ASSERTE(results >= minValue && value <= maxValue); // document

        _ASSERTE(results == redundantValue); // need to document

        _ASSERTE(results == requiredValue); // need to document

        _ASSERTE(results != illegalValue); // need to document

        ...

        return TRUE;

    }

 

    void FunctionMessingWithMemory()

    {

        ...

        // assert memory check if concerned about memory corruption

        _ASSERTE)_CrtCheckMemory());

    }

Windows API断言

    下面的断言适用于直接使用Windows API函数编程。当在一个Windows过程中处理消息时,你应该在消息出错时应用相关断言。也就是说,对从wParamlParam派生的参数使用断言。

    LRESULT CALLBACK MyWndProc(HWND hWnd, UlNT msg, WPARAM wParan, LPARAM lParam)

    {

        _ASSERTE(IsWindow(hWnd));

 

        switch(msg)

        {

        case MyMessage1:

            {

                HWND hChildWnd = (HWND)wParam;

                _ASSERTE(IsWindow(hChildWnd));

                LPTSTR string = (LPTSTR)lParam;

                ASSERT_STRING(string); // this is a custom assertion

                ...

                break;

            }

        case MyMessage2:

            {

                HDC hDC = (HDC)wParam;

                _ASSERTE(GetObjectType(hDC) == OBJ_DC);

 

                BYTE* buffer = (BYTE*)lParam;

                ASSERT_POINTER(buffer, BYTE);

                ...

                break;

            }

        ...

        }

        return DefWindowProc(hWnd, msg, wParam, lParam);

    }

断言策略小结

    要想总结这些断言策略,必须首先应用这些断言模式。先试着学习这些已经建立起来的模式,而不是一开始就自己编造。挑选适合你的数据的最强有力的断言形式。虽然存在很多类型的断言,但是函数参数断言是最常见的,基于不变关系的断言通常是最有力的。根据你的策略断言一切。不要试图挑选哪一种断言会发现错误。

3.9为你的断言书写文档注释

    刚才给出的好几个断言都有“need to document”这样的注释。一些特定的断言,比如说ASSERT_VALID(pObject)ASSERT_POINTER(pointer, pointerType),都是不言自明的,但是更一般的断言就不是这样的了。比如说,如果一个断言是来验证一个假设或者需求,那么你必须保证假设和需求都是清晰明了的。考虑Wimiows98中的这个程序:

    void CExanpleClass::DrawObject (CDC dc, int x, int y) {

        ASSERT(x >= -32768 && x <= 32767);

        ASSERT(y >= -32768 && y <= 32767);

    }

    想想如果其中一个这样的断言失效的时候,你将会十分困惑。为什么坐标会被限制到这些任意的值之内?这些对坐标的限制看起来垃十分随心所欲,除非你知道在Windows98系统中,Windows98 GDI坐标系统采用的是16位的值,这一点和Windows2000不同。在断言中做出说明能帮助解决这些疑惑。

    void CExanpleClass::DrawObject (CDC dc, int x, int y) {

        // Win98 requires 16-bit coordinates

        ASSERT(x >= -32768 && x <= 32767);

        ASSERT(y >= -32768 && y <= 32767);

    }

    如果你使用自定义断言,这些断言有自我解释的特征(就像在这一章前面描述的那样),你可以直接在断言语句中就给出注释。

    在断言模式中,我己经标注了需要注释的断言。总要为那些不清晰的断言做出注释,毕竟你比任何人都了解这些断言的意思。如果对某个问题有简单的解决方法,也把它标注出来。给你的同事和你自己一个目前情况的线索

3.10实现AssertValid

    如果你能为你的CObject派生类写出好的AssertValid函数的话,MFC ASSERT_VALID宏比普通的断言更加有力。如果你使用下面所示的MFC ClassWizard生成的代码,你就离这个目标不远了:

    void CExampleClass::AssertValid() const

    {

        CObject:: AssertValid();

    }

    回忆一下CObject:: AssertValid的实现:

    void CObject:: AssertValid const

    {

        ASSERT(this != NULL);

    }

    简而言之,没有AssertValid函数的好的实现,ASSERT_VALID(pObject)ASSERT_VALID(pObject != 0)一样。

    AssertValid函数是检査类不变关系的理想场所。MSDN中建议AssertValid函数应该做一个“浅层检查”(shallow check)。意思是说,“如果一个对象包含了指向其他对象的指针,它应该检查这个指针是否为空,而不是对指针指向的对象做有效性检查”。这个建议的目的是避免出现断言“瀑布”(cascade),从而使程序的调试版本的性能降到最低。比如说,假设你有一个数据成员是其他对象的集合,而这个集合通常都包含了几百个对象。如果对这样的对象做“深层检査”(deep check),就会在你每次调用执行ASSERT_VALID的函数的时候,导致出现上千个断言。这样的断言“瀑布”极大地影响了性能,使你的程序难以调试。

    尽管你想要避免这样的错误,但是我并不认为浅层检查是一个通常情况下的好做法,特别是数据成员不是对象的集合,进行ASSERT_VALID不会极大地影响性能的情况下。除使用ASSERT_VALIDCObject派生的数据成员进行断言之外,我建议对其他类型的指针使用更加强有力的断言,比如ASSERT_POINTERASSERT_NULL_0R_POINTER,而不是简单地检査指针是否为空。只在数据成员为集合的时候才采用浅层检查。毕竟,AssertValid函数是验证从CObject派生类的对象的最好选择,不要由于被误导的性能方面的恐惧而不敢釆用。当然,在这个函数里,你的断言应该比其他函数里的少。记住,如果AssertValid函数成为性能瓶颈的话,你总是能够减少这些断言。如果性能成为问题,最好对普通的用法采用功能强大的AssertValid函数,而在时间比较关键的地方使用简单所断言。

    通过AssertValid函数检查对象的类不变关系,对简单教据成员使用功能强大的断言,而对集合数据成员采用浅层检查

    我建议对于AssertValid函数采用下面的断言模式:

    void CMyObject:: AssertValid()

    {

        //check the immediate base class first

        CMyObjectBaseClasss:: AssertValid();

 

        //check the data member not in the base class

        ASSERT_VAIID(m_pObject1);

        ASSERT_VALID(m_pObiect2);

 

        // now check the class invariants not checked by the base class

        // be sure to document the invariants

        ASSERT(m_Value != illegalValue);

        ASSERT(m_Object1.GetSize) == m_Size);

        ...

    }

    第一步是调用一次基类的AssertValid函数。这样做能够验证基类提供的所有数据成员,因此在这里就不用再去验证它们了。第二步,验证派生类所独有的数据成员。最后,针对派生类进行类不变关系的断言,这样相对来说更有效率。比如说,如果类中包含了一个值的列表,你可能要检查这个列表的长度,并且和其他的数据成员进行比较。同样,你可能需要对列表的头和尾进行检査,保证它们遵循某种不变关系,除非列表很小,否则你不要检查列表中的每一项,也不要试图检查列表中表项的顺序,因为这样的检查太费时间。

    还有一些额外的细节需要考虑:

    AssertValid函数中断言的顺序十分重要。在检查数据的细节之前首先要保证数据是有效的。

    •程序员不要指望断言会抛出异常,因此AssertValid函数也不会抛出异常(显然,这是前面所说的MCASSERTX宏的另一个问题。如果你必须在AssertValid涵数里面调用一个会抛出异常的程序,那么就在AssertValid函数内部解决这个异常。

    •保证对类的不变关系(即使是那些你不用检查的不变关系)和可能不太清晰的断言给出注释。

    •正如在第2章讨论的那样,一些对象的创建过程分为两少:第一步,构造函数创建一个有效且为空的对象;第二步,初始化函数(比如Init或者Open)创建真正的对象。保证AssertValid函数能够反映出两步里对象都是有效的。例如,下面的方法:

    void CMyWindow:: AssertValid()

    {

        //check the immediate base class first

        CWnd:: AssertValid();

 

        //check the data member not in the base class

        ASSERT_VAIID(m_pObject);

 

        if(::IsWindow(m_hWnd)

        {

            // object has been fully constructed - now check the

            // class invariants not checked by the base class

            ...

        }

    }

    在这段代码里,类不变关系只有在实际的窗口被创建了以后才得到检查。

3.11防御性编程(Defensive Programming)

    断言能帮助在调试的时候发现运行时刻错误,但是它们并不能保护一个程序,防止其发生运行时刻错误。考虑下面这段用来调整核反应堆中心温度的代码:

    HRESULT CReactorCore::RegulateTemperature(int currentTemperacure) {

        if(currentTemperature < MaximumDesiredTemp)

            RemoveCoolingEods();  // remove rods- - no longer necessary

        else if(currentTemperature < MaximumAllowedTemp) {

            InsertCoolingRods(); // insert rods to cool down core

            return S_HOTCORE;

        }

        else

            _ASSERTE(FALSE);  // reactor out of control

        returnS_QK;

    }

    尽管这段代码可能在开发和测试的时候都工作良好,其至在正常情况下也会正常运行,但是一场灾难即将发生。如果在开发的时候核心温度超过了最大允许值,断言就会向程序员显示错误,但是在实际使用的时候,如果核心温度超过最大允许值,这段程序将不会采取任何解决问题的措施,甚至不向操作人员报错。更糟糕的是,如果调试版本被错误地封装起来发布,断言将会在出错的时候终止程序(Visual C++不会出现这样的情况),程序就在最需要它们的时候被停止运行了。

    这段代码的真正问题在于,这里压根就不能使用断言。断言应该检测那些在程序正常运行的时候永远都不可能出现的状态。在这个例子里,程序能够正常运行,但是反应堆不会。加热过度的问题必须得到解决。实际上,不正确地用断言来解决这个问题才是真正的错误。断言是用来揭示错误的,而不是用来纠正运行时刻错误的

    当然了,这是一个极端的例子,我知道你永远都不会犯这样的错误。但是存多少次你看到下面这段代码?

    char* strcpy(char* dest, char* source) {

        _ASSERTE(source != 0);

        _ASSERTE(dest != 0);

        char* returnString = dest;

        while((*dest++ = *source++) != '\0') ;

        return returnString;

    }

 

    void CExampleDialog::SetEditBoxText(const CString& caption) {

        CEdit* pEdit = (CEdit*) GetDlgItem(ID_EDlTBOX);

        ASSEBT_VALID(pEdit);

        pEdit->SetWindowText(caption);

    }

    在这些例子里面,断言用来向程序员报告程序中的错误。不幸的是,它们在发布版本里不会处理这些错误,也不会在出现不希望的数据的时候,发生存取异常。这些例子中缺乏地就是防御性的编程。

    防御性的编程是构造程序的一种方式,在这种方式里,当某些没有料到的事情发生后程序仍然能正常运行。防御性的程序能保护它们自己在接收到意外数据或者无效数据的时候,不会运行在不可预知的状态,不会发生系统崩溃(system failure),甚至不会发生错误。这样的程序同样是具有健壮性的。我经常看见把断言称为是一种防御性编程的手段。正如前面的例子中所示的那样,这显然是错误的。断言在调试的时候向程序员揭示运行时刻错误(调试版本里),但是防御性的编程使用户在运行程序(发布版本里)的时候,当出现意外情况的时候,程序仍然能继续工作。两个概念有相似的地方,但是并不相同。

    实际上,防御性的编程要求程序在检测到意外情况的时候,通过返回一个“安全”的值(比如说布尔函数返回false,返回指针或者句柄的函数就返回空值),一个错误代码或者抛出外异常(这两种方法在第5章“使用异常和返回值”详细讨论)来解决问题。可不管使用什么技术,垃圾输入都不应该导致垃圾输出。特定的几种防御性的编程技术包括:处理无效函数参数和数据、出现问题的时候程序失败、检査临界函数返同的错误代码以及处理异常。需要防御性编程的标准问题包括:错误的输入数据、内存或者硬盘空间不够、不能打开一个文件、外设不能访问、网络连接不上或者甚至在程序中还有错误,目的是保持程序的运行状态。如果错误严重的话,就告诉用户,然后继续运行,甚至在可能是有些退步的状态下

    当然了,程序崩溃都是有原因的。有些问题严重到最好的解决办法就是关闭程序。比如说,一个字处理程序。假设用户给出一个命令,要求显示About Box时,About Box显示用户的名字和授权信息。如果出于某些原因,不能创建授权信息的字符串。这个问题并不严重,因此,About Box代码应该能解决这个问题,这可以通过显示没有授权信息的对话框来解决。这样一个简单的问题不应该导致出错信息。用户可以继续使用程序,而且肯定不应该丢失文件。但是如果程序监测到文件已经被破坏了。这是一个严重的问题,不应该允许用户继续编辑文件或者把文件存入到某个存在的文件夹里。相反,程序应该警告用户,并且关闭程序。

    Steve Maguire的《Writing Solid Code》里,他强调防御性编程隐藏了错误。尽管这个问题是可能的,但不是必然的。毕竟防御性编程中并没有要求你隐藏错误。如果你的程序是防御性的,别忘了使用断言。如果你使用断言,别忘了防御性编程。这两种技术最好结合在一起使用

    当把防御性编程和断言结合在一起的时候,我发现我经常使用_ASSERTE(FALSE)。比如说,我不写:

    _ASSERTE(complexExpression);

    if(!complexExpression }{

        // handle error

        ...

    }

    VERlFY(!FAILED(retVal = SomeFunction()));

    if(FAILED(retVal)) {

        // handle error

        ...

    }

    而是写:

    if(!complexExpression }{

        // handle error

        _ASSERT(FALSE);

        ...

    }

    if(FAILED(SomeFunction())) {

        // handle error

        _ASSERT(FALSE);

        ...

    }

    在这种情况下false断言既容易书写也容易维护,因为它消除了使用重复代码和使用麻烦且冒险的VERIFY语句为复杂的程序书写断言的必要。FALSE断言的缺点就是使用_ASSERTE宏或者assert函数,它们在断言失效的消息框中显示的表达式没有提供任何信息。要想得到有帮助的信息,你可以使用下面的任何一种方法:

    _ASSERTE("This object requires the MM_TEXT mapping mode." == 0);

    _ASSERTE(!"This object requires the MM_TEXT mapping mode.");

    这些断言的值为false,不过它们能够显示更具有描述性的消息。

    考虑使用_ASSERTE(FALSE)来简化防御性编程和断言的结合。要想得到更有描述性的断言消息,考虑使用_ ASSERTE("Problem descripticm." == 0)

3.12错误处理

    断言不是防御性编程的替代品,也不是错误处理的替代品。断言不应该出现在正确运行的程序的状态下。比如说,你永远都不要书写下面的代码:

    // read file

    CFile file;

    CString pathName;

    ... // set pathName somehow

    if(!file.Open(pathName, CFile::typeText | CFile::modeRead))

        _ASSERTE(FALSE);

    ...

    没有成功打开一个文件是一个问题,但是显然不是错误。它不应该被当作是意外情况,而是必须在程序中加以处理。

    注意,在单线程程序的源代码中,如果你采用错误消息来处理这个问题,就不要再使用断言在源代码中定位问题所在。要想知道为什么收到了错误消息,可以从调试器中选择Break Execution命令,或者在程序运行时按下F12(只在Windows 2000中有效)

3.13各种各样的提示

    下面的这些提示能帮助你从断言中获得最大好处。

发布版本问题

    在调试版本和发布版本之间有很多区别,断言是其中一个(这些区别将在第7章“使用Visual C++调试器调试”详细讨论)。如果你的调试版本正常工作而发布版本没有正常工作的话,很可能是某些断言语句产生了副作用。要快速排除断言的问题,试着如下重新定义断言宏,并把这段代码加入到StdAfx.h头文件的结尾处。

    #ifdef _DEBUG

    #undef ASSERT

    #undef _ASSERT

    #undef _ASSERTE

    #define ASSERT(expr)    ((void)(expr))

    #define _ASSERT(expr)    ((void)(expr))

    #define _ASSERTE(expr)    ((void)(expr))

    #endif // _DEBUG

    如果程序行为发生改变,你的断言就产生了副作用。

总是使用/W4警告级别

    总是使用/W4警告级别来避免断言错误。比如说,下面这条语句在编译的时候如果不使用/W3警告级别:

    _ASSERTE(i = 42);

    /W4警告级别就会给出消息:“waming C4706assignment with inconditional expression(警告C4706:在条件表达式中出现了赋值)(使用/W4警告级别的意义已经在第2章中讨论过了)

断言也是代码

    断言也是代码,也需要维护,偶尔还需要调试。当你的代码改变的时候,断言语句和它们的注释可能也会发生改变。当然了,断言不会在正确的代码中间失效,因此你必须修改或者删除不满足条件的断言。如果维护代码的不是同一组的程序员,要保证他们明白他们也要维护断言。

让它简单点

    试着让你的断言尽量简单。不要自作聪明,把断言放在让人意想不到的地方。相反,把调试代码和程序代码分开。记住断言越复杂,你花在调试代码上的时间就越多。

    不要把不相关的检査放在同一个断言语句中,除非你能肯定你总是能够打断调试器(这是不可能的)。如果不通过调试器,你就无法从文件名和行号得出是哪一项检查导致了断言失效。比如说,注意:

        _ASSERT(value != illegalValue);

        _ASSERTE((flagOrMode & ILLEGAL_FLAG_OR_MODE) == 0);

    比下面这段代码更好:

        _ASSERTE(value != illegalValue && (flagOrMode & ILLEGAL_FLAG_OR_MODE) == 0);

    因为问题会在断言失效消息里清晰地标示出来。

    有时候断言语句(以及其他的布尔语句)都通过使用DeMorgan理论来简化。DeMorgan理论是说,A&&B!(!A || !B)相同,A||B又和!(!A && !B)相同。

    正如前面所说的那样,复杂的断言能在防御性编程的时候通过_ASSERTE(FALSE)语句简化。比如:

        _ASSERTE(complexExpression);

        if(!complexExpression }{

            ...

        }

    能被下面的代码替换:

        if(!complexExpression }{

            _ASSERT(FALSE);

            ...

        }

    从另一方面来说,试着在断言语句中而不是在程序代码中保持断言逻辑.。不使用;

        if(x > 0)

            _ASSERTE(y > 0);

        if(x == 0)

            _ASSERTE(this};

        else

            _ASSERTE(that);

    而要使用:

        _ASSERTE(x <= 0 II y > 0);

        _ASSERTE((x == 0) ? this : that);

考虑使用_CrtSetReportMode_CrtSetReportFile

    C运行时刻函数库和MFC断言宏显示一个断言失效的消息框,但是这种默认的行为可以被改变。你可以使用_CrtSetReportMode函数,让断言把消息输出到消息框、输出窗口、文件或者任何这些选项的组合中。_CrtSetReportFile函数用来设置输出文件。

    在某些情况下,改变输出目的地很有用。比如说,你可以选择让断言在显示消息框的同时还输出到日志文件里以备测试之用。日志文件在它们的错误报告中为测试人员提供了精确的信息。如果你想自动测试并且希望测试调试版本(因为调试版本里包含了可以帮助你寻找错误的断言),你应该把断言的输出信息放到文件或者调试器的输出窗口中。这点是很有帮助的,因为自动测试脚本并不能很好地处理断言消息框。同时。如果测试系统组件没有用户界面的话,你就需要重定向断言的输出。因为失效的断言会锁住系统直到有人关闭了消息框。

    你也可以使用_CrtSetReportMode函数有选择地防止某些断言失效消息框的显示。比如说,假设你修改了程序结果导致上百个断言的失效。不用单击Ignore按钮上百次,你可以调用_CrtSetReportMode(_CRT_ASSERT或者_CRTDBG_MODE_DEBUG),使断言输出到调试输出窗口,并且一旦你解决了问题,就恢复消息框的输出。由于你可以双击输出窗口的Debug标签中的断言失效信息,因此还是很容易发现出错的代码的。唯一的缺陷就是你可能得不到调用桟。

    另外,假设在窗口画图代码中发生了断言失效。就会在窗口上画图的时候显示消息框,使得画图代码难以调试,因为窗口要经常失效。在这神情况下,你可以使用下面的技术:

    void CMyView::OnDraw(CDC* pDC) {

        int previousReportMode = _CrtSetReportMode(_CRT_ASSERT, _CRTDBG_MODE_DEBUG);

        CMyDoc* pDoc = GetDocument();

        ASSERT_VALIDE(pDoc);

        ... // draw the window

        if(previousReportMode != -1)

            _CrtSetReportMode(_CRT_ASSERT, previousReportMode);

    }

    这种方法让你不用打扰窗口画图就可以在出现窗口后,随时察看导致断言失效的代码。

使用库代码中的断言

    MFC库提供了非常好的使用断言的例子。如果你在使用MFC的时候出现了错误,很有可能问题会被某个断言所发现。显然,在库代码中使用好的断言策略将会获益匪浅。但是,注意标准断言失效信息框显示源代码文件名和行号,MFC会把源代码都带上。如果你想创建你自己的库的调试版本,而又不想带上源代码的时候怎么办呢?

    最简单的解决方法就是用_ASSERTE宏而不是其他的断言,用来提供失效表达式。另外,你可以把断言和TRACE语句结合起来,这样能提供额外的信息,就像下面所示的这样;

    if(!string.LoadString(promptID)) {

        TRACE1("Error:Failed to load string %d.\n), promptID);

        _ASSERT(FALSE);

    }

    显然,最好的办法是创建自定义的断言宏(就像这一章前面所介绍的MCASSERTMCASSERTX)。能够在断言失效消息框中显示出对问题的完整描述。也可以通过StackWalk API函数在创建的自定义断言宏的消息框中显示调用栈。还有一个好处,用户自定义的断言消息框可以有Save命令,因此,库的用户就不用把消息框中的所有信息都写下来。没有提供源代码使得断言不够有效,但是你可以通过自定义的断言提供足够的信息,使它们至少可用。

正确性检査

    尽管一般情况下,你不能用断言来检査正确性,但是有的场合下,用两种方式做一些运算然后在断言中比较它们的值,也是检査正确性的有效办法,比如,假设你有一个非常复杂的、髙度优化的算法,佴是可能有错误,你可以用下面的断言:

    result = ComplexHighlyOptimizedGoryAlgorithm(input);

    _ASSERTE(result == SimpleAlgorithm(input));

    当然了,你必须保证调用SimpleAlgorithm没有副作用。可以考虑在必要的时候写一些专门的只用于测试的函数来进行较为复杂的断言。

使用GetObjectType函数

    你可以通过GetObjectType函数来断言一个具有有效句柄的GDI对象(察看返同值是否为零),或者某个特定的GDI对象(察看返回值是否是某个特殊值)。比方说,你可以用下面的方式断言个工具刷是否有效:

    _ASSERTE(GetObjectType((hBrush) == OBJ_BRUSH);

    但是,要意识到GetObjectType函数可能返回一些让人吃惊的结果。如,下面的断言没有失效:

    HBRUSh hBrush = CreaeSolidBrush(RGB(0,0,0));

    DeleteObject(hBrush);

    _ASSERTE(GetObjectType(hBrush) == OBJ_BRUSH);

    为什么?因为黑色的刷子是一个备用的对象(也就是不能被删除),因此DeIeteObject函数调用就没有作用。

MSDN文档错误

    MSDN文档声称IsBadCodePtrIsBadReadPtrIsBadStringPtrIsBadWritePtr这几个API函数在接收到坏指针的时候就会在调试版本里导致断言失效。这个说法是错误的。你必须把这些函数包装在断言语句中,就像在这一章里所示的那样。

从错误中学习

    调整断言策略,使之符合你的错误。尽管这一章里介绍的断言策略在一般情况下都是正确的,然而用户的程序却各不相同。不管你什么时候发现了一个断言没有发现的错误(尤其是这个错误很难追踪的时候),问问自己是否能够增加或者修改断言,使得代码能够自动掲示错误。如果是这样的,就在你的代码中增加断言,并且修改你的断言策略。

不稳定的程序

    如果你的程序不稳定并且没有使用断言,或者只有散乱分布的断言,停下所有工作。花一整天的时间看看所有的代码,并且加上我们这一章里描述的断言。如果你这么做了,你就会发现这很值得。

    断言是一种让错误在运行时刻自我暴露的简单有效实用的技术。它们帮助你较早较轻易地发现错误,使得整个调试过程效率更高,要想更有效率,就要采用一定的策略。不要随心所欲地把断言散布在你的代码里面。相反,要遵从某个已经建立起来的模式。你会发现这样做会事半功倍。

3.14 推荐阅读

    Bate, Rodney,Debugging with Assertions.C/C++ User Journal, Janualry 1992.

    一个对使用断言的很好的总结。对断言历史的描写尤为有趣。

    Pong, Earl.Being Assertive in C/C++.C/C++User Journal, June 1997

    一个对使用断言的好的精练的总结,包括对各种不同使用方法的小结。对防御性编程中的断言使用的论点尤为精彩。

    Maguire, Steve, Writing Solid Code: Microsoft's Techniques for Developing Bug-Free C Programs. Redmond, WA: Microsoft Press, 1993

    这本书的第2章给出于怎么使用断言的很好描述,还有很多实际的例子,大力推荐。

    Robbins,John.Bugslayer.Microsoft System Journal, February 1999.

    自定义SUPERASSERT宏显示了失效表达式、最后的错误值和一个调用栈。

    Rosenblum,BruceD.Improve Your Programming with Asserts.Dr. Dobb's Journal, December 1997.

    对使用断言的十条法则。帮助你创建更可靠的软件,还特别强调了断言不应该被使用的方式。

    Stout,JohnW.Front-End Bug Smashing in Visual C++ and MFCVisual C++ Developers Journal, November 1996

    对在Visual C++和MFC中如何使用断言的优秀总结。大力推荐。
原创粉丝点击