隔栏、断言、防御型和攻击型编程的思考


2014-01-12

一、前言

  本文的内容最初源自我重温《代码大全》时候读到的一段内容,然后和同事讨论又引发了自己的一些思索。最后觉得这值得写下来,一来我一直很喜欢琢磨编码风格,有一些这方面的​思考,但都是一些存放在脑子里的思维碎片,(部分)整理成文对我自己是件好事。二来抛砖引玉,希望能找到一些同好探讨。

二、常用的断言宏

  先贴一段我常用的断言宏,后面讲述的时候所说的ASSERT是通指下面这一组ASSERT*。

#ifdef DEBUG
#define ASSERT(x) assert(x)
#else
#define ASSERT(x)
#endif

#define ASSERT_RETURN(x)            {ASSERT(x); if(!(x)) {return;}}
#define ASSERT_RETURN_VAL(x, ret)   {ASSERT(x); if(!(x)) {return ret;}}
#define ASSERT_GOTO(x, label)       {ASSERT(x); if(!(x)) {goto label;}}
#define ASSERT_BREAK(x)             {ASSERT(x); if(!(x)) {break;}}
#define ASSERT_CONTINUE(x)          {ASSERT(x); if(!(x)) {continue;}}

三、隔栏与断言

  《代码大全》里有这么一段“隔栏与断言的关系”:

隔栏外部的程序应使用错误处理技术,在那里对数据做的任何假定都是不安全的。
隔栏内部的程序应使用断言技术,因为传进来的数据应该已在通过隔栏时被清理过了。

  这里的“隔栏”我理解为一些接口,比如类的public函数。按照上述说法,应该在public函数的对参数进行合法性验证“if (!x) return”,在private函数的开头使用ASSERT。

  感觉也有点道理。但是觉得又不太习惯。我习惯这么做:
  public和private函数都用ASSERT,既把问题反馈给外部使用者,又反馈自身内部的问题。

  曾经和同事讨论过这个问题,同事没我这么挑(或者“钻牛角尖”?),他一般的做法是:在public函数里做参数检查,private里不对参数做检查。一来是priva​te里没必要做判断(public已经判断过了),二来是影响效率。

  我尝试了一段时间,但是还是觉得不合我的习惯,原因是:

  1. 即便public函数做了参数检查,也难保开发者不引入别的问题。比如传递参数过程中误改。
  2. 所谓“内部”、“外部”,只是个相对的概念。多人协作来开发、使用这些类的过程中,一个模块中的函数可能由不同的开发者编写,相互都可以理解为“外部”(甚至不同函数也可​以认为互为“外部”)。即便在做设计时已经约定好相互之间的数据要求,也难免实现过程中会偏离。更不用说使用极端敏捷开发、基本不做文档化设计的小团队协作。
  3. 如“隔栏和断言的关系”所述:“隔栏内部的程序应使用断言技术,因为传进来的数据应该已在通过隔栏时被清理过了”。
  4. 担忧的那部分性能损耗极有可能微乎其微。(后面有一些性能优化的想法分享。)

  又琢磨了琢磨,感觉《代码大全》里说的也有道理。隔栏处确实更适合做过滤,而不是断言。
  因为隔栏外部的程序未必需要隔栏内部的反馈。比如单元测试时,测试程序可能会有意的传入非法的参数。

四、一个讨论引发的思索

  另外,同事分享了一个讨论贴,主贴里楼主愤怒地吐槽:

昨天线上的一个服务程序问了点问题,程序无限重启。
通过gdb调试发现该程序在某个函数里调用空指针,然后造成了段错误。因为有守护进程监控,所以有了无限重启的现像。
我把引起段错误的地方告诉了程序的作者,此人竟然说,这个指针应该不为空才对,现在指针为空说明了数据有问题,应该让数据的生产者去修改,他拒绝修改。

  跟帖的有支持楼主的(后简称A),也有认同文中“程序的作者”(B)。

  回帖里支持双方的都有。比如支持B的典型说法有:“strcpy不就这样么?”、“野指针同样会照成崩溃,所以是否检查空指针完全看程序各个模块之间的约定”。

  我是毫不犹豫的支持A的:

  1. 错误应该尽早暴露。
    并且应该尽可能由开发者可控的引爆,而不是被动的爆掉,然后没头脑地分析爆炸现场。简单来说,一个好的开发人员应该让修正问题的代价尽可能的小。
  2. 参数检查不是银弹,但即便如此,对一些普遍的错误做处理也是必要且高效的。
    比如常见的NULL指针。这些常见的错误可能占各种参数错误发生总量的一半,解决掉它们带来的收益是很可观的。很多事情很难做到100%完美,但是度量(很多时候靠经验)​面对的问题,整理出解决这些问题的收益比曲线,然后处理掉那些收益比最高的问题,就是一个很好的方法。而不是嚷嚷“我没办法解决100%的问题,所以我就不解决了。问题出​现了再说”。激进一点说,在我看来,这是不负责的想法。
  3. 标准库的做法并不一定是正确的,比如臭名昭著的realloc。
    具体的缺点很多文章里有讨论,《代码大全》里也有提到。
  4. 函数都需要检查参数。
    至于如何检查参数(比如是否需要检查空指针),主要取决于函数本身对参数的期望,而不应只取决“模块之间的约定”。

  说的远一些,我很喜欢有调试开关或者调试功能的开发库和工具。比如:

  对我来说,我甚至希望所有的开发库都有类似的机制。我希望某天开发者能通过某个开关让strcpy检查空指针,检查源、目的覆盖问题等等。

  此外,还有一些涉及到防御型编程和“攻击型编程”(好像没有约定俗成的名称?姑且这么称呼吧)的想法。

  我个人推荐的做法是:对自己狠一点,对最终用户温柔一些。精确点说是“开发和内测版本中多用攻击型技术,让故障尽早(并且可控地)被引爆。发布版本里多用防御行编程,以包​容一些非致命的问题。”

  举例来说,本文最开始的ASSERT_RETURN宏就是这种方法的一个典型:

  在开发版本(#ifdef DEBUG分支)中,启用了assert,一旦不符合研发人员的预期,就会立刻引爆,让研发能够发现问题。而发布版本中则只是做了”if return/break/continue”等,包容了错误。让程序能够正常的运行下去。

  另外在发布版本中是包容还是优雅的退出,取决于具体的问题。比如office中发现用户通过剪贴板复制过来的数据不合要求,如果此时“优雅的退出”,用户可能杀人的心都有​了。

五、性能优化相关的一些想法

  这个话题也是一个很庞大的话题,我只说说我个人的想法:

  1. 优化的前提是掌握了准确的性能分析数据,明确了性能瓶颈。没有进行科学的性能分析就着手进行的优化都是耍流氓。
    当然,开发过程中就拍脑袋想出来的“性能瓶颈”更是地球人愚蠢的想法(我自己犯过好几次,而且还是在我高举“过早优化是一切罪恶之源”大旗的情况下。最后都证明是捡了芝麻​丢了西瓜,感谢我的同事点醒了我)。
    开发阶段需要做的事是尽量的模块化和抽象,当后续诊断出性能瓶颈后可以很方便的替换、调优那些模块。
  2. 自上而下,先考虑架构,再考虑层、模块,最后考虑底层的实现。
    切莫为“这个密集调用的函数里这条检查语句很复杂,可能会影响整体性能”而“优化”。首先,参考上述的1,别耍流氓。其次,优化也有收益比曲线,先优化收益比最高的那些瓶​颈,那些才是金矿。

  最后,广而告之:《代码大全》,你值得拥有。

六、版本记录