声明:本文发表于程序员杂志今年第5期,略有删节,非经杂志社和作者书面许可,请勿擅自转载部分或全部内容。
善守者,敌不知其所攻。 -孙子兵法 虚实篇
1总论
No program is bug-free,这是软件工程中一个无法证明但确得到普遍认可的命题。如果把软件测试看成是向五花八门的潜在的Bug发动的"进攻",那么在软件编码过程中生产高质量可靠的代码便可以看成为抵御各钟潜在的设计缺陷和运行错误而修筑的"防守"工事。可以这么说,一个程序的错误和异常处理机制(Error/Exception Handling Mechanism)是决定软件质量和可靠性的至关重要的因素,也是软件工程中值得关注的焦点之一。当一个项目开始时,往往就要考虑它使用什么样的监错/防错设计以及异常处理机制。
孙子兵法对于防守的最高境界的描述是:"我不欲战,虽画地而守之,敌不得与我战者,乖其所之也",而对于软件编码而言,则是在限定的时间内,交付稳定可靠的程序,尽可能避免因为设计失当而造成的错误,同时对于各种运行时可能发生的异常能够正确的处理,不致引起程序的瘫痪乃至系统的崩溃等"灾难性的后果"。
2区分错误和异常
孙子曰:"水因地而制流,兵因敌而制胜",而对于编写可靠稳定代码而言,首先要明确的一点我们面对的"敌人"是什么,从而对于不同的敌人采取不同的策略。这里我们要强调的是,必须明确区分软件中的"错误(Error)"和"异常(Exception)",值得特别注意的是,这里的"异常"是广义的概念,并不等同于现代软件编程语言中的try/throw/catch异常处理机制,后者只是前者的一种解决方法,后文有详细论述。
让我们先来看看一个error和exception的定义:
"Error…indicates serious problems that a reasonable application should not try to catch. Most such errors are abnormal conditions."
("错误"是理性的程序不应该试图捕获的严重问题,大部分这样的错误是非正常的情况)
"Exception…indicates conditions that a reasonable application might want to catch."
("异常"是理性的程序所应当捕获的情况)
初学编程的最容易犯的一个概念性错误便是搞混了这二者的区别,从上面的定义我们可以看出,这二者的共性都是我们在进行程序开发时所不期望发生的情况(unexpected conditions),而这两者的区别也是非常明显的:
首先,从两者的时间范畴(timing domain)看:错误是程序在运行时(run-time)所"不应捕获"的,也就是意味着对于错误的防治应当在运行时之前,即设计时(design-time);而异常则是在运行时(Run-time)必须和应当捕获和处理的;
其次,从两者的解决机制(handling mechanism)看:对于错误由于我们无法在运行时加以捕获处理,更多的是要取监测之,发现之,从而纠正之(监错),或者为了保证潜在的错误不带来灾难性的后果而在编程中加以预防(防错);而对于异常,我们更关心的是去捕获之,从而处理之。
下面将用具体的例子说明这两者的区别及其处理机制的不同,并在文章的最后,给出一个完整的例子说明错误防治策略和异常处理机制在实际项目中的运用。
3监错设计和防错设计
广义的说,程序中的错误有两类,语法错误(Syntax Error)和逻辑错误(Logic Error),本文中,我们主要讨论后者(严格地说,只是后者的一个子集),即在代码成功通过编译后由于设计逻辑上的缺陷带来的程序非法情况。
下面先给出简单的例子(如非特别指明,本文的例子均以C++写成):
int z = x / y;
这个语句似乎没有任何的问题,而事实真是如此吗?如果执行到该语句时除数y的取值为0,那么便会产生除0错(Divided by zero),而对于除法而言,我们从来不会允许其除数为0,如果真的发生了,意味着前面对y的错误赋值在这里引发了一个错误。
下面是一种改进的方法,使用了断言机制进行监测,我们称之为"监错性设计":
assert(y != 0);
int z = x / y;
assert是C++标准库assert.h中的一个断言宏(Assertion Macro),它的作用在于当其所断言的布尔表达式取到布尔假时,在程序的调试版本中引发一个断言错误(Assertion failure.)从而向程序员报告(通常也终止程序),这便是现代程序设计(调试)技术中经常采用的断言机制,通过在某些代码中设置一些特定检查点检查一些特定的条件是否满足,来帮助判断程序是否正确。
断言的一个不足之处在于它仅存在于程序的调试版本(debug version),对于发行版本(release version)不起作用,即如果在程序的发行版本中执行除法操作时y依然不幸地取到了零值,依然发生可能严重的后果。请看另一种可能的设计是:
int z;
if (y != 0)
{
z = x / y;
}
else
{
// deal with this situation here
}
这种写法被称为"防错性设计",它的优点在于万一在发行版本中,y仍然被错误的取到了0,不致因为除0错引起可能的程序崩溃,但是,其缺陷在于,如果y确实不应该取到0,那么这种防错设计事实上隐瞒了错误,值得一提的是Steve Maguire 在Writing Clean Code里的这样一段话:"防错性程序设计虽然常常被誉为有较好的编码风格,但它却隐瞒了错误。要记住,我们正在谈论的错误决不应该再发生"但是,作者又说,"尽管防错性程序设计会隐瞒错误,但它确实有价值。一个程序所能导致的最坏结果是执行崩溃,并使用户可能花几个小时建立的数据全部丢掉。在非理想的世界中,程序确实会瘫痪,因此为了防止用户数据丢失而参去的任何措施都是值得的。"值得我们三思。
下面是另外一个例子:
FILE * fp = fopen("c:\\test.txt", "r");
Int ch = getc(fp);
也许细心的读者已经类推出这里设计的不妥当之处,如果第一句的文件打开失败,第二句的输入显然会导致不可知的结果,应当改正为:
FILE * fp = fopen("c:\\test.txt", "r");
If (fp != NULL)
{
int ch = fgetc(fp);
} else
{
// deal with this situation here
}
在这里,我们选用了Steve Maguire所说的"防错性设计"而不是简单采用断言机制在调试版本中报错,因为文件打不开这种情况在现实情况中非常可能发生。但请读者特别注意不要把防错和异常处理混淆起来,在这里fp==NULL确实是发生了I/O"异常",但我们讨论的是,对于fgetc() 函数而言,将空指针作为函数输入参数是构成了一个"错误",而不是"异常"。而对于发现的fopen返回NULL的情况(事实上这里采用的是"利用返回值"的异常处理机制,见后文),程序员确实可以(很多时候必须)将它作为"异常"进行处理。但是请注意的是这两者概念上是有区别的。
关于防错性设计,下面是另一个典型的例子(改编自Writing Clean Code):
char * p;
char * pEnd;
/* somehow make p point to a memory block, pEnd indicates boundary of it*/
while (p < pEnd)
{
*p = "E";
p++
}
char * p;
char * pEnd;
/* somehow make p point to a memory block, pEnd indicates boundary of it*/
do
{
*p = "E";
p++
} while (p < pEnd)
试比较左右代码,似乎功能没有什么不同。请设想,如果在进入循环之前由于某种原因(比如越界内存读写,乃至"宇宙射线的轰击",Steve Maguire语),使得p>pEnd, 那么右边的代码将造成越界的内存块读写,而左边的代码将略过循环,保护了内存块,因此左边是"防错性设计"。但是它确实可能隐蔽错误,所以可以考虑再加上一个断言assert(p<pEnd)。同时,如果这种可能性真的只可能是"宇宙射线的轰击"才可能产生,那么"防错性设计"则未必是必须的,因为其带来的负面影响超过了其正面作用。
总结一下,上面提及的"错误"的产生,通常是因为不恰当的做了某种"假定"(assumption),具体到上面的例子,分别是假定了y!=0,fp != NULL和p < pEnd,而对于错误的处理方法通常有下列两种:
一是"监错性设计",利用断言机制,在做了隐含假定的地方均用断言进行确认假定的成立与否,从而帮助在调试和测试时发现,但对发行版本没有作用。
二是"防错性设计",通过加入适当的条件判断,使得即使在发行版本中发生假定不成立的情况也不致因此产生破坏性的后果,但防错性设计可能会增大发现错误的难度,并且由于增加条件判断部分的代码可能影响发行版本的性能。
基本的一个选择思路是,如果你在逻辑上做了某种假定(也就是认为某种程序的某种状态是必然或必然不达到的),通常需要采用断言在调试版本中进行监测;而如果希望某种非正常情况即使在发行版本中也加以预防,则应选用防错设计。
需要指出的是,这两种错误处理方法并不矛盾,事实上,它们通常是同时在项目中采用,使得在防止错误带来严重后果的同时不致隐瞒错误,其弊端是增加了编码量。
4异常的捕获和处理
所谓异常,简单的说就是在运行时发生的无法在设计时事先料到的"非常"事件,这种事件通常和具体的运行环境和资源分配有关。最优秀的程序员在写下一个申请内存的new语句时也无法知道将来这条语句在某台机器上真正被执行的时候会不会申请不到所需的内存空间,在写下一个打开文件的fopen语句的时候更无法预期是否在每一台机器上这个文件必然存在;更何况用户经常不按程序员设想的方式来使用软件。因此,对于异常,我们无法像错误一样加以监测或预防,只能当其发生时加以适当的捕获和处理,其根本目的不是要避免异常的发生,因为异常本质上是根本无法避免的,而在于当它发生时不会造成灾难。
很多文章已经详细的讨论过异常的捕获和处理机制,常见的异常处理方法有以下几种:
I 利用函数返回值
当一个函数执行过程中发生运行异常情况时,最常见的方式就是返回一个预先定义的错误值,又可以细分为两类,一类为是返回一个布尔值,用true/false表示函数是否调用成功;另一类则是事先定义一系列的错误值,举windows中常用的HRESULT的返回值为例子
FLAG DESCRIPTION NUMERIC VALUE
S_OK Operation successful 0x00000000
E_OUTOFMEMORY Failed to allocate necessary memory 0x8007000E
E_POINTER Invalid pointer 0x80004003
不同的返回值代表不同的错误类型。在每个可能产生异常的函数调用后均检查其返回值来捕获异常,并根据返回值选择不同的处理支路,这便构成了一个完整的捕获-处理机制。
II 设置全局标志机制
其基本思路是发生错误时设置全局(相对而言)的一个错误标志。从而可以通过检查全局标志来检查是否出现异常情况,比如win32s的
DWORD GetLastError(void);
每次调用,都返回该线程上一个错误的错误值。c语言运行库(Runtime Library)支持的errno (属于error.h)也是采用了这样一种解决方案(下面这个例子来源于MSDN,作者是Robert Schmidt):
#include <errno.h>
#include <math.h>
#include <stdio.h>
int main(void)
{
double x, y, z;
/* ... somehow set 'x' and 'y' ... */
errno = 0;
result = pow(x, y);
if (errno == EDOM)
printf("domain error on x/y pair\n");
else if (errno == ERANGE)
printf("range error on result\n");
else
printf("x to the y = %d\n", (int) result);
return 0;
}
这个完整的例子体现了C语言中利用全局标志errno进行异常捕获和处理,即在每次可能带来异常的操作后检查全局标志。这种方法可以拓展为利用一个全局性的错误堆栈,从而可以记录大于一次的异常情况。
I和II两种方法经常结合起来用,许多现有的系统都采用了这样一种复合机制,比如C Runtime Library, Windows API等都采用这样的方法。
III try/throw/catch机制
终于提到了这种也许最出名的异常处理机制,其之所以著名是因为现代的C++/Java/C#等高级语言都引入了这种机制,以至于很多人一提到异常都想到了它,它的基本思想是将程序工作流程和错误处理流程"结构化"地分开,关于该种机制,由于比较复杂,而且很多书籍和文章有详细的专门论述,这里就不再展开。
许多文献都提及一个事实,几种异常处理机制各有优劣,很难找到一种绝对优秀的通用的异常处理方法,我们也认同这个观点,因而选择异常机制便成为一个比异常机制本身更重要的问题,有下面几个在项目中作选择的基本考虑因素:
1)永远不要回避异常情况,而去"大胆"假定你的程序永远申请的到内存空间,想打开的文件永远存在;
2)在一个软件项目中尽量选用一致的异常处理模式,并且在一开始就应当确定并且贯彻执行,有一种观点认为,一个软件项目应当首先开始于"assert.h"和"error.h"这两个头文件,虽有些夸张,但也不无道理;
3) 选择异常处理机制应充分考虑使用的编程语言和开发环境,比如对于java的开发环境而言,使用第三种机制(异常)是非常自然的,而在一些情况下我们需要针对项目的需要对开发环境的异常处理机制进行定制,例如为效率考虑有时候必须禁用C++的try/throw/catch异常机制,另外在嵌入式环境中,C++异常机制本身往往就是不可用的。
5 实例
错误和异常本质上都是一种非正常的程序状态,在程序设计实践中,二者事实上是难以分开的,下面是一个综合的例子,我们要设计一个函数,用指定的字符和填充次数填充一段内存地址:
#include <assert.h>
#define E_OK 0
#define E_INVALID_ARG 1
int fill_buf(char *start, int count, char ch)
{
assert(start != NULL);
if (count <= 0)
return E_INVALID_ARG;
for (int i = 0; i < count; i++)
{
*(start + i) = ch;
}
return E_OK;
}
void main(int argc, char * argv[])
{
int count = -1;
if (argc == 2)
{
count = atoi(argv[1]);
} else
{
printf("Wrong parameter number");
}
char * buf = NULL;
buf = new char[20];
assert(buf != NULL);
int err= fill_buf(buf, count, "S");
if (err != E_OK)
{
printf("Fail to fill a buffer");
} else
{
printf("Fill a buffer successfully with %d characters", count);
}
}
先看fill_buf()函数,首先检查输入参数是否非法,对于传入空指针的情况,我们认为这是一种在正式交付的程序中绝不应存在的情况,因此我们选用了断言宏进行检验,而对于count值,我们将其取到负值作为异常处理,利用返回值返回供调用者捕获,同时这也是一种防错设计(针对后面的内存写操作而言),这里我们再一次看到防错设计和异常处理"共生"的例子。需要注意的一点是,如果该函数的返回值被调用者忽略则可能隐瞒错误(也许这正是我们所习惯做的)。
再看调用处即main()函数中,我们对argc的检查是防止对agrv非法引用的防错设计,而通过检查fillbuf() 返回值,选择不同的屏幕输出,将count<0的输入情况归结为异常处理,因为count是由用户在运行时动态输入的。同时,可以发现,如果在发行版本中产生分配内存失败的错误,将会导致对内存的非法操作(因为断言在发行版本中失效),这是考虑到现有使用虚拟内存操作系统环境下,分配内存失败的可能性微乎其微,因此不需再对其进行防错设计。
6 结语
程序的错误/异常处理问题早已引起技术界的持续重视,本文旨在从一个宏观的角度,谈论它们的区别、联系和处理方法,从而使读者对其有一个整体性的把握和框架性的认识。对于更具体的微观技术细节,读者可以参考其它文献,例如Steve Maguire的Writing Clean Code就有不少深入的探讨断言的使用,关于异常处理的文献更是数不胜数。
。