UML软件工程组织

安全抵达!
来源:IBM
防御性编码和单元测试“交通规则”

级别:入门

Scott A. Will (sawill@us.ibm.com), 经理,质量保证和系统测试,IBM Corporation/Tivoli Systems Software
Theodore F. Rivera (trivera@us.ibm.com), 产品开发经理,质量保证,IBM Corporation/Tivoli Systems Software
Adam Tate (atate@us.ibm.com), 经理,质量保证和总体解决方案测试,IBM Corporation/Tivoli Systems Software

2004 年 1 月

开发人员编写代码。不幸的是,开发人员也编写缺陷,其中大多数缺陷是在最初的编码阶段加入的。修复这些缺陷成本最低的地方同样也是在开发的初始阶段。如果等到功能测试或者系统测试来捕获并修复缺陷,那么您的软件开发成本就会高得多。在本文中,作者 Scott Will、Ted Rivera 和 Adam Tate 讨论了一些基本的“防御性”编码和单元测试实践,让开发人员更容易找到缺陷 —— 更重要的是,从一开始预防缺陷产生。

我们三个人的家中有四个青少年驾驶员,很快还会增加一个。不用说,我们非常熟悉在驾驶员培训班中灌输给我们这些新驾驶员的防御性驾驶技术。因为在我们的青少年驾驶员短短的开车期间,他们看到了打手机的男人闯红灯、女人在拐弯的时候化妆,以及业务主管在早晨上班的路上读报。所以孩子们很快学会如何注意可能会发生事故的区域以及如何避免这些事故。

防御性驾驶和防御性开发

大多数司机接受过防御性驾驶技术的教育 —— 这有很好的理由 —— 但是并不是所有开发人员都接受过防御性开发的教育,特别是那些没有用汇编语言进行过多少开发(如果不是完全没用过的话)、也没有因内存约束和处理器限制而关心过编写极其紧凑的代码的年轻开发人员。本文讨论防御性编码和单元测试概念,它们可以帮助开发人员更快生成更好的代码并且缺陷更少。

为什么防御性开发是重要的?

捕捉错误、问题和缺陷的最佳位置是在开发周期的早期。图 1 展示了最容易出现缺陷的地方,以及最容易发现它们的地方,并包括了修复这些缺陷的成本(这些成本是针对 1996 年的 —— 今天的成本显然更高)。

图 1. 缺陷:引入阶段及发现阶段(包括成本)
 缺陷:引入阶段及发现阶段(包括成本)

当然,比在编码阶段找到缺陷更好的是在一开始就防止它们。防止缺陷应该是开发人员最优先考虑的。我们将分析几个让开发人员可以在编码和单元测试时防止并检测缺陷的简单的、经过证明的方法。

在编译前(防御性设计考虑)

防止缺陷(特别是系统性缺陷)的最有效方式是仔细检查编码所依据的设计。由设计缺陷导致的缺陷 —— 虽然一般不是很多 —— 通常修补成本是最高的。事前花很少的时间针对以下几点对设计进行检查可以得到显著的长期回报。

设计考虑

设计是否有任何不清楚或者混乱的部分?如果是的话,在编写任何代码 之前 澄清这些问题。否则,您可能以一种方式解释一个设计需求,而同事则以另一种方式解释它,从而得到不兼容的实现。

如果您的代码要访问同时被其他组件访问的数据,那么保证您的设计可以处理这种情况。同时,检查设计的安全问题(请参阅 参考资料)。

如果您的代码严重依赖于其他应用程序的代码,那么您对那个应用程序是否熟悉到可以对设计进行检查?考虑在您的设计检查小组中加入熟悉该产品的一个开发人员。在 设计阶段 发现的集成问题可以得到最有效的处理。

安装和使用考虑

如果您的代码是以前版本的一个升级,那么是否有会使升级失败的参数或者其他选项改变?有哪些其他产品与新代码交互或者集成 —— 如果这些产品本身也改变了呢?还有,您的代码是否容易安装?

操作系统和数据库考虑

您的代码是否会在新版本的操作系统或者数据库上运行?您是否知道这些新版本中加入了哪些改变以及它们是否(及如何)影响您的代码?

这只是测试

设计是否结合了可测试性?虽然您可能认为可测试性问题不是您需要关心的,但是事实上单元测试 开发人员的责任之一 —— 几乎所有使执行功能测试和/或系统测试更容易的任何事情也会使单元测试更容易执行。

下面是 可测试性 领域内容的几个例子(更多内容请参阅 参考资料 部分)。

  • 设计是否允许运行时外部工具访问“状态”变量(例如,当前状态),特别是那些测试程序需要用来验证代码是否正确工作以帮助确定问题的变量?
  • 是否对跟踪和日志给予了足够的重视?您让其他人分析缺陷越容易,您在发现缺陷后修正它们就越容易(而且在单元测试中发现自己的问题也会更容易)。
  • 您是否考虑了所有可能调用您的代码的上下文?如果您可以将错误消息与调用它的用户函数上下文相关联,那么用户就更有可能理解这个错误。
  • 设计是否结合了您的测试自动化工具所需要的特定的“钩子(hook)”?

再多想一想您肯定可以在这个清单中加入更多的内容,特别是那些对您的产品或者组织特定的内容。

防御性编码技术:编译器是您的朋友

当您完成对设计的检查后,就轮到编码了。就让我们面对它,除了设计错误外,编码是惟一引入缺陷的地方。无论如何,您的测试程序和客户是不会加入缺陷的 —— 只有 会。我们都知道时间很紧张,但是如果您没有时间在第一次就把它编写正确,那么您怎么能找到时间去修正它呢?花上一些时间,这会使您在以后的编码工作中更轻松。

防止缺陷的最好方法之一是使用编译器。令人恐惧的是,开发人员在编译时通常选择使用最低程度的警告输出,所以请启用编译的全部警告 —— 把即使将编译器配置为检查 所有方面 编译时也不产生一个警告当成编写代码的一个挑战。此外,对代码使用多种编译器使很多程序员获益 —— 这种方法有时会捕获不同的语法错误。

编码习惯

下面我们将抛砖引玉介绍几个好的编码习惯。我们不是要为您定义“最佳编码习惯” —— 我们只是要您形成自己遵守的代码编写习惯。下面是几个供参考的最佳习惯的例子。

在使用前初始化所有变量

您是否有一组可接受的默认值,特别是对于可能被用户、其他组件或者其他程序有选择地修改的数据?同时,我们强烈要求您列出在最外围例程中要使用的所有本地变量,然后再专门初始化它们。这样不会对您编写代码时的想法留下任何疑问。虽然这可能要多花一些时间并且像是没有理由地增加了几行代码,但是与只是在“运行中(on the fly)”声明本地变量相比,大多数优化编译器不会对此生成任何额外的运行时代码。清单 1 显示了在一个例程中最初几行代码的一个例子:

清单 1. 初始化本地变量

public unsigned short TransMogrify( UFEventLink IncomingLink )
  {
    //
    // local variables
    //
    unsigned short usRc;
    String sOurEventType;
    String sTheirEventType;


    //
    // beginning of code
    //
    usRc = 0;
    sOurEventType = null;
    sTheirEventType = null;

    //
    // a miracle occurs...
    //

    return( usRc );

  } // end "TransMogrify"

使用一个“编码标准”文档

如果您有一个编码标准文档,就使用它。您可以在 Internet 上找到许多种编码标准。找到一种简单的、切中要害、并为您留下一定的活动空间的标准。Sun 的网站有一个关于 Java 编程的编码规范的文章(请参阅 参考资料),它给出了拥有标准的下列几点理由:

  • 一个软件生存期百分之八十的成本都用在维护上。
  • 几乎没有软件在整个使用期间都是由原作者维护的。
  • 编码规范改进了软件的可读性,使工程师可以更快和更充分地理解新代码。
  • 如果您将源代码作为产品交付,那么需要保证它有像您创建的所有其他产品一样的包装和整洁性。

即使不赞成“标准”的想法,至少采用这个简单的建议:对变量名使用“匈牙利命名法”,这会使您的代码更容易阅读和维护(有关匈牙利命名法的说明请参阅 参考资料)。

保证返回代码的一致性

在调试时有一种会制造麻烦的情况是:调用程序屏蔽(或者覆盖)一个表示错误的返回代码。一定要想好您要向调用您的代码的例程返回什么值,并保证从您所调用的例程返回的所有错误代码都得到恰当处理。如果返回代码 n 在一个地方意味着一件事,就不要在其他的地方用返回代码 n 表示另一件事。

对每个例程使用“单点退出”

这一点怎么强调也不过分:对每个例程使用单点退出 —— 就是说,没有多重返回!这是最容易忽视的、也是您可以采用的最好的习惯。如果例程只从一个地方返回,那么就可以用一种非常容易的方法保证在返回前完成所有必要的清理工作,这也使调试更容易。清单 2 显示了一个包含多重返回的代码示例。注意重复代码、甚至忘记“清理”项目(如红色突出显示的文本所示)是多么容易。

清单 2. 单点退出示例

1   public String getName( )
2     {
3       //
4       // local variables
5       //
6       String returnString;
7
8
9       //
10      // beginning of code
11      //
12      returnString = textField.getText( );
13      if ( null == returnstring )
14        {
15          badCount++;
16          totalCount++;
17          return( null )
18        }
19
20      returnString = returnString.trim( );
21      if ( returnString.equals( "" ) )
22        {
23          badCount++;
24          totalCount++;
25          return( null );
26        }
27
28      totalCount++;
29      return( returnString );
30
31    } // end getName

在第 15 行,badCount 增加了,因为 getText( ) 返回 null。在第 23 行,badCount 代码又重复了。现在想像一下如果这个例子需要完成更复杂的“清理”时会有多混乱。

清单 3 显示了一种更好的方法:

清单 3. 单点退出示例 —— 修正后

1   public String getName( )
2     {
3       //
4       // local variables
5       //
6       String returnString;
7
8
9       //
10      // beginning of code
11      //
12      returnString = textField.getText( );
13      if ( null != returnstring )
14        {
15          returnString = returnString.trim( );
16          if ( returnString.equals( "" ) )
17            returnString = null;
18        }
19
20      //
21      // "cleanup"
22      //
23      if ( null == returnString )
24        badCount++;
25      totalCount++;
26
27      return( returnString );
28
29    } // end getName

这是一个简化的例子,但是请注意遵照这种习惯有多么容易,以及这样做的好处。

加强警戒(En garde)!

要记住,您的客户对您的产品有与您不一样的想法。他们会在一个您的小组很可能从来也没想到的 —— 或者至少是没有可能测试的 —— 环境中安装它。他们会以您从来没有想到过的方法使用它,并以您意想不到的方法配置它。下面的列表有助于帮助您保证他们不会发怒:

  • 验证所有收到的参数的完整性(考虑如果您期待一个数组而传递来的是一个 null,但是您在索引数组之前没有检查这种可能性时会发生什么情况)。
  • 考虑所有可能的错误情况并增加处理每种情况的代码(您希望代码得体地处理错误条件而不是堵塞它)。
  • 对于那些未预料到的错误条件,加入一个一般性的“捕获所有”错误处理程序。
  • 在适当的时候和地点使用常量。
  • 在代码各处加入跟踪和日志。
  • 如果您的产品将翻译为另一种语言,那么保证您的代码可以“支持”它。即使出现这种情况的机会很小,但是提前计划总是好一些。修改代码以使它提供支持是最容易产生缺陷的。下面是几个您要考虑的与支持相关的问题:
    • 您是否有任何硬编码的字符串?
    • 您是否正确地处理不同的日期/时间?
    • 不同的货币表示呢?
  • 还有,在代码中使用大量断言。有关在 Java 代码中使用断言的细节信息请参阅 参考资料
  • 给您的代码加上充分的 注释。总之,您还记得在六个月前编写那个方法时的想法吗?一年后要修改您的代码的某个人又会怎么想呢?在我们提出的所有建议中,这一条可能是最重要的。

跟踪和日志

日志是必不可少的,而现有的最好工具是 alphaWorks 的 Logging Toolkit for Java (更多信息请参阅 参考资料)。

单元测试(防御性测试技术)

在本文中,我们所说的 单元测试 是开发人员在自己的代码正确编译后、在交给功能测试小组之前进行的所有测试和分析。正如我们在 这只是一个测试 中提到的,主动进行单元测试并 在测试时像一位测试者那样思考(即,必须往坏处想、热衷于破坏并喜欢恶作剧)是很重要的。下面是在单元测试时要记住的几件事。

静态代码分析工具

第一种,也是最容易的分析代码的方法是让别人替您做 —— 或者像在这里一样,让其他 工具 替您做。有一些不同的静态代码分析工具可用,从综合性的工具 —— 一些开发机构实际上在他们的“编译”环境(这可是需要购买的)中加入了这样的工具 —— 到其他可以免费从 Internet 上下载的工具。有关可用的静态代码分析工具的信息请参阅 参考资料

发现缺陷

当您准备运行代码并检查缺陷时,要记住往坏处想。这些缺陷是您所创建的或者由您忽略的代码产生。下面是一些帮助您找到代码中缺陷的提示:

  • 试着强行制造您所想到的所有错误条件并检查可以出现的所有错误消息。
  • 试着使用与其他组件或者程序交互的代码路径。如果其他程序或者组件还不存在,那么就自己编写一些脚手架代码以使您可以试用 API 或者填充共享内存、共享队列,等等。并让您的功能测试小组可以使用这个脚手架代码,这样他们就可以把它加入到他们的武器库中。
  • 对于 GUI 中的每一个输入字段,试验下面多种不同的组合(考虑 自动化):
    • 不可接受的字符(控制字符、非打印字符等)。
    • 过多的字符。
    • 过少的字符。
    • 负数(特别是当您只期待正数时)。
    • 过大和/或者过小的数。
    • 剪切和粘贴数据和文本到输入字段,特别是当您编写的代码限制用户可以键入该字段的内容时。
    • 文本和数字的组合。
    • 全大写字母和全小写字母。
  • 为代码创建“压力条件”,如大量活动、慢连接的网络和所有您想到的可以将代码推到极限条件的东西。
  • 反复进行同样的步骤,然后:
    • 检查未预计到的内存损失条件。
    • 检查当内存用光时发生什么。
    • 试图创建缓存溢出、满队列、不可用的缓存以及其他“不能正确工作”的情况。
  • 对于数组和缓存,试着向数组(或者缓存)增加 n 个数据项,然后试图删除 n+1 个项。

关于时间的考虑?

如果操作“b”在操作“a”之前发生会怎么样?就算您 认为 它不会发生 —— 您能 使 它发生吗?如果是的话,可以打赌您的客户会使它发生的。最好现在找出来,而不是在修复成本更高、并听到客户报怨您的软件质量糟糕之后再去做。

脚手架代码

我们在前面 发现缺陷 中讨论了脚手架代码。如果是为自己的使用需要而创建的,一定要将它交给验证工程师。可能您提供的脚手架代码使他们可以很快地开始测试您的代码,或者至少使他们更好地理解当其他组件存在时可以预期什么。

如果您的产品有保护性的安全功能,那么您必须测试它们。提供可以创建您想要防止的情况的脚手架代码是很重要的:您必须能够创建系统试图防止的那种情况。

脚手架代码的另一个简单例子是提供操纵队列的代码。如果您的产品使用队列,那么想像如果有一个可以在运行时从队列中增加或者删除项、破坏队列中的数据(以保证适当的错误处理)等等的工具会有多方便。

源代码级调试程序

使用源代码级的调试程序是进行彻底和成功的单元测试的关键方法。开发人员应该与他们的调试程序共生死。不幸的是,对源代码级的调试程序的充分了解和使用是一种正在消亡的做法,尽管这些调试程序的好处远远超过任何学习曲线。简而言之,我们强烈鼓励您全面学习一种调试程序。下面是用源代码级调试程序对代码进行单元测试的几种方法。您可以:

  • 在运行中操纵数据 —— 例如,在输入代码时设置中断点,然后重新设置传递的参数值以检查代码是否能正确处理(现在为)无效的参数。以这种方式使用调试程序就不需要让错误条件真正发生。
  • 设置断点,然后“单步”通过代码,这样您就可以看到每一行代码所做的事情。
  • 设置对变量的“监视(watch)”。
  • 强制使用错误代码路径。
  • 观察调用堆栈以检查哪一个例程调用了您的代码。
  • 在错误发生时“捕获”它们。
  • 执行边界检查。

认识您的验证工程师

验证工程师是测试知识的很好来源。他们可以给您指出要测试什么并帮助您了解可以在代码中加入什么以帮助他们测试(如代码钩子)。此外,您可以向他们展示如何使用您的脚手架代码。他们还会很有兴趣了解您认为在测试中哪些应该是自动化的 —— 如果您某些事情做了不止一遍,那么他们也会。

开始测验!

现在是进行小测试的时候了。让我们看看您是否用心了。

问题

您希望检查一个整数的值是否为 5。通常,要这样编写代码:


if ( i == 5 ) then
  {
    //
    // do something...
    //
  }

不过,如果您对代码进行“手指检查”,并且把代码写成了下面这样会出现什么情况呢?


if ( i = 5 ) then
  {
    //
    // do something...
    //
  }

这个失误是一个缺陷,但是只有在运行时才能捕获它 —— 可能需要相当的调试努力才能找到它。编译器会轻易放过您的代码,那么如何防止这种错误发生?

答案
实际上有两个答案:您可以使用一种上面描述的静态代码分析工具,并希望它有足够的健壮性可以捕获这种错误,也可以交换操作数以使常量位于左边:


if ( 5 == i ) then
  {
    //
    // do something...
    //
  }

因为这种方法保证您可以在编译代码时立即捕捉到问题,所以它是首选的技术。虽然它看上去有些笨,但是代码可以编译并运行得很好。然而,当您“手指检查”代码时就可以立即看到好处了:


if ( 5 = i ) then
  {
    //
    // do something...
    //
  }

可是编译器不喜欢这样,因为 5 不能被赋值为另一个值。这就是我们在 前面 说您应当将编译器看成是您的朋友时所表达的意思。

您还可以在检查 null 指针时使用这个技巧。看下面的代码:


if ( returnString == null )
  {
    //
    // do something...
    //
  }

如果您偶然将它“误写”成下面这样会发生什么呢?


if ( returnString = null )
  {
    //
    // do something...
    //
  }

您可能不会得到想要的结果。而改用下面的方法您会得到与我们刚描述过的同样的“编译器保护”:


if ( null == returnString )
  {
    //
    // do something...
    //
  }

结束语

您可能预计会有一个长的小结,把我们所讨论的所有内容重新概述一遍,然后是请您尽可能多地采用我们的建议。冒着让您失望的风险,为保持简明扼要我们做了一个相当简洁的归纳:要么现在去做,要么以后花 多得多 的代价去做。换句话说,您在开发周期的早期在测试和预防代码缺陷上花的时间越多,您在以后节省的时间和金钱就越多。这就是防御性编码的意义。它就是这么简单。

参考资料

关于作者:

Scott Will、Ted Rivera 和 Adam Tate 是 Tivoli Systems Software(IBM 的一个部门)的质量保证经理。他们有开发、验证、客户支持、专业服务和其他相关技术领域的背景。


版权所有:UML软件工程组织