第一部分 建立稳定的系统

通常,软件工程师花费在检查代码方面的时间同编写代码所花费的时间相当。保证软件的质量是每个程序员追求的目标,在问题出现之前将其消灭对程序员实现这个目标大有裨益。另外,软件系统应具有在不可预测的环境中正常运行的能力。

C++中引入了异常处理机制来支持复杂的出错处理,从而避免大量的出错处理逻辑干扰程序的代码。第1章介绍恰当地使用异常处理对性能良好的软件是何等的重要,该章还介绍了建立在异常安全代码基础之上的设计原则。第2章涉及单元测试和调试技术,意在使程序在其发布之前质量尽可能地好。使用断言(assertion)来表示和强化程序中的不变量(invariant),是经验丰富的软件工程师的确切标志。此外,本章还将介绍一个支持单元测试的简单框架。

第1章 异常处理

增强错误恢复能力是提高代码健壮性的最有力的途径之一。

遗憾的是,在实践中人们通常会忽略出错情况,就好像程序处在一个无错误的状态下进行工作。毫无疑问,导致上述问题的一个原因就是,检测错误是一个乏味的工作并且会导致代码的膨胀。比如,函数printf()返回那些被成功地打印出来的字符的个数,但是却很少有人去检测这个返回值。单单代码激增一项就足以令人厌恶,更不用说代码膨胀将不可避免地增加程序阅读的困难了。

C语言中采用的出错处理方法被认为是“紧耦合的”—函数的使用者必须在非常靠近函数调用的地方编写错误处理代码,这样会使其变得笨拙和难以使用。

异常处理(exception handling)是C++的主要特征之一,是考虑问题和处理错误的一种更好的方式。使用异常处理:

1)错误处理代码的编写不再冗长乏味,并且不再与“正常的”代码混合在一起。程序员只需编写希望产生的代码,然后在后面的某个单独的区段里编写处理错误的代码。如果要多次调用同一个函数,则只需在某个地方编写一次错误处理代码。

2)错误不能被忽略。如果一个函数必须向调用者发送一条错误消息,它将“抛出”一个描述这个错误的对象。如果调用者没有“捕获”并处理它,错误对象将进入上一层封装的动态范围,并且一直继续下去,直到该错误被捕获或者因为程序中没有异常处理器捕获这种类型的异常而导致程序终止。

本章将分析C中的错误处理方法,并讨论为什么该方法在C中工作得不尽如人意,以及为什么在C++中根本无法使用该方法。本章还介绍了try、throw和catch等在C++中用于支持异常处理的关键字。

1.1 传统的错误处理

在本教材的大多数例子中,我们使用assert()的意图是:用于开发阶段的调试,通过宏定义语句#define NDEBUG使其在最终发行的软件产品中失效。运行时错误检测处理采用了在第1卷第9章中开发的require.h中定义的函数(assure()和require()),该文件也附在本教材的附录B中。这些函数以一种方便的方式表达了这样的意思,“这儿发生了一个错误,读者可能希望用更复杂的代码来处理该错误,但是在本例中却不必对此过多地分心”。require.h中定义的函数对于一些小的程序来说已经足够了,但是对于更复杂的应用则应当采用更加完善的错误处理代码。

当确切地知道应该做什么的时候,错误处理将会非常地简单明了,因为在语境中已经知道了所有必要的信息。程序员可以在错误发生的时候立即处理它。

当在某个语境中不能得到足够的信息时,问题就出现了,这时需要将错误信息传递到一个包含上述信息的不同的语境中去。在C语言中,有三种方法处理这种情况:

1)在函数中返回错误信息,如果无法将返回值用于这个方面,则设置一个全局的错误状态标志(标准C中提供了errno和perror()来支持这种方法)。就像前面提到的一样,程序员会因为冗长乏味的错误检查而倾向于忽略错误信息。另外,从一个出现异常情况的函数中返回可能根本没有意义。

2)使用鲜为人知的标准C库中的信号处理系统,由函数signal()(用以推断事件发生时出现了什么情况)和函数raise()(产生一个事件)实现。同样,该方式的耦合度非常高,因为如果要使用可能产生信号的库函数,库的使用者必须了解和设置适当的信号处理机制。在大型项目中,不同的库产生的信号值可能会发生冲突。

3)使用标准C库中的非局部跳转函数:setjmp()和longjmp()。使用setjmp()可以在程序中保存一个已知的无错误的状态,一旦发生错误,就可以通过调用longjmp()返回到该状态。同样,这使得错误发生的位置与保存状态的位置之间产生高度耦合。

当考虑C++的错误处理方案时,会涉及另一个关键问题:C中的信号处理方法和函数setjmp()/longjmp()并不调用析构函数,所以对象不会被正确地清理。(实际上,如果longjmp()跳转到超出析构函数的作用范围以外的地方,则程序的行为是不可预料的)。在这种情况下不可能有效地从错误状态中恢复,因为程序中留下了未被清理但又不能被再次访问的对象。下面的代码演示了函数setjmp()/longjmp()的使用方法:

第一部分 建立稳定的系统 - 图1

函数setjmp()的行为很特别,因为如果直接调用它,它便会将所有与当前处理器相关的状态信息(比如指令指针的内容、运行时栈指针等)保存到jmp buf中去并返回0。在这种情况下,它的表现与一个普通的函数一样。然而,如果使用同一个jmp buf调用longjmp(),则函数返回时就好像刚从setjmp()中返回时一样—又回到了刚刚从setjmp()返回的地方。这一次,返回值是调用longjmp()时所使用的第二个参数(argument),因此可以通过这个值断定程序实际是从longjmp()返回的。读者可以想象有很多不同的jmp buf的情况,程序可以跳到多个不同的位置。局部goto(使用标号)和这种非局部跳转的区别在于,通过setjmp()/longjmp()程序可以返回到运行栈中任何预先确定的位置(即任何调用setjmp()的位置)。

在C++中使用longjmp()的问题是它不能识别对象。特别是,当跳出某个作用域的时候,它不会调用对象的析构函数[1]。而析构函数的调用在C++中是必需的操作,所以这种方法不能用于C++。实际上,C++标准已经说明,使用goto跳入某个作用域(有效地跳过构造函数的调用),或使用longjmp()跳出某个作用域而且这个作用域的栈中有某个对象需要析构时,程序的行为是不确定的。

[1]当读者运行这个例子的时候,可能会感到奇怪—某些C++编译器包含了扩展的longjmp(),能够清除栈中的对象。但这种行为是不可移植的。