Enhanced Assertions
--By Andrei Alexandrescu and John Torjo
刘未鹏(pongba)译
--这篇与John Torjo合著的文章描述了一个特性完备的,工业强度的assertion设施。这个工具包的特性包括多重debug级别,日志记录,还有一个搜集详细状态信息的方法。
好吧,我承认:我正在体验"Writer's block"。材料都在这儿,很酷。我享用了我最喜爱的早餐(自制的牛奶什锦早餐,我的私人配方:50%燕麦片,50%坚果,50%葡萄干--试着干掉它们),我所信赖的open source编辑器正在动人的闪烁着(我知道你在想什么:“动人的”?滑稽透顶),然而,我无法想出一个好的介绍性的开头。为了缓解我的焦虑,亲爱的读者,原谅我在文章的开始就用这些滑稽的话来令你烦恼。然而这是一个meta-programming专栏,不是么?再加上你是个C++程序员,所以你几乎不会介意一些额外的语法。
既然我们已将第一段的问题放至一边,请允许我向你介绍我的朋友John Torjo,C++专家,顾问,"Practical C++"的专栏作家(http://buider.com/),我们长期由e-mail来往。John和我合著了这篇文章,描述了经John的改善过的assertion framework。
John在读过我的关于assertions的文章[1](其大部分是Jason Shirk和我讨论的结果)后,发现它有所欠缺。更准确的说,他发现他自己需要assertion设施提供更多的特性。这一切是因为我那篇关于assertions的文章和附的源代码使用了Simple World Assumption的环境。这是个非常专业的术语,你可能也可能没有听过它,所以请允许我将它说得详细一点。
在Simple World Assumption下,程序员们有正常的工作时间,有合理的进度表。他们有时间并且被鼓励去进行代码评测。因此代码中任何失败的assertions都会在单件和整体的测试中招来一连串的批评。程序员测试并调试代码,确保在全部测试环境中都没有assertion失败,最终在NDEBUG宏被定义的情况下编译并将小而快的可执行文件发送给他们的项目经理,然后项目经理再将它发送到恰当的消费者基群。顺便一提,在Simple World Assumption之下,项目经理被认为有能力帮助程序员完成他们的工作,并且不给他们施加压力和负担。(正如它所表现出来的,Simple World Assumption在现实中并不存在)
在一个更为现实的世界中,程序员得忍受进度表所带来的相当大的压力,于是写单件的测试被取代为直接将没怎么经过测试的程序不负责任地扔给black-box测试组,而后者为bug出现的情况写bug报告。
指出一段会进展至bug出现的事件序列并不总是简单的。在多种情形并存或多线程,事件驱动下的编程会使bug的重现变得相当困难。使用未初始化变量所产生的随机行为,错误的转型,或者缓冲区溢出只是增加了一些调味料而已。咳,我几乎忘记了名目繁多的系统配置,比如被安装的DLL和注册表设置...(曾经写过一个能在你的系统上完美运行却在另一个上面神秘失败的应用程序吗?)
一个对这种情况有所帮助的方法,John说,是设计一个更好的assertion framework来扩展assertion的能力。明确如下:
* 存在assertions的多重级别、设计错误比白纸黑字更明显。在一个极端,拥有最多的checks,而随着软件的成熟只有最少的会失败。在另一个极端,有"低成本,高效用"的checks,你可以将它们为专业测试人员、beta版的测试者、有时甚至可以为你的软件的最终用户保留着。我个人并不喜欢让最终用户看到assertion message,但是John令我相信这种情况是很可能发生的。此外,继续往下读,因为失败的check可以有多种方式来报告。
* 仅仅显示消息是不够的,特别是在开发和测试组分开工作的时候。一个日志记录的设施是极其有用的,这样,在某一次运行失败后,开发者就能够看到哪个assertion(或者哪些assertion)失败了。
* 消息的质量有极大的改善,不仅包括有关失败的表达式、文件、行的详细信息,还包括相关变量的值(在程序员的控制之下)。
所有这些额外的特性的加入组成了一个工业强度的assertion工具包,在原来的assertion代码之上,John添加了一些在我的文章"Enforcements"中也被用到的技巧,还有许多属于他自己的技巧。我们会在下面详细介绍经改善过的assertion工具包是如何工作的。
提供额外的状态信息(Providing Extra State Information)
当一个assertion失败时,就意味ASSERT表达式被求值为false。然而,你可能常常想要了解到底是哪个关键变量的值导致了assertion的失败。例如,考虑在某个你确信两个string都为空的时刻,你写出如下代码:
string s1,s2;
...
ASSERT(s1.empty()&&s2.empty());
如果这个assertion失败了,你很可能想要知道myString(译注:泛指string对象,如s1或s2,下同)里面到底保存了什么,这能提供对它最后一次更新地点的洞察。John的framework允许你以如下的语法做到这一点:
SMART_ASSERT(s1.empty()&&s2.empty())(s1)(s2);
注意到圆括号的使用提供了一个给ASSERT以额外的参数的方式,并且这是可扩展的,这与ENFORCE[2]类似。(当然,ENFORCE并非是以这种特殊的风格使用operator()的第一个组件)
当这样做的时候,如果assertion失败了,显示并被记录为日志的消息看起来像这样:
Assertion failed in matrix.cpp: 879412
Expression: 's1.empty()&&s2.empty()'
Values: s1="Wake up,Neo"
s2="It's time to reload."
这就是魔法如何工作的。准备接受一些很棘手但却无疑是值得了解的东西吧。(为了能够理解并受用,你需要唤醒你内心深处对宏的热爱)首先,基本的想法是:要获得变量名称和变量的值,你需要使用"stringizing operator #"(译注:一种特殊的操作符,能使其后面的文本变成C/C++字符串形式,如#class 被编译器替换为"class"),在以上的例子中,你需要对myString使用stringizing operator。但是它只能在宏内部使用,并且以下的事实让事情变得很棘手:你需要一个能“无限扩展”的宏机制--一个被扩展开来的宏仍然可以继续作为一个宏(这样你才能收集更多的变量s3,s4...的信息)。但是我们都知道这种递归式的宏并不能工作。
然而,如果你能在踩下油门的同时打开汽车顶棚并用你的左手举起天线,你就会发现这技巧虽然困难但毕竟是可行的。荣誉属于Paul Mensonides,是他(就我们目前所知)发明了这个技巧。这儿是你所需要做的:
首先,在你的Assert类(这个类和我前一篇文章里定义并使用的同名的类很相似)内部添加两个成员变量SMART_ASSERT_A和SMART_ASSERT_B。它们的型别是Assert&。
class Assert
{
...
public:
Assert& SMART_ASSERT_A;
Assert& SMART_ASSERT_B;
//whatever member functions
Assert& print_current_val(bool,const char*);
...
};
确保你将这些成员用*this初始化。所有这些的全部意图是:如果你有一个Assert型别的对象obj,你可以写obj.SMART_ASSERT_A或obj.SMART_ASSERT_B,而它们的行为跟obj自身的行为一模一样,因为它们都是对*this的引用。第二--很酷的技巧即将登场--定义两个宏:SMART_ASSERT_A和SMART_ASSERT_B,它们以某种方式“递归至另一个”,像这样:
#define SMART_ASSERT_A(x) SMART_ASSERT_OP(x,B)
#define SMART_ASSERT_B(x) SMART_ASSERT_OP(x,A)
#define SMART_ASSERT_OP(x,next) \
SMART_ASSERT_A.print_current_val((x),#x).SMART_ASSERT_##next
如你所见,当你调用SMART_ASSERT_A(xyz)时,它将会被扩展成一段以SMART_ASSERT_B为结尾的代码。当你调用SMART_ASSERT_B(xyz)时,它会被扩展成一段以SMART_ASSERT_A为结尾的代码。在扩展的时候,这两个宏获取你传给它们的值xyz和值的字符串形式(译注:既"xyz",这是通过stringizing operator #实现的)。
是的,这真棘手。一个可以帮你理解这个技巧的意见是:当预处理器看到SMART_ASSERT_A(或_B)后面跟着一对括号时,它就将这个当成对宏的调用来对待。如果没有括号,预处理器就简单地将这个符号仍然留在那儿。而在后一种情况下,符号SMART_ASSERT_A(或_B)只是代表成员变量。这儿是SMART_ASSERT宏定义,它开始了那两个宏(SMART_ASSERT_A和SMART_ASSERT_B)的轮番上场的动作:
#define SMART_ASSERT(expr) \
if( (expr) ) ; \
else make_assert( #expr).print_context(__FILE__,__LINE__).SMART_ASSERT_A
如果在这个时候你说"Aha!" ,那么你的意见跟我在读了这些代码第二十遍之后的意见一样。好,现在让我们从最初的表达式开始跟踪宏的扩展过程:
SMART_ASSERT(s1.empty()&&s2.empty())(s1)(s2);
让我们首先展开SMART_ASSERT宏。(我们没必要按照预处理器的顺序来展开,为了让过程清晰明了,我们将过程分为几大块。我们还将适当调整展开后的代码的格式)
if( (s1.empty()&&s2.empty()) ) ;
else make_assert( "s1.empty()&&s2.empty()").
print_context("matrix.cpp",879412).SMART_ASSERT_A(s1)(s2);
现在让我们再来展开SMART_ASSERT_A宏,以及展开后的出现的SMART_ASSERT_OP宏:
if( (s1.empty()&&s2.empty()) ) ;
else make_assert( "s1.empty()&&s2.empty()").
print_context("matrix.cpp",879412).
SMART_ASSERT_A.print_current_val((s1),"s1").
SMART_ASSERT_B(s2);
注意SMART_ASSERT_A是如何不再被预处理器作为一个宏来对待的,那是因为它后面并没有紧跟着一个括号(()。扩展SMART_ASSERT_B和SMART_ASSERT_OP后的最终结果是:
if ( (s1.empty() && s2.empty()) ) ;
else make_assert( "s1.empty() && s2.empty()").
print_context("matrix.cpp", 879412).
SMART_ASSERT_A.print_current_val((s1), "s1").
SMART_ASSERT_A.print_current_val((s2), "s2").SMART_ASSERT_A;
考虑到这时SMART_ASSERT_A是被作为成员变量对待的,而且每个成员函数都返回对Assert的引用,这是个完美成型的语句。
失败处理和日志记录(Handling and Logging)
当一个assertion失败时,有两件事情相继发生:
* 失败信息被记录为日志
* 失败根据它的级别被适当处理
这两个动作是完全直交的,并且能够被分开定制。例如,你能够处于这样一个模式:你从不请求记录错误但是你仍然记录了所有的错误,这在自动运行,push installation(鼓励安装?强制安装?),或者你的"innocent user protection program"(为无知用户提供保护的程序?)方面很有用。
你可以通过将你自己的日志记录(logger)函数传给静态成员函数Assert::set_log(void (*assert_handler)(const assert_context&))以定制日志记录。你可以定义你自己的失败处理例程然后通过调用Assert::set_handler(level,void (*assert_handler)(const assert_context&))将它安置(这与由来以久的set_unexpected的风格一样)。assert_context包含了从失败的assertion中获得的上下文(acquired context),这将在下面解释。
并非只有一种Assert(Not Just One Type of Assert)
正如一些老资格的程序员所注意到的,一个应用程序能够有不同的assert级别,其中一些比另一些更紧要。
让我们看一看assert是如何被使用的。典型地,当你假定一些情况永不会发生时你会使用assertion(例如,你期望一个size或一个index变量永远不为负值)。
有些时候你将assertion与某种防御性质的编程风格结合起来(这样写的代码会在某些无效输入的情况下“生还”),但不总是这样。在后一种情况,抛出一个异常会更好一些。看看如下的代码:
void install_program(User& user,const char* program_name){
//only admins can install programs
ASSERT(user.get_role()=="admin");
...
}
assertion有四个等级:
* lvl_warn(仅仅是个警告,如果没有用户干涉程序也能够继续)
* lvl_debug(缺省等级,通常的assert)
* lvl_error(一个错误)
* lvl_fatal(这是致命的错误,很可能程序或系统已经变得不稳定)
每种级别可以用不同的方式处理。因此,我们对每个级别的缺省处理如下:
* Warning:倾印(dump)消息并且程序继续执行。
* Debug:询问用户将要做什么(忽略?Debug?...)。
* Error:抛出一个异常(std::runtime_error)。
* Fatal:终止程序。
你可以依赖于缺省处理,或者,如上面所说,改变处理例程,很简单,像这样:
Assert::set_handler(lvl_error,my_handler);
以下教你你如何设定assertion的级别:
SMART_ASSERT( user.get_role()=="admin").level(lvl_error);
SMART_ASSERT( user.get_role()=="admin").level(lvl_debug); //这是缺省的
SMART_ASSERT( user.get_role()=="admin").level(lvl_fatal);
SMART_ASSERT( user.get_role()=="admin").level(lvl_warn);
//更简洁的方式
SMART_ASSERT( user.get_role()=="admin").error();
SMART_ASSERT( user.get_role()=="admin").debug();
SMART_ASSERT( user.get_role()=="admin").fatal();
SMART_ASSERT( user.get_role()=="admin").warn();
获得上下文(Acquiring Context)
当一个assertion失败时,如何处理它取决于你。例如,你可以决定忽略它(如果它只是个warning),或者抛出一个异常,或者做其它类似的事情。但是最重要的是,如果你决定将它显示出来,你可以选择数据是如何显示的以及哪些数据将被显示。你可能会选择仅仅简单的显示一条消息并提供一个类似Figure A的“高级”选项。
你可以以类似的风格定制日志记录。为了允许这么做,当一个assertion失败时,它获取上下文:文件名,行号,级别,被求值为false的表达式,以及与表达式相关的变量值。获得__FILE__和__LINE__方式与在[1]中描述的类似。
class assert_context {
public:
//where the assertion failed: file & line
std::string get_context_file() const;
int get_context_line() const;
//get/set expression
void set_expr(const std::string & str);
const std::string & get_expr() const;
typedef std::pair<std::string,std::string> val_and_str;
typedef std::vector<val_and_str> vals_array;
//return values array as a vector of pairs
//[Value,corresponding string]
const vals_array & get_vals_array() const;
//adds one value and its corresponding string
int add_val(const std::string& val,const std::string& str);
//get/set level of assertion
void set_level(int nLevel);
int get_level() const;
//get/set (user-friendly) message
void set_level_msg( const char* strMsg);
const std::string & get_level_msg() const;
};
通常,当记录日志时,你会想要记录尽可能多的信息。因此,大部分时候你会对缺省的日志记录例程很满意,它将context中的所有东西都写下来。失败处理是另一个极端。对处理一个assert有无限多种方法。
* 仅仅忽略它。
* 对用户显示一个摘要(相关的文件名,行号,和表达式)。
* 在console窗口里显示所有详细资料。
* 抛出一个异常。
* 在一个UI对话框里显示摘要或是详细资料。
* 终止并进行核心转储(core dump),等等。
当你的应用程序到了beta阶段时,你会对缺省处理感到愉快。然而,随着你的程序的成熟并拥有大量的用户,你会想要获得最终控制权。你几乎肯定会想要取代缺省的处理例程,并提供你自己的。
一个对用户友好的处理例程简单地看起来像这样:
//显示一个具有“Ignore”和“Ignore All”两个按钮的消息框
void customerfriendly_handler( const assert_context & ctx) {
static bool ignore_all=false;
if( ignore_all) return;
std::ostringstream out;
if( ctx.msg().size()>0) out<<msg();
else out<<"Expression:'"<<ctx.get_expr()<<"' failed!";
int result=message_box(out.str());
if(result==do_ignore_all)
ignore_all=true;
}
//将它安置安置好
Assert::set_handler(lvl_debug,customerfriendly_handler);
对用户友好的消息(User-Friendly Message)
正如在介绍中所说的,并不总是你自己调试你的程序。老资格的程序员总是告诉你要为你的代码提供文档。对于assert也一样。当一个assertion失败时,你会想知道那意味着什么。让我们来看一个行为友好的ASSERT:
//too many users!
ASSERT(nUsers<1000);
如果这个assertion失败了,大概"nUsers<1000"这样的消息会被显示并记录。那么再向前一步会是允许显示你自己的解释性的字符串,那会让表达式具有更高阶的语意:
SMART_ASSERT(nUsers<1000)(nUsers).msg("Too many users!");
这是个相当优雅的解决方案。因为它让代码变得更具“自解释”性(self-documenting,译注:即从代码本身能够看出代码的意图),并且让错误消息含义更丰富。
msg()成员函数并不改变assertion级别。level()成员函数设置assertion级别,同时你也能对其提供一个可选的消息字符串。这些辅助函数被用来改变assertion级别至warn,debug,error,fatal并且同时在后面跟上一个消息字符串。如下:
//using level()
SMART_ASSERT( nUsers<1000)(nUsers).level(lvl_debug,"Too many users!");
SMART_ASSERT( nUsers<1000)(nUsers).level(lvl_error,"Too many users!");
//using helpers
SMART_ASSERT( nUsers<=900)(nUsers).warn("Users aproaching max!");
SMART_ASSERT( nUsers < 1000)(nUsers) .debug( "Too many users!");
SMART_ASSERT( nUsers < 1000)(nUsers) .error( "Too many users!");
<span