断言(C++大师Andrei Alexandrescu的文章)
断言
Andrei Alexandrescu
本部分“泛型<编程>”讨论断言(assertions),一个你兵器库内非常强大的工具。以assert为基础,我们建立一个更强大的工具,帮助你建立更好的程序。我们很快就能看到,断言不仅是简单的工具/宏/函数。这是一种生活方式,一道深深的鸿沟把程序员们分成两类:了解,和不了解断言的力量。
Assert(cool);
那么,断言到底包含了什么?为什么你要重视断言?你什么时候需要用到断言,并且同样重要的,什么时候你不想要用到断言。
我的看法是断言(例如用标准assert宏表示)是一个最最简单强大的工具来保证程序的正确性。断言的威力通常被低估了,至少在我参与的项目中是这样。可以这样说:一个项目的成功条件是开发人员如何有效地在代码中使用断言。
断言的一个重要特性是它只在调试方式中产生代码(当NDEBUG宏没有定义时),所以它某种意义上是“免费”的。这让你测试看上去非常明显的事实,本来检测这些是一种效率上的浪费,但在发布模式中效率上没有浪费,所以你在插入大量断言时心理上不会感到不舒服。
哲学家说事实胜过假想,事实比我们的头脑所能设想的更为复杂。这在软件开发中当然也适用。有着无数的例子,本来“绝对不可能触发”的断言事实上被触发了。历史一次次重演:“这个值当然是正的!那个指针明显不是空的!这个数组?我敢打赌毫无疑问是排序的。为什么要反复再核对呢?”如果你是新手,你认为你写些assert是在浪费你的时间,但它们的触发可以救你的命。这都是因为软件开发很复杂,并且在正在修改的程序中任何情况都有可能发生。断言检查你认为“明显为真”的地方实际上是为真。
一个断言的微妙方面是它越“笨”,当它被触发时所能带给你的信息就越多——并且它也就越有价值。这是因为根据信息的理论,事件中的信息随着事件发生概率的提高而减少。某个assert越不可能触发,当它触发时带给你的信息也就越多。比如,当调试一些不带断言的代码时,你可能首先检查更明显的失败可能原因,你会只在晚些时候(晚些,可能是“在排除所有可能解释后的半夜时分”)才考虑到那些“不可能发生”的条件。
所以什么时候使用assert,并且什么时候使用真正的指明一个错误的运行时检查?不管怎样,assert用来指出错误,但存在其他方法也能指出错误。你该如何决定哪种情况下使用何种错误报告方式呢?
有一种有效的测试手段来区分哪种场合你需要使用assert,什么时候需要使用真正的错误检查:你对可能发生的情况使用错误检查,即使这些情况不太可能发生。你只对你确信在任何情况下都不可能发生的情况使用assert。一个失败的断言总是指明一个设计上或程序员的错误——不是一个用户错误。
断言检测的情况理论上几乎总是可以在编译时检测。但仅仅是理论上。验证某些事情实际上是行不通的,比如不可接受的编译时间,缺乏源代码等等。,
另一方面,你不会用断言检查可能失败的函数返回值。你不会用assert来确保malloc工作正常[5],创建成功一个窗口,或者启动一个线程。但是你能够用assert来保证API如文档中所写的那样工作。比如,如果某个API函数的文档说它只返回一个正值,但由于某种原因你觉得它可能存在问题,你就需要写一条assert。
实现断言
标准提供的assert有着非常简单的实现,类似于下面:
- //来自于标准包含文件cassert或assert.h
- #ifdef NDEBUG
- void __assert(const char*, const char*, int);
- #define assert(e) ((e) ? (void)0 : __assert(#e, __FILE__, __LINE__))
- #else
- #define assert(unused) ((void)0)
- #endif
__assert辅助函数在标准错误输出流中显示一条错误信息并且通过调用abort()来中断程序。各种不同实现可能存在不同,比如,Microsoft Visual C++显示一个对话框窗口来让你有机会进入调试器查看源代码。你仍旧不能忽略断言然后继续执行原有操作——当你在调试器中继续操作时仍旧会调用abort()(有一点让人遗憾——你就象俄耳甫斯,在欧律狄刻被带回冥府前才看到她。但有方法克服这个情况——对Microsoft Visual C++用户而言而不是对俄耳甫斯[6])
用abort()终止程序过于粗鲁。很多情况下,你可能想要忽略一个特殊断言,因为你觉得它是无害的。而且某些操作系统和调试器允许你进入你的源代码来追踪发生的问题,同样在这种情况下你不需要abort(),相反你需要有选择是否继续跟踪程序代码的自由。
这也是为什么通常建议是你写自己的断言机制。这样做之前,你要先查看你的编译器文档,看看怎样进入调试器。比如,在x86处理器上运行的Microsoft Visual C++的魔咒是__asm{ int 3 }。我们可以用一条小小的宏来表示中断点:
#define BREAK_HERE __asm { int 3 }
现在我们可以这样实现断言机制:
- inline void Assert (const char* e, const char * file, int line)
- {
- switch (AskUser(e, file, line))
- {
- case giveUp:
- abort ();
- case debug:
- BREAK_HERE;
- break;
- case ignore:
- //什么也不干
- break;
- }
- }
- #define ASSERT(e) ((e) ? (void)0 : Assert(#e, __FILE__, __LINE__))
你还必须定义AskUser函数,它使用某种I/O来询问用户程序需要采取何种行动。一个更好的断言机制还会提供在程序终止,调试或忽略前记录日志的选择。
BREAK_HERE的一个问题是它经常在被调用的精确位置中断进入调试器。但你需要的代码中断位置是你调用ASSERT的位置,而不是AskUser定义内部。这要求你在ASSERT宏内部插入BREAK_HERE,,而不是在ASSERT宏调用的函数内部[7]。
表达式还是语句
这给我们带来一个问题:直到目前,上面定义的ASSERT是个表达式。但是,如果你要ASSERT调用BREAK_HERE,你需要把ASSERT转成一条语句。这是因为BREAK_HERE在你系统中可能是一条语句。
表达式宏总是比语句宏更灵活,因为你可以在更多场合使用表达式,并且你可以通过在后面跟一个分号来把表达式转换为语句。但是,语句宏让你定义宏时更灵活(也需要更仔细)。
一个ASSERT转换成一条语句后就是这样:
- #define ASSERT(e) do/
- if (!(e)) switch (AskUser(#e, __FILE__, __LINE__))/
- {/
- case giveUp: /
- abort ();/
- case debug:/
- BREAK_HERE;/
- break;/
- }/
- } while (false)
表面无用的“do/while”结构是个句法技巧,它的目的是要求程序员在ASSERT后面紧跟一个分号,这样不至于引起混淆。
在发行模式下,你可以这样定义ASSERT:
#define ASSERT(unused) do {} while (false)
它虽然什么也不做,但很巧妙。
断言与意外
当断言失败时经常需要抛出意外。首先,在意外不会“自然”发生的地方你也可以很好地测试你的代码,这样,使用断言在“beta”版本里更有意义。在这个情况下,你需要检查设计问题,但你的用户没有中断进入调试器的选择。在这种情况下,抛出意外会更自然些。
从语法角度看,我们希望象这样来使用ASSERT:
ASSERT (A != B); //如前
ASSERT<std::runtime_error>(!Santa.bag.empty(),
“Looks like Santa’s bag is empty – and it surely shouldn’t!”);
理想情况是,对ASSERT的两次使用用同一个名称,这样就不会再污染已经被塞满了的宏名称空间。第二个版本会抛出一个包含文件名和行数的意外,再加上一些有用的程序员自定义错误信息。
我们在重新定义ASSERT时有两个问题。一个是ASSERT必须支持模板语法和普通的非模板语法。另一个问题是ASSERT必须支持一个或二个参数。如果你写过宏,你肯定知道这些问题超出宏的能力范围[8]。
但为什么一定要用宏实现ASSERT?我们这样做是因为在NDEBUG模式下必须不做任何处理。如上所述,程序员把ASSERT看作是无代价的调试工具非常重要。在目前的编译器技术下,即使有积极内联,也只有宏能够保证一个表达式不会被执行,就象它本来就是没有附带作用而且毫无意义。
一个想法是让ASSERT成为展开为函数的宏,这样我们可以使用模板参数和重载:
void Assert(bool expression);
template <class E> void Assert(bool expression, const char* message = “”);
#define ASSERT Assert
现在ASSERT(a!= b)和ASSERT<std::runtime_error>(a != b)都工作得很好。然而往往的,事情不象看上去那么简单。现在我们失去了三个重要信息:被检测的表达式的文本表达形式,当前文件名,和当前行数。某些版本信息可能也很有用,这样可能要用到其他三个标准预定义宏,分别是__DATE__, __TIME__,和__TIMESTAMP__。非标准但日益流行的扩展宏__FUNCTION__和__PRETTY_FUNCTION__也会很有用。
但我们怎样让ASSERT委托给一个函数呢?我们需要在调用函数前嵌入一些代码,这些代码会在函数被调用后执行,一个有趣的解决方案是定义一个类在ASSERT宏内:
- #define ASSERT/
- struct Local {/
- Local (const Assert& info)/
- {/
- if (!info.Handle(__FILE__, __LINE__))/
- BREAK_HERE;/
- }/
- | localAsserter = Asserter::Make
恩?这些都是什么意思?其实没什么大不了,但我必须说这种代码内的一些细节需要注意。这样,这些代码依赖于定义在别处的Asserter定义,并且创建了一个局部结构,叫做,恩,Local(没创意)。现在你写:
ASSERT (A != B);
代码扩充为:
- struct Local {
- Local (const Asserter& info)
- {
- if (!info.Handle(__FILE__, __LINE__))
- BREAK_HERE;
- }
- } localAsserter = Asserter::Make(a != b);
如果你象我一样,你会为这里的句法和语法技巧而兴奋起来:我们为了让工作有序进行,在宏内包含(__FILE__, __LINE__,特别是BREAK_HERE)这些宏,把其他代码放在宏外。如果你现在兴奋了,那太好了,可能在后面几节你会更兴奋,如果你没有,谁知道,也许你也会变得兴奋起来。
现在你需要做的是定义一个Asserter类:
- class Asserter
- {
- ptotected:
- const bool holds_;
- public:
- Asserter (bool holds) : holds_(holds)
- {
- }
- virtual bool Handle(const char* file, int line) const
- {
- if (holds_) return true;
- switch (AskUser(file, line))
- {
- case giveUp:
- abort();
- case ignore:
- return true;
- }
- return false;
- }
- static Asserter Make(bool flag)
- {
- return Asserter(flag);
- }
- };
这样整个设计就象这样:当ASSERT(A != B)被调用,调用Asserter::Make(a != b) 创建和初始化类型为Local的对象,该函数传入布尔条件到Asserter的构造函数并且传入这个Asserter对象到Local的构造函数。
Local的构造函数对Asserter::Handle(__FILE__, __LINE__)求值。如果返回的是true,Local已经考虑到了问题得到处理和什么也不做的情况。否则,Local中断进入你的调试器。这就是全部!
有漏洞吗?没有吗?
机敏的读者可能已经注意到上面定义ASSERT中一个致命缺点。考虑下面语句:
ASSERT(a != b);
ASSERT(c != d);
确实,当宏展开后,你会看到一些关于重复符号的编译时错误。实际上,Local和localAsserter在同一个作用域被定义了两次!哎哟!怎么办?
这个问题我尝试了两个解决办法。一个是麻烦的,复杂的并且无法在所有编译器上都行得通的。另一个是简单的,漂亮的,并且在所有编译器上都可以无问题的运行。
做个选择并不困难,但为了丰富知识,值得都说一下。
糟糕的解决办法是依靠__LINE__和某种复杂的预处理技巧对程序的每一行产生一个唯一标识,这我自己也从来不能100%理解。我不会用细节来烦你。在Microsoft Visual C++中这个技巧行不通,而且似乎将来也不行。基于此,他们定义了另一个宏__COUNTER__,通过它你可以使用这个讨厌的技巧。
即使付出所有这些努力,当你在同一行调用ASSERT两次,产生唯一标识的技术也会失败:
ASSERT(a != b); ASSERT(c != d); //错误!标识符重定义!
好吧,到次为止。
好的解决方法是...就在这里。我不会给你带来什么意外。把它收集到你的宏技巧库里吧
- #define ASSERT/
- if (false) ; else struct Local/
- {/
- Local (const Asserter& info)/
- {/
- if (!info.Handle(__FILE__, __LINE__))/
- BREAK_HERE;/
- }/
- } localAsserter = Asserter::Make
现在轮到模板了
(请注意:从此刻起请你紧系安全带。)
好,现在怎么处理许诺过的模板架构:
ASSERT<std::runtime_error>(!Santa.bag.empty(),
“Looks like Santa’s bag is empty – and it surely shouldn’t!”);
没问题。所有我们要做的是从Asserter继承一个参数化类,如下:
- template <class E>
- class AsserterEx : public Asserter
- {
- const char* const msg_;
- public:
- AsserterEx(bool holds, const char* msg)
- : Asserter(holds), msg_(msg)
- {
- }
- virtual bool Handle(const char* file, int line) const
- {
- const Action action = AskUser(file, line, msg_);
- if (action == throwUp) throw E(msg_);
- return Asserter::Handle(file, line);
- }
- }
这样AsserterEx<E>::Handle要求用户做出决定,要么抛出一个意外要么让Asserter::Handle做出处理。
接下去,我们在Asserter中增加一个模板函数Make:
- class Asserter
- {
- …同前…
- template <class E> static AsserterEx<E> Make(bool flag, const char* msg)
- {
- return AsserterEx<E>(flag. Msg);
- }
- }
大功告成——现在有Asserter::Maker的两个版本。一个是我们刚刚说的对应于ASSERT<std::runtime_error>(a != b, “Something weird is going on, Watson”)。
好的,一切都很美好并且…嗨,等等。没看到有动态分配,那Handle怎么是virtual的?Asserter是多态语义还是纯值语义?
多么复杂的细节!我们来复习一下Local通过什么过程创建。Local的构造函数接受一个const Asserter引用,并且模板函数Asserter::Make返回一个从Asserter继承的对象的值。该对象被直接绑定到Local的构造函数所接受的引用而不会被切割(slicing)。在上述的构造函数中,引用的行为完全是多态的,所以不存在bug。
那些读过Petru Marginean和我写的关于ScopeGuard的文章[9]的牛人们可能注意到这里和那里用到了相似技术来完成“隐性多态”,而不用多态分配。
最后,在发布模式下如何定义ASSERT?有许多答案。看下面:
- #define ASSERT/
- if (true) ; else struct Local/
- {/
- Local (const Asserter& info)/
- {/
- if (!info.Handle(__FILE__, __LINE__))/
- BREAK_HERE;/
- }/
- }/
- } LocalAsserter = Asserter::Make
所有代码都在,只不过会被if(true)检测语句跳过。现在的编译器能够轻易消除在没用到的else子句中的冗余代码。
附加功能
经验证明有两个功能是非常有用的。
有时候你会发现一个断言是良性的,并且为了继续执行余下程序你想要禁止这个断言。如果你在构造函数中保存一个静态布尔变量,你就可以轻松达到目的:
- #define ASSERT/
- if (false) ; else struct Local/
- {/
- Local (const Asserter& info)/
- {/
- static bool ignore;
- if (!ignore && !info.Handle(__FILE__, __LINE__, ignore))/
- BREAK_HERE;/
- }/
- | localAsserter = Asserter::Make
如果用户选择“为了继续执行忽略本行断言”Asserter::Handle会把ignore设为true,而且不会再来烦你——非常有用。
另一个酷特性是在一个程序执行过程中可以忽略所有断言。这可以通过在Asserter类中保存一个静态布尔成员并且操作它来轻松达到目的。
我们能够进一步支持更多有用功能,比如传入更多宏(__DATE__, __TIMESTAMP__, __FUNCTION__, 等等),但请注意要点而不被细节迷惑。
你可以参看附带源代码,代码中包含一个ASSERT的功能版本,是基于Microsoft Visual C++ Everett Beta的。
结论
断言很重要,也很简单。很不幸的是一些人认为因为它太简单所以不重要。坚持在你代码中尽量使用断言,它是警惕而又可靠的卫兵,保护你(和你的程序)不会陷入混乱。
本文介绍了一个断言机制,它支持直接中断进入调试器(决定于你的编译器和OS是否支持该特性),重载,意外抛出,在一个过程中断言的单行忽略或全部忽略。
ASSERT把所有工作集中于宏技巧,模板代码,和多态函数中,从而得到一个有用的断言小工具。
致谢
前面ASSERT实现中的许多想法都直接来源于在4月份ACCU大会上与Jason Shirk(以”The Visual C++ 7.1 compiler is so cool!”一文而出名)的讨论。
参考书目和注释
[1] Andrei Alexandrescu. "Generic<Programming>: Move Constructors," C/C++ Users Journal C++ Experts Forum, February 2003, <www.cuj.com/experts/2102/alexandr.htm>.
[2] <www.moderncppdesign.com/mojo/old/>
[3] Usenet posting by Rani Sharoni, <http://groups.google.com/groups?dq=&hl=en&lr=&ie=UTF-8&safe=off&selm=df893da6.0301021305.d8f76af%40posting.google.com>.
[4] Standard C++ Defect Report,<http://anubis.dkuug.dk/jtc1/sc22/wg21/docs/cwg_active.html#291>.
[5] 顺及,我最近在我的256M桌上型电脑上分配内存失败,而且是在正常使用中。虽然启动了许多应用程序,但都很正常——这证实了一个事实:内存耗尽会在真实世界中发生。
[6] 如果你在调试时右击编辑器,选择“Set Next Statement ”,你可以用“go on”来越过assert,从而继续操作。
[7]问题是内联Assert没用:多数调试器可进入内联函数。
[8]需要注意的是GNU C++(gcc)有非标准扩展,允许宏有可变数量变量。
[9] Andrei Alexandrescu and Petru Marginean. "Generic<Programming>: Simplify Your Exception-Safe Code," C/C++ Users Journal C++ Experts Forum, December 1999,<www.cuj.com/experts/1812/alexandr.htm>.
Andrei Alexandrescu 是位于西雅图的华盛顿大学的博士,也是受到好评的《Modern C++ Design》一书的作者。可以通过www.moderncppdesign.com. 来联系他。Andrei同时也是C++研讨会 (<www.gotw.ca/cpp_seminar>).的一名有号召力的讲师。