现在的位置: 首页 > 自动控制 > 工业·编程 > 正文

新手必看:断言最佳实践

2013-12-25 23:10 工业·编程 ⁄ 共 8800字 ⁄ 字号 暂无评论

在我看来,断言并非一个良好的报错机制,因为它们通常在同一个软件的调试版和发行版中的行为有着极大的差异。虽说如此,断言仍然是C++程序员确保软件质量的最重要的工具之一,特别是考虑到它被使用的程度和约束、不变式一样广泛。任何关于报错机制的文档如果没有提到断言的话肯定不能算是完美的。

    基本上,断言是一种运行期测试,通常仅被用于调试版或测试版的构建,其形式往往像这样:

#ifdef NDEBUG
# define assert(x)  ((void)(0))
#elif /* ? NDEBUG */
extern "C" void assert_function(char const *expression);
# define assert(x)  ((!x) ? assert_function(#x) : ((void)0))
#endif /* NDEBUG */

断言被用于客户代码中侦测任何你认为绝不会发生的事情(或者说,任何你认为永远不会为真的条件式):

class buffer
{
  . . .
  void method1()
  {
    assert((NULL != m_p) == (0 != m_size));
    . . .
  }
private:
  void    *m_p;
  size_t  m_size;
};

buffer类中的断言表明类的作者的设计假定:如果m_size不是0,那么m_p也一定不是NULL,反之亦然。

    当断言的条件式被评估为false时,断言就称为被“触发”了。这时候,或者程序退出,或者进程遇到一个系统相关的断点异常,如果你处于图形界面操作环境中的话,你往往还会看到弹出了一个消息框。

    不管断言是如何被触发的,将失败的条件表达式显示出来总是很好的,并且,既然断言大部分时候是针对软件开发者而言的,那么最好还要显示它们的“出事地点”,即所在的文件和行号。大多数断言宏(assertion macros)都提供这个能力:

#ifdef NDEBUG
# define assert(x)  ((void)(0))
#elif /* ? NDEBUG */
extern "C" void assert_function( char const *expression
                               , char const *file
                               , int        line);
# define assert(x)  ((!x)
                      ? assert_function(#x, __FILE__, __LINE__)
                      : ((void)0))
#endif /* NDEBUG */

  因为断言里的表达式在发行版的构建中会被消去,所以确保该表达式没有任何副作用是非常重要的。如果不遵守这个规矩的话,你往往会遇到一些诡异而令人恼火的情况:为什么调试版可以工作而发行版却不能呢?

一、显示消息!

    断言所采取的行动可能五花八门。然而,大多数断言实现都使用了其条件表达式的字符串形式。这种做法本身没什么不对,不过可能会令可怜的测试者(可能就是你)陷入迷惘,因为你所能得到的全部信息可能简洁得像这样:

"assertion failed in file stuff.h, line 293: (NULL != m_p) == (0 != m_size));"

不过,我们还是可以借助这个简单的机制使我们的断言信息变得更丰富一些。例如,你可能会在switch语句的某个永远不可能到达的case分支中使用断言,这时候你可以通过使用一个值为0的具名常量来显著改善断言的信息,像这样:

switch(. . .)
{
  . . .
  case CantHappen:
   {
      const int AcmeApi_experienced_CantHappen_condition = 0;
      assert(AcmeApi_experienced_CantHappen_condition);
      . . .

现在当这个断言被触发时,它所给出的信息要比下面所示的更具有描述性:

"assertion failed in file acmeapi.cpp, line 101: 0"

还有一个办法可以用来提供更丰富的信息,同时还可以免除前一种方法中的变量名称中有大量下划线的不爽。因为C/C++会把指针隐式地解释(转换)为布尔值,所以我们可以借助于“字面字符串常量可被转换为非零指针并进而被转换为true”这个事实,把一则易于阅读的信息和断言的测试表达式进行逻辑与运算:

#define MESSAGE_ASSERT(m, e)  assert((m && e))

像这样使用它:

[cpp] view plaincopy
MESSAGE_ASSERT("Inconsistency in internal storage. Pointer should be null when size is 0, or non-null when size is non-0", (NULL != m_p) == (0 != m_size)); 
这下我们所能得到的失败信息可就丰富多了。另外,因为那个字符串本身就是条件表达式的一部分,所以在发行版的构建中会被消去。也就是说,你可以随心所欲地给出任何附加的信息!

二、不恰当的断言
    断言对于调试版构建中的不变式检查是有用的。只要你谨记这一点,你就不会错得太离谱。

唉,我们看到太多把断言误用在运行期查错中的情形了(错误的实践)。一个典型的例子是把它用在检查内存分配失败中(这可能会出现在大学一年级的程序查错材料中):

[cpp] view plaincopy
char *my_strdup(char const *s) 

  char *s_copy = (char*)malloc(1 + strlen(s)); 
  assert(NULL != s_copy); 
  return strcpy(s_copy, s); 

你可能会觉得没有人会这么干。如果你是这么认为的,我建议你使用grep(一种可在文件内进行字符串查找的工具)去你最喜爱的一些库里查一查,其中你会看到用断言检查内存分配失败、文件句柄以及其他运行期错误的代码。

这么做错也就错了罢,不幸的是中间偏偏还有个“半吊子”,也就是说,有不少人会将断言和正确处理错误情况的代码放在一起使用:
char *my_strdup(char const *s) 

  char *s_copy = (char*)malloc(1 + strlen(s)); 
  assert(NULL != s_copy); 
  return (NULL == s_copy) ? NULL : strcpy(s_copy, s); 

    我实在无法理解这种做法!考虑到大部分人都是在拥有虚拟内存系统的桌面硬件上做开发的,在这种环境下调试,除非你被限制在一个低内存量的配置机制下,或者你被规定运行时库的调试API具有低内存量的行为,否则你几乎不可能感受到内存异常的存在(即几乎不可能遇到内存分配失败或内存不足的情况)。

    不过,若是把“内存分配”换成其他更容易“闯祸”的举动,则事情会看得更明白一些。例如,若用于文件句柄,这就是在提醒你:把你的测试文件放到正确的地方,而不要试图把错误反馈功能武装到坚不可摧。几乎可以肯定地说,错误反馈能力到了实际部署中总会不够用。

    还有,如果问题在于一个运行期的失败条件,那么为什么你要在一个断言中捕获它呢?如果你在下面的运行期错误处理的编码上出了错误,难道你不想让程序崩溃掉从而更能体现发行模式的真实行为吗?退一步说,即便你手中握着一个“超级智能”的断言(参考徐射雕编写的《C++模板在契约式编程的应用》一文),这仍然只会为你自己以及评审你的小组的人树立一个糟糕的例子。

    如果你在前面使用一个断言捕获了错误,那么即使下面又有运行期错误处理的代码,也将得不到调用(调试期)。也就是说,即便你在后面的错误处理的编码上出了错误,也会因为在调试时执行流被上面的断言“截断”而无法发现(错误的编码只要不被执行当然也就不会露出马脚了)。然而这种错误到了发行版又会立即显出狰狞的面目,因为在发行版中断言会失效。总的来说,这就导致了两个问题:第一,你的编码错误在调试期被前面的断言掩盖了起来;第二,调试版和发行版的错误处理行为不一。这两点都可能会带来相当大的困惑。

在我看来,将断言应用到运行期的失败条件式身上,即便后面跟着发行模式下的处理代码,也最多只会分散注意力而已,说得严重一点,这是错误的编程实践。不要那样做!

建议:使用断言来断言关于代码结构的条件式,而不要断言关于运行期行为的条件式。

三、64位指针的语法问题
    另一个问题是有关在断言中使用指针的。在int是32位而指针是64位的环境中(我在Unbutu64位机器就是这种情况),如果把原生指针用在断言中的话,根据assert()宏的定义,将会导致一个截断警告:

void *p = . . .;

assert(p); // 警告:截断

当然,这也是以后要大谈特谈的话题之一,并且实际上也是引起我们对有关布尔表达式的恼人问题“过敏”的原因(参考徐射雕编写《智能指针最佳实践》里面关于智能指针里面的实际指针判空的讨论)。答案是在你的代码中明确地表达意图:

void *p = . . .;

assert(NULL != p); // 现在漂亮多了!

四、为你的断言命名
    既然命名问题在上一节被提了出来,那么让我们现在就来解决它。正如我曾提到的,一个断言宏首先应该包含单词“assert”。我见过或用过的就有:_ASSERTE()、ASSERT()、ATLASSERT()、AuAssert()、stlsoft_assert()、SyAssert(),等等。

C和C++中的标准断言宏被称为assert()。在不同的C和C++库中也许都含有它们独特的断言宏,例如:Webkit、boot、stl等。

根据惯例,所有的宏都应该是大写的,这是个非常好的习惯,因为这么一来,宏就可以跟函数和方法醒目地区分开来。尽管将assert()写成一个函数是完全可行的:
// 假定只被C++编译器所用 
#ifdef ACMELIB_ASSERT_IS_ACTIVE 
extern "C" void assert(bool expression); 
#else /* ? ACMELIB_ASSERT_IS_ACTIVE */ 
inline void assert(bool ){} 
#endif /* ACMELIB_ASSERT_IS_ACTIVE */ 
之所以不考虑这么做,是因为它不具有目前的断言宏的许多优点。首先,如果这么做的话编译器就无法将断言表达式优化掉。好吧,严格一点说,在某些情况下这种优化还是可能的,不过优化不能完全进行,即便编译器有最好的优化能力也不行。不管编译器和项目之间存在什么精确的调整,原则上这总是会带来大量的垃圾代码。

另一个问题是某些类型不能被隐式地转换为bool或int,或转换为被你选作表达式类型的类型。因为标准的assert()宏可能会把接受到的表达式放到if/while语句或for循环的条件表达式或三元条件操作符(? :)中,进而所有通常的隐式布尔转换都会参与进来。这和将表达式传给接受bool或int的函数有相当大的差别。

最后一个原因是:如果将assert()实现为函数,那么就无法将表达式作为断言的错误信息的一部分于运行期显示出来,这是因为“字符串化”能力是预处理器的重要能力之一,而不是C++(或C)语言的。

目前断言是以宏的形式存在的,并且可能一直以这种形式存在下去。所以,它们应该是大写的。这并不仅仅因为那是个一致的编码标准,也是因为大写更醒目,从而可以使我们的编码生活轻松一些。

五、避免使用#ifdef _DEBUG
     某些情况下由于性能上的原因,default条件被排除在switch语句之外(呵呵,这个一定有很多争议),而使用一个断言来代替它的位置。有些人会认为代码应尽可能的简单,如下:
switch(type) 

  . . . 
#ifdef _DEBUG 
  default: 
    assert(0); 
    break; 
#endif // _DEBUG 

    这说明,即使是最具经验的程序员也容易成为身处的开发环境的牺牲品。这段代码有几个小错误。首先,assert(0)所给出的错误信息可能会相当贫乏,这取决于编译器对断言的支持。这个问题容易解决:
. . . 
default: 
  {  
      const int unrecognized_switch_case = 0; 
      assert(unrecognized_switch_case);  
  } 
. . . 

不过,在大多数编译器中,这跟最初的冗长形式相比信息量仍然不够:
assert( type == cstring || type == single  
         || type == concat || type == seed); 
    使用_DEBUG的最主要问题还在于,它并非指示编译器去生成断言的明确符号。首先,根据我的经验,_DEBUG只在PC编译器上流行。对于许多编译器而言,调试模式是缺省的构建(build)形式,只有定义了NDEBUG才会导致编译器进入发行模式,并且将断言消去。显然,正确的途径是使用一个编译器无关的抽象符号来控制构建模式,例如:
#ifdef ACMELIB_BUILD_IS_DEBUG 
  default: 
    assert(0); 
    break; 
#endif // ACMELIB_BUILD_IS_DEBUG 
    然而即便是这些也不能算是问题的全部。通常,在产品的预发布版中保留一些调试功能是完全合理的。因此,你可能需要使用自己的、独立于_DEBUG、NDEBUG甚至ACMELIB_BUILD_IS_DEBUG的断言。

六、DebugAssert ()和int 3
    尽管这是特定于Win32+Intel架构上的东西,不过还是值得注意的,因为它非常有用,并且让人吃惊地鲜为人知。Win32 API函数DebugBreak()会在调用它的进程中引发一个断点异常。这种能力允许一个独立的进程被调试,或者使你的IDDE(Integrated Development  and Debugging Environment,集成开发与调试环境)中的当前调试进程暂停运行,从而允许你去查看调用栈(call stack),或体验其他调试方面的迷人功能。

在Intel架构上,该函数只是简单地执行“int 3”机器指令,这在Intel处理器上会引发一个断点异常。

一个小小的遗憾是,当控制流程转到调试器时,执行点落在DebugBreak()内部,而不是友好地落在引发异常的代码上(没有落在调用DebugBreak()的用户代码上)。解决这个问题的一个简单方式是在Intel架构下采用内嵌汇编。Visual C++运行时库提供了_CrtDebugBreak()函数作为它的调试基础设施的一部分,该函数在Intel架构中定义如下:

#define _CrtDbgBreak() __asm { int 3 }

使用“int 3”指令意味着调试器会精确地停在它被需要的点上,也就是说,精确地停在“肇事”的那行代码上。

七、静态/编译期断言
    到目前为止我们只看过了运行期断言。然而,如果可能的话,最好还是在编译期就把错误抓住。在本书中的许多部分,我们都提到了静态断言,它也被称为编译期断言,因此现在正适合把它们详细地描述一下。(要是你想了解C++在静态代码处理上的能力的话,请参考: http://blog.csdn.net/shediaoxu/article/details/8349942  看到里面的情况千万不要惊讶,那里只是简单的体现了C++编译器的一小部分功能,尽情的让编译器当我们的奴隶吧!)

    基本上,静态断言提供了一个编译期对表达式进行验证的机制。不消说,为了能够在编译期得到验证,表达式当然必须能够在编译期进行求值。这就缩小了可以被编译期断言所作用的表达式的范围。例如,你可以使用编译期断言来确保你对int和long的大小的期望对于当前编译器而言是正确的:

STATIC_ASSERT(sizeof(int) == sizeof(long));

不过要注意,它们不能对运行期表达式进行求值:

. . .

Thing::operator [](index_type n)

{

  STATIC_ASSERT(n <= size()); // 编译错误!

  . . .

    触发静态断言的结果是无法通过编译。由于静态断言跟大多数C++(和C)的现代特性一样,本身并不是语言特性,而是其他语言特性的某种副作用,所以错误信息的含义不会那么明显直白。我们马上就会看到它们会怪异到什么地步。

     通常,静态断言的机制是定义一个数组,并将表达式的布尔结果作为数组大小。由于在C/C++中,true可以被转换成整数1,false则转换为0,因此表达式的结果可被用于定义一个大小为1或0的数组,而大小为0的数组在C/C++中是不合法的。因此,如果表达式的布尔结果为false,则编译便不能通过。(以上方法在Webkit代码里面是不是非常常见呀!)考虑下面的例子:

#define STATIC_ASSERT(x)   int ar[x]

. . .

STATIC_ASSERT(sizeof(int) < sizeof(short));

int的大小永远不会小于short,因此表达式sizeof(int) < sizeof(short)的评估结果恒为0。从而,上面的那行STATIC_ASSERT()被求值为:

int ar[0];

而这在C/C++中是非法的。

     很明显,上面的实现存在诸多问题。数组ar被声明了,却并没有被使用,这将会导致大部分的编译器给出一个警告,阻止你的构建(build)过程。再者,在同一个作用域中使用STATIC_ASSERT()两次或两次以上将会导致ar被重复定义的错误。

为了解决这些问题,我把STATIC_ASSERT()定义成这样:

#define STATIC_ASSERT(ex)  \

          do { typedef int ai[(ex) ? 1 : 0]; } while(0)

这在大多数编译器中都工作得不错。然而,有些编译器对大小为0的数组却姑息纵容,因此需要一些条件编译来处理这些情况:

#if defined(ACMELIB_COMPILER_IS_GCC) || \

    defined(ACMELIB_COMPILER_IS_INTEL)

# define STATIC_ASSERT(ex)  \

          do { typedef int ai[(ex) ? 1 : -1]; } while(0)

#else /* ? compiler */

# define STATIC_ASSERT(ex)  \

          do { typedef int ai[(ex) ? 1 : 0]; } while(0)

#endif /* compiler */

无效数组大小并非实现静态断言的唯一途径。我知道还有另外两种有趣的机制,尽管这里我并没有使用它们。

第一个机制依赖于这么一个事实:switch语句的每个case子句都必须对应于不同的值:

#define STATIC_ASSERT(ex) \

    switch(0) { case 0: case ex:; }

第二个机制则依赖于“位域(bitfield)必须具有非零长度”的事实:

#define STATIC_ASSERT(ex) \

    struct x { unsigned int v : ex; }

    以上三种形式的静态断言在触发时给出的错误信息都很让人费解。你会看到诸如“case label value has already appeared in this switch”或“the size of an array must be greater than zero”之类的信息,因此处于嵌套模板的情形时,你得花上一会儿才能弄明白是哪儿出了问题。

    为了改善这种混乱的状况,Andrei Alexandrescu在中描述了一种技术,用于提供更好的错误信息,并且在现有语言的限制下竭尽所能发展这种技术。(你应该查看一下该技术,它非常迷人。)

    对于我自己而言,我倾向于避开那么做带来的复杂性,这是基于三个原因。第一,我比较懒,总是倾向于尽量避开复杂性(我还认为你提供的技术越简单越好,但由于本书中有许多东西颇费脑细胞,所以我没法把这作为一个严格的理由,只是因为我懒而已)。第二,我写的C代码和C++代码一样多,我更喜欢对于这两种语言都有效的设施。

    最后,静态断言是由于在编码时误用了某个组件而触发的。这就意味着它们不但少见,而且局限于导致它们被触发的程序员圈子内。因此,我认为开发者只需少量的时间就可以找到错误之所在(虽然不一定花上少量的时间就可以找到解决的办法),他们很少会介意这一丁点儿代价。

在我们结束这个条款之前,还有一件值得注意的事情,那就是:在实现静态断言时,“无效数组大小”和“位域”技术具有一个“switch”技术所不具备的优点:前二者可以被用在函数之外,而后者则不能(运行期断言同样不能)。

八、尾声
    本文描述了断言的基础知识,当然还有更多断言可做的有趣事情,不过它们已经超出了本文的讨论范围。这里很多技术不是语言特性,而是语言错误的副作用产生的,这就到了玩C++玩到技巧来得地步了,更多好玩有用的C++知识可以参考 http://blog.csdn.net/shediaoxu 。本文摘抄了Matthew Wilson的Imperfect C++里面的部分章节。在这里非常感谢Matthew Wilson给我带来的全面的契约式编程的认识。

给我留言

留言无头像?