《Imperfect C++中文版》——1.4 断言

简介:

本节书摘来自异步社区出版社《Imperfect C++中文版》一书中的第1章,第1.4节,作者: 【美】Matthew Wilson,更多章节内容可以访问云栖社区“异步社区”公众号查看。

1.4 断言

Imperfect C++中文版
在我看来,断言并非一个良好的报错机制,因为它们通常在同一个软件的调试版和发行版中的行为有着极大的差异。虽说如此,断言仍然是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 */

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

1.4.1 获取消息

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

"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++会把指针隐式地解释(转换)为布尔值(见15.3节),所以我们可以借助于“字面字符串常量可被转换为非零指针并进而被转换为true”这个事实,把一则易于阅读的消息和断言的测试表达式进行逻辑与运算:

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

像这样使用它:

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));

这下我们所能得到的失败信息可就丰富多了。另外,因为那个字符串本身就是条件表达式的一部分,所以在发行版的构建中会被消去。也就是说,你可以随心所欲地给出任何附加的信息!

1.4.2 不恰当的断言

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

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

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

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

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

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具有低内存量的行为,否则你几乎不可能感受到内存异常的存在。2

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

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

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

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

1.4.3 语法以及64位指针

另一个问题4是有关在断言中使用指针的。在int是32位而指针是64位的环境中,如果把原生指针用在断言中的话,根据assert()宏的定义,将会导致一个截断警告:5

void *p = . . .;
assert(p); // 警告:截断
当```  
然,这也是我在17.2.1小节的话题之一,并且实际上也是引起我对有关布尔表达式的恼人问题“过敏”的原因。答案是在你的代码中明确地表达意图:

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

###1.4.4 避免使用verify()
不久前我和别人谈论关于自己的断言宏的定义问题,他们想把它命名为verify(),以避免跟标准库宏冲突。唉,这么做有两个问题:

首先,VERIFY()是MFC中一个广为人知的宏,其用途和assert()大致相同,不过它的条件式不会被消去,从而在任何场合下都会执行。它的定义如下:

ifdef NDEBUG

define verify(x) ((void)(x)) / x仍然存在/

elif / ? NDEBUG /

define verify(x) assert(x)

endif / NDEBUG /

如果把verify()宏的行为定义成跟assert()一样的话,那些习惯于已经建立的“verify”的行为的人们肯定会对此感到相当困惑:为什么在调试期还好端端的代码,到发行模式下就失败了呢?他们也许需要愣一阵子才能意识到verify宏在发行模式下不起作用,不过一旦回过神来,他们可能就会晃着扳手从停车场那边向你追过来。

第二个问题是,单词“assert”只可以为“断言”所用。这是因为你可以使用grep和类似的工具几乎无歧义地查找到你的断言,也是由于它对程序员而言是如此扎眼。当在代码中看到它们时,人们立刻就会意识到“某些不变式(见1.3.3小节)正被测试”。现在如果将断言冠以另外一个名字来使用的话,只会把原来相对清晰的概念搞成一团糊涂浆。

我以前曾经定义过自己的verify()宏,它的语义和MFC的VERIFY()相同,现在想起来觉得这是个危险的举动。交给断言的表达式必须没有任何副作用,而坚持这一点并不算太困难,我已经好几年没有犯这个错误了。不过,如果你混合使用了不同语义的断言宏,其中有些必须有副作用,而有些必须没有副作用,那么你很容易就会被搞混淆,并且非常难以建立一种稳定的习惯。现在我已经不再使用任何形式的verify()宏了,我建议你也要这样。

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

C和C++中的标准断言宏被称为assert()。在STLSoft库中,我有自己的stlsoft_assert()以及其他一些宏,所有这些都是小写的。在Synesis库中,断言宏被命名为SyAssert()。在我看来,这些做法都是不合适的。

根据惯例,所有的宏都应该是大写的,这是个非常好的习惯,因为这么一来,宏就可以跟函数和方法醒目地区分开来。尽管将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循环的条件表达式或三元条件操作符(? :)中,进而所有通常的隐式布尔转换(见13.4.2小节和第24章)都会参与进来。这和将表达式传给接受bool或int的函数有相当大的差别。

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

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

###1.4.6 避免使用#ifdef _DEBUG
在25.1.4小节我提到,由于性能上的原因,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的断言。

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

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

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

define _CrtDbgBreak() __asm { int 3 }

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

###1.4.8 静态/编译期断言
到目前为止我们只看过了运行期断言。然而,如果可能的话,最好还是在编译期就把错误抓住。在本书中的许多部分,我们都提到了静态断言,它也被称为编译期断言,因此现在正适合把它们详细地描述一下。

基本上,静态断言提供了一种编译期对表达式进行验证的机制。不消说,为了能够在编译期得到验证,表达式当然必须能够在编译期进行求值。这就缩小了可以被编译期断言所作用的表达式的范围。例如,你可以使用编译期断言来确保你对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,则编译便不能通过。考虑下面的例子:

define STATIC_ASSERT(x) int ar[x]

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

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

int ar[0];

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

很明显,上面的实现存在诸多问题。数组ar被声明了,却并没有被使用,这将会导致大部分的编译器给出一个警告,阻止你的构建(build)过程8。再者,在同一个作用域中使用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 /

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

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

define STATIC_ASSERT(ex) \

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

define STATIC_ASSERT(ex) \

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

为了改善这种混乱的状况,Andrei Alexandrescu在[Alex2001]中描述了一种技术,用于提供更好的错误信息,并且在现有语言的限制下竭尽所能发展这种技术。9

对于我自己而言,我倾向于避开那么做带来的复杂性,这将基于3个原因。第一,我比较懒,总是倾向于尽量避开复杂性10。第二,我写的C代码和C++代码一样多,我更喜欢对于这两种语言都有效的设施。

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

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

###1.4.9 断言:尾声
本节描述了断言的基础知识,当然还有更多断言可做的有趣事情,不过它们已经超出了本书的讨论范围。另外,还有两个相当不同但同样有用的技术,即SMART_ASSERT [Torj2003]和SUPER_ASSERT [Robb2003],我建议你去研读一下关于它们的资料。

1译者注:一种可在文件内进行字符串查找的工具。
2译者注:即几乎不可能遇到内存分配失败或内存不足的情况。
3译者注:作者这里讲的相当简略。具体的意思是:如果你在前面使用一个断言捕获了错误,那么即使下面又有运行期错误处理的代码,也将得不到调用(调试期)。也就是说,即便你在后面的错误处理的编码上出了错误,也会因为在调试时执行流被上面的断言“截断”而无法发现(错误的编码只要不被执行,当然也就不会露出马脚了)。然而这种错误到了发行版又会立即显出狰狞的面目,因为在发行版中断言会失效。总的来说,这就导致了两个问题:第一,你的编码错误在调试期被前面的断言掩盖了起来;第二,调试版和发行版的错误处理行为不一。这两点都可能会带来相当大的困惑。
4我知道,我把什么东西都一股脑儿塞到这一部分来了,不过我觉得这些东西值得你去了解。
5以前用Dec Alpha的时候我曾遇到过这个问题,并且,我在新闻组上看到有人在其他平台上有过类似的经验。
6译者注:Integrated Development and Debugging Environment,集成开发与调试环境。
7译者注:作者的意思大概是,没有落在调用DebugBreak()的用户代码上。
8你确实把警告级别设成“高(high)”,并把它们当成错误来对待了,对吗?
9你应该查看一下该技术,它非常迷人。
10我还认为你提供的技术越简单越好,但由于本书中有许多东西颇费脑细胞,所以我没法把这作为一个严格的理由,只是因为我懒而已。
本文仅用于学习和交流目的,不代表异步社区观点。非商业转载请注明作译者、出处,并保留本文的原始链接。
相关文章
|
机器学习/深度学习 IDE Ubuntu
《C++ Primer中文版(第5版)》学习笔记与习题完整发布!
《C++ Primer中文版(第5版)》学习笔记与习题完整发布!
453 0
《C++ Primer中文版(第5版)》学习笔记与习题完整发布!
|
C语言 C++ 容器
C++11 FAQ中文版--转
更新至英文版October 3, 2012 译者前言: 经过C++标准委员会的不懈努力,最新的ISO C++标准C++11,也即是原来的C++0x,已经正式发布了。让我们欢迎C++11! 今天获得Stroustrup先生的许可,开始翻译由他撰写和维护的C++11 FAQ。
1580 0
|
C++
C++ Primer中文版(第5版)
http://product.china-pub.com/3802148#ml
953 0
|
程序员 C++
【转】c++.primer.plus.第五版.中文版[下载]
c++.primer.plus.第五版.中文版[下载]一共有5部分。全部下载完才可解压阅读。c++.primer.plus.第五版.中文版(一)c++.primer.plus.第五版.中文版(二)c++.primer.plus.第五版.中文版(三)c++.primer.plus.第五版.中文版(四)c++.primer.plus.第五版.中文版(五)“在遇到无法解决的问题时,我总会求助于C++ Primer一书。
1389 0
存储 编译器 Linux
15 0