Enhanced Assertions

                                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 TorjoC++专家,顾问,"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对象,如s1s2,下同)里面到底保存了什么,这能提供对它最后一次更新地点的洞察。Johnframework允许你以如下的语法做到这一点:

 

      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_ASMART_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_Aobj.SMART_ASSERT_B,而它们的行为跟obj自身的行为一模一样,因为它们都是对*this的引用。第二--很酷的技巧即将登场--定义两个宏:SMART_ASSERT_ASMART_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_ASMART_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_BSMART_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阶段时,你会对缺省处理感到愉快。然而,随着你的程序的成熟并拥有大量的用户,你会想要获得最终控制权。你几乎肯定会想要取代缺省的处理例程,并提供你自己的。

 

    一个对用户友好的处理例程简单地看起来像这样:

 

      //显示一个具有IgnoreIgnore 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!");

      SMART_ASSERT( nUsers < 1000)(nUsers) .fatal( "Too many users!");

 

难得糊涂(Ignorance Is Bliss)

    万一你正在处理别人的代码(有时这些代码是不可更改的),并且有一个assertion老是失败,你会为了解到存在一个"Ignore Always"(总是忽略)选项而愉快。"Ignore Always"[1]中所展示的那样工作,但是在幕后它使用了别的实现:它记住所有以"Ignore Always"回答的失败的assertion所对应的文件名和行号。

 

    这个机制假定你不会在同一行有两个SMART_ASSERTs。它的优势是有趣的"粒度级别"(levels of granularity)----就像"Ignore all assertions in file xyz.cpp"----现在已经变成可能了。

 

    "Ignore All"在非程序员使用你的程序时是有用的。万一在同一行有许多assertion失败了,用户会更喜欢使用"Ignore All"选项。并且,测试者可以使用"Ignore All"来加快测试速度,因为他们知道assertions会被记录下来。

 

    你可以定义你自己的处理策略。例如,John的关于SMART_ASSERT的完全实现[3]定义了一个双向保持的机制,它使"Ignore Always""Ignore All"在你的程序连续运行时始终都工作。那相当优雅。

 

Release模式是怎么回事?(What About the Release Mode)

    使用ASSERT的传统的方式是将它作为一个调试工具。ASSERT的强度源于它们的零成本(no-cost)的许诺--它们在release版中并不存在(release模式下,就象它们从未在那儿存在过--没有任何额外负担)

 

    然而,这并非总是最好的方法。有时用户想要你的程序的一个release版本(处于效率的考虑)。在release模式下,所有asserts都消失了,所以bugs变得很难跟踪。在早期的开发过程中,你会想要将assertions也保留在release版本中。甚至连Windows NT都有一个Debug版本以使它对于程序员们更容易。

 

    你还应该注意到在debug模式下,并不一定是ASSERT将程序的速度拖慢了,而更像是编译器flags指示它不要去优化代码所致。所以在许多情况下将ASSERT保留在release模式(经过优化的)可能并不会拖慢程序的速度。

 

    使用SMART_ASSERT,可以通过定义SMART_ASSERT_DEBUG_MODE宏的方式很轻易的将SMART_ASSERT打开或关闭。如果你没有定义它,缺省的是:SMART_ASSERTdebug模式下存在,在release模式下消失。

 

    如果你选择定义它,这儿是你需要做的:

 

      #define SMART_ASSERT_DEBUG_MODE 0 //SMART_ASSERTs are off(gone)

      #define SMART_ASSERT_DEBUG_MODE 1 //SMART_ASSERTs are on(present)

 

    最后,还有一些你在release模式下也需要它存在的asserts,即使他们可能导致一些(小的)额外负担。这些通常是你的代码中最关键和重要的部分。SMART_VERIFY行为类似于SMART_ASSERT,但有两点不同:SMART_VERIFYdebug模式和release模式下都能工作,SMART_VERIFY的缺省级别是error(lvl_error),但是SMART_ASSERT的缺省级别为debug(lvl_debug)。这是意料之中的事,因为如果一个SMART_VERIFY失败了,而程序却继续按照正常路径执行,则程序很可能会崩溃(因此,一个异常也会被抛出)。这儿是个例子:

 

      Widget* p=get_nth_widget(n);

      SMART_VERIFY(p)(n).msg("Widget does not exist");

      //if p were null,and we reached this point,the program would most likely crash

      p->print_widget_info();

 

结论(Conclusion)

    John的工作创建了一个特性完备的,工业强度的assertion设施。像以前一样,使用assertion仍然是成功的程序(以及成功的程序员)的一个关键性因素。现在事情变的更好,我们有了这个设施,它使你为你的程序定义,使用,和分析不变量的过程变得简单。Happy asserting!

 

致谢(Acknowledgements)

    非常感谢Pavel Vozenilek,是他从一开始就鼓励John写这个库。感谢Paul Mensonides提供允许"Chaining"宏的代码。也感谢Boost委员会为John测试代码并给出了许多肯定的回应。

 

参考书目和注解(Bibliography and Notes)

[1] Andrei Alexandrescu. "Assertions(http://www.moderncppdesign.com/publications/cuj-04-2003.html)".

[2] Andrei Alexandrescu and Petru Marginean. "Enforcements(http://www.moderncppdesign.com/publications/cuj-06-2003.html)".

[3] http://www.torjo.com/smart_assert.zip

 

关于作者(About the Authors)

Andrei Alexandrescu 是西雅图的华盛顿大学的哲学博士,也是Modern C++ Design一书的作者。你可以通过 http://www.moderncppdesign.com/联系他。Andrei也是C++研讨会的具有号召力的讲师之一。

 

John Torjo是个自由作家以及C++顾问。他喜爱C++,泛型编程,流(stream)。他也喜欢写文章,你可以通过john@torjo.com与他联系。

 

阅读更多
想对作者说点什么? 我来说一句

没有更多推荐了,返回首页