本节书摘来自异步社区出版社《Imperfect C++中文版》一书中的第1章,第1.4节,作者: 【美】Matthew Wilson,更多章节内容可以访问云栖社区“异步社区”公众号查看。
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 */因为断言里的表达式在发行版的构建中会被消去,所以确保该表达式没有任何副作用是非常重要的。如果不遵守这个规矩的话,你往往会遇到一些诡异而令人恼火的情况:为什么调试版可以工作而发行版却不能呢?
断言所采取的行动可能五花八门。然而,大多数断言实现都使用了其条件表达式的字符串形式。这种做法本身没什么不对,不过可能会令可怜的测试者(可能就是你)陷入迷惘,因为你所能得到的全部信息可能简洁得像这样:
"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));这下我们所能得到的失败信息可就丰富多了。另外,因为那个字符串本身就是条件表达式的一部分,所以在发行版的构建中会被消去。也就是说,你可以随心所欲地给出任何附加的信息!
断言对于调试版构建中的不变式检查是有用的。只要你谨记这一点,你就不会错得太离谱。
唉,我们看到太多把断言误用在运行期查错中的情形了。一个典型的例子是把它用在检查内存分配失败中(这可能会出现在大学一年级的程序查错材料中):
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
在我看来,将断言应用到运行期的失败条件式身上,即便后面跟着发行模式下的处理代码,也最多只会分散注意力而已,说得严重一点,这是错误的编程实践。不要那样做!
建议: 使用断言来断言关于代码结构的条件式,而不要断言关于运行期行为的条件式。
另一个问题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()大致相同,不过它的条件式不会被消去,从而在任何场合下都会执行。它的定义如下:// 假定只被C++编译器所用
extern "C" void assert(bool expression);
inline void assert(bool ){}
switch(type){ . . .
default:
assert(0); break;}
这说明,即使是最具经验的程序员也容易成为身处开发环境的牺牲品。这段代码有几个小错误。首先,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才会导致编译器进入发行模式,并且将断言消去。显然,正确的途径是使用一个编译器无关的抽象符号来控制构建模式,例如:default:
assert(0); break;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,则编译便不能通过。考虑下面的例子:. . .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()定义成这样: