简介
有能力的程序员能够编写在未发生异常情况时正常运行的代码。使程序员出类拔萃的技能之一是能够编写在发生错误和出现“意外事件”时仍然能继续运行的代码。然而,术语“意外事件”会给人一种错误的印象。如果您的代码嵌入在一个广泛分布的成功产品中,那么您应该预料到代码可能发生的各种异常(且可怕)的情况。计算机将耗尽内存,文件未如您所愿地存在于应该存在的地方,从未失败的函数有可能在新版本的操作系统中失败,等等,不一而足。如果您希望代码能继续可靠地运行,那么就需要预见所有这些事件。
本文讨论的特定类别的异常事件是错误情况。错误情况并没有一个准确的定义。直观地讲,它就是指通常能够成功的事情并未成功。内存分配失败就是一个典型示例。通常,系统有大量内存可以满足需求,但是偶尔计算机也可能会由于某种原因而特别繁忙,例如,运行大型的电子表格计算,或者在 Internet 世界中有人正在对该计算机发动拒绝服务攻击。如果您要编写任意类型的重要组件、服务或应用程序,那么就需要预见到这种情况。
本文采用的编码示例基于 C/C++,但一般原则是独立于编程语言的。即使不使用 C/C++ 代码,使用多种语言的程序员也应该记住这一点。例如,PC Week 举办的黑客竞赛 (Hackpcweek.com) 的获胜者,就是利用某些 Perl CGI 代码中检查返回代码的失败,来部分地对 Apache 服务器发起攻击。有关进一步的信息,请参阅 http://www.zdnet.com/eweek/stories/general/0,11011,2350744,00.html 上的文章。
任何重要的软件部分都会与其他的层和组件进行交互。编写处理错误的代码的第一步,是了解当错误发生时系统的其余部分正在执行哪些操作。本文的其余部分将讨论错误处理范例,然后讨论与多个范例相关联的陷阱。本文还包括了两个简单但至关重要的有关错误情况处理的原则。
错误处理范例
无论何时发生错误情况,都需要考虑三个独立的问题:检测错误情况、报告错误情况以及对错误情况作出响应。通常,处理这些问题的职责分散在代码的不同组件或层中。例如,检测系统是否内存不足是操作系统的工作。内存分配函数的工作是将这种情况报告给它的调用方。例如,VirtualAlloc 会返回 NULL,Microsoft 基础类库 (MFC) 运算符new 会引发 CMemoryException *,而 HeapAlloc 可能会返回 NULL 或引发结构化异常。调用方的工作就是对此作出响应,方法是清理自身的工作,捕捉异常,并且还可能将失败报告给它的调用方或用户。
因为不同的层和组件需要相互协作以处理这些错误,所以第一步是定义特殊词汇,即,所有组件共同遵守的约定。遗憾的是,在 C、C++ 或其他任何语言中都没有单一且定义完善的约定。相反,却存在许多约定,并且每种约定都有各自的优缺点。
下面列出了一些最常用的约定:
返回一个 BOOL 值以指示成功或失败。Windows API 和 MFC 中的很多调用都返回 TRUE 以指示成功,返回 FALSE 以指示失败。这种方法既好又简单。但问题在于,该方法不对失败进行解释,也不区分不同类型的成功或失败。(当使用 Windows API 时,您可以使用 GetLastError 来获得特定代码。)
返回状态。遗憾的是,随着 Windows API 的变化,该约定出现了两种不同的样式。COM 函数返回 HRESULT:HRESULT >= 0(例如,S_OK)表示成功,HRESULT < 0(例如,E_FAIL)表示失败。其他函数(例如,Registry 函数)则返回 WINERROR.H 中定义的错误代码。在该约定中,ERROR_SUCCESS (0) 是唯一的成功值,所有其他值都表示各种形式的失败。这种不一致有可能造成各种各样的混乱。更糟糕的是,值 0(它在 Boolean 样式的返回值约定中表示失败)在此处表示成功。其后果将在后面进行讨论。在任何事件中,状态返回方法都具有优势 — 不同的值可以表示不同类型的失败(或者对于 HRESULT 而言,表示成功)。
返回一个 NULL 指针。C 样式内存分配函数(例如,HeapAlloc、VirtualAlloc、GlobalAlloc 和 malloc)通常返回一个 NULL 指针。另一个示例是 C 标准库的 fopen 函数。与 BOOL 返回值相同,还需要其他一些机制来区分不同种类的失败。
返回一个 “ 不可能的值 ” 。该值通常为 0(对于整数,如 GetWindowsDirectory)或 –1(对于指针或长度,如 C 标准库的 fgets 函数)。NULL 指针约定的泛化是它找到某个值 — 例程采用其他方式将无法返回该值,并且让该值表示错误。因为通常没有中心模式,所以在将该方法扩展到大型 API 时会在某种程度上出现问题。
引发 C++ 异常。对于纯粹的 C++ 程序来说,该约定可让您使用语言功能来获益。Bobby Schmidt 最近撰写的有关异常的“Deep C++”系列文章详细讨论了这些问题。然而,将该方法与旧式的 C 代码或者与 COM 结合可能会带来问题。对于 COM 方法而言,引发 C++ 异常是非法的。此外,C++ 异常是开销相对较大的一种机制。如果操作本身的价值不高,那么过大的开销通常会抵消所获得的好处。
引发结构化异常。这里的注意事项恰与 C++ 异常相反。该方法对于 C 代码而言非常整洁,但与 C++ 的交互性不太好。同样,该方法不能与 COM 有效地结合。
注 如果您要选用较旧的代码基,那么您有时会看到“原生的异常处理机制”。C++ 编译器只是在最近才开始比较好地处理异常。在过去,开发人员经常基于 Windows 结构化异常处理 (SEH) 或 C 库的 setjmp/longjmp 机制来构建他们自己的机制。如果您已经继承了这些代码基中的一个,则需要自担风险,重新编写它可能是最好的选择。否则,最好由经验非常丰富的程序员来处理。
错误处理是任何 API 定义的关键部分。无论您是要设计 API 还是使用他人设计的 API,都是如此。对于 API 定义而言,错误行为范例与类定义或命名方案同样重要。例如,MFC API 非常明确地规定了在资源分配失败时哪些函数引发哪些异常,以及哪些函数返回 BOOL 成功/失败调用。API 的设计者明确地将某些想法植入这一规定,用户需要理解其意图并按照已经构建的规则进行操作。
如果您要使用现有的 API,则必须处理所有现有约定。如果您要设计 API,则应该选用能够与您已经使用的 API 相适应的约定。
如果您要使用多个 API,则通常要使用多个约定。某些约定组合可以很好地工作,因为这些约定很难混淆。但是,某些约定组合却很容易出错。本文所讨论的许多特定陷阱就是由于存在这些不一致而造成的。
多个 API 约定的问题
混用和匹配不同的 API 约定通常是无法避免的,但这非常容易出错。一些实例是显而易见的。例如,如果您尝试在同一个可执行文件中混用 Windows SEH 和 C++ 异常,则很可能会失败。其他示例更为微妙。其中一个反复出现的特定示例就与 HRESULT 有关,并且是以下示例的某种变体:
extern BOOL DoIt();
BOOL ok;
ok = DoIt(...);
if (FAILED(ok)) // WRONG!!!
return;
该示例为何是错误的?FAILED 是一个 HRESULT 样式的宏,因此它会检查其参数是否小于 0。以下是它的定义(摘自 winerror.h):
#define FAILED(Status) ((HRESULT)(Status)<0)
因为 FALSE 被定义为 0,所以 FAILED(FALSE) == 0 是违反直觉的,这无须多言。而且,因为该定义嵌入了强制转换,所以即使您使用警告级别 4,也不会获得编译器警告。
当您处理 BOOL 时,不应该使用宏,但应该进行显式检查:
BOOL ok;
ok = DoIt(...);
if (! ok)
return;
相反,当您处理 HRESULT 时,则应该始终使用 SUCCEEDED 和 FAILED 宏。
HRESULT hr;
hr = ISomething->DoIt(...);
if (! hr) // WRONG!!!
return;
这是一个恶性错误,因为它很容易被忽略。如果 CoDoIt 返回 S_OK,则测试将成功完成。但是,如果 CoDoIt 返回某个其他成功状态,会怎样呢?那样,hr > 0,所以 !hr == 0;if 测试失败,代码将返回实际上并未发生的错误。
下面是另一个示例:
HRESULT hr;
hr = ISomething->DoIt(...);
if (hr == S_OK) // STILL WRONG!!!
return;
人们有时会插话说 ISomething::DoIt 在成功时总是返回 S_OK,因此最后两个代码片段肯定都没有问题。但是,这不是一个安全的假设。COM 接口的说明非常清楚。函数在成功时可以返回任何成功值,因此 ISomething::DoIt 的众多实现者中的任何一个都可能选择返回某个值,例如 S_FALSE。在这种情况下,您的代码将中止运行。
正确的解决方案是使用宏,这也就是宏存在的原因。
HRESULT hr;
hr = ISomething->DoIt(...);
if (FAILED(hr))
return;
因为已经引出了 HRESULT 的主题,所以现在是提醒您 S_FALSE 特性的大好时机:
它是一个成功代码,而不是一个失败代码,因此 SUCCEEDED(S_FALSE) == 1。
它被定义为 1,而不是 0,因此 S_FALSE == TRUE。
有关错误情况的两个简单原则
有许多简单的方法可以使代码更可靠地处理错误情况。相反,人们不愿意做的许多简单事情会使代码在发生错误情况时变得脆弱和不可靠。
总是检查返回状态
没有比这更简单的事情了。几乎所有函数都提供某种表明它们是成功还是失败的指示,但如果您不检查它们,则这一点没有任何用处。这能有多困难呢?可以将其视为一个卫生问题。您知道在吃东西之前应该洗手,但您可能并不总是这样做。这与检查返回值的道理相同。
下面是一个涉及到 GetWindowsDirectory 函数的简单而实用的示例。MSDN 文档清楚地说明了 GetWindowsDirectory 的错误行为:
Return Values
如果该函数失败,则返回值为 0。要获得扩展的错误信息,请调用 GetLastError 函数。
实际上,文档中写得非常清楚。
下面是一个判断 Windows 目录驻留在哪个驱动器中的代码片段。
TCHAR cDriveLetter;
TCHAR szWindowsDir[MAX_PATH];
GetWindowsDirectory(szWindowsDir, MAX_PATH);
cDriveLetter = szWindowsDir[0]; // WRONG!!!
...
如果 GetWindowsDirectory 失败,会发生什么情况呢?(如果您不相信 GetWindowsDirectory 会失败,这只是您暂时的观点。)好,该代码不检查返回值,因此分配给 cDriveLetter 的值未初始化。未初始化的内存可以具有任意值。实际上,该代码将随机选择驱动器。这样做几乎不可能是正确的。
正确的做法是检查错误状态。
TCHAR cDriveLetter;
TCHAR szWindowsDir[MAX_PATH];
if (GetWindowsDirectory(szWindowsDir, MAX_PATH))
{
cDriveLetter = szWindowsDir[0];
...
}
这种情况还能发生吗?不检查返回值的最常见借口是“我知道那个函数绝对不会失败”。GetWindowsDirectory 就是一个很好的示例。一直到 Windows? 98 和 Windows NT? 4.0,它实际上确实没有失败过,因此许多人养成了一个不好的习惯,即,假设它永远不会失败。
现在 Windows 终端服务器出现了,要判断单个用户的 Windows 目录变得更为复杂。GetWindowsDirectory 必须完成更多的工作,可能包括分配内存。而且,因为开发这一函数的开发人员非常负责任,所以他完成了正确的工作并检查内存分配是否成功,如果不成功,则返回描述完整的错误状态。
这就导致了另外一些问题:如果 GetWindowsDirectory 在失败时已经将它的输出初始化为空字符串,是否会有所帮助?答案是否定的。结果不会是未初始化的,但它们仍将是粗心大意的应用程序所未曾料到的东西。假设您具有一个由 cDriveLetter – 'A' ; 索引的数组,那么现在该索引将突然变为负值。
即使终端服务器对于您不是问题,但同样的情况可能会发生在 Windows API 的任何未来实现中。您希望禁止正在开发的应用程序在将来版本的操作系统或替代实现(如 Embedded NT)中运行吗?一种良好的习惯是记住以下事实:代码经常在其预期的到期日之后继续生存。
有时,检查返回值是不够的。请考虑 Windows API ReadFile。您经常会看到如下代码:
LONG buffer[CHUNK_SIZE];
ReadFile(hFile, (LPVOID)buffer,
CHUNK_SIZE*sizeof(LONG), &cbRead, NULL);
if (buffer[0] == 0) // DOUBLY WRONG!!!
...
如果读取操作失败,说明缓冲区的内容是未初始化的。多数情况下它可能为零,但这一点并不确定。
读取文件失败的原因有许多。例如,该文件可能是远程文件,而网络可能发生故障。即使它是本地文件,磁盘也可能恰好不合时宜地损坏。如果是这种情况,则文件的格式可能完全不同于预期格式。他人可能无意中或别有用心地替换了您认为应该存在于某个位置的文件,或者该文件可能只有一个字节。更为奇怪的事情已经发生了。
要处理这一情况,您不但需要检查读取操作是否成功,还必须检查以确保您已经读取了正确数量的字节。
LONG buffer[CHUNK_SIZE];
BOOL ok;
ok = ReadFile(hFile, (LPVOID)buffer,
CHUNK_SIZE*sizeof(LONG), &cbRead, NULL);
if (ok && cbRead > sizeof(LONG)) {
if (buffer[0] == 0)
...
}
else
// handle the read failure; for example
...
无庸讳言,上述代码有点儿复杂。但是,编写可靠的代码要比编写并不总是能够正常工作的代码更为复杂。对上述代码产生性能方面的疑问是很正常的。虽然添加了几个测试,但在全局上下文(函数调用、磁盘操作,至少复制 CHUNK_SIZE * sizeof(LONG) 个字节)中,其影响是极小的。
通常,每当需要进行返回值检查时,总是涉及到一个函数调用,因此性能开销不太重要。在某些情况下,编译器可能会内联该函数,但是如果发生这种行为,并且由于返回常数而实际上不需要检查返回值时,编译器会将测试优化掉。
诚然,还是有一些特殊情况:您通过删除返回值检查而节省的少数 CPU 循环至关重要;编译器无法为您提供帮助;您控制了要调用的函数的行为。在上述情况下,省略一些返回值检查是有意义的。如果您认为自己处于类似的情形,则应该与其他开发人员进行讨论,重新审视真正的性能折衷,然后,如果您仍然确信这样做是正确的,则在代码中每个省略返回值检查的地方加上明确的注释,说明您的决定并证明它的正确性。
总是检查内存分配
无论是使用 HeapAlloc、VirtualAlloc、IMalloc::Alloc、SysAllocString、GlobalAlloc、malloc 还是任何 C++ 运算符 new,您都不能想当然地认为内存分配成功。同样的道理也适用于其他各种资源,包括 GDI 对象、文件、注册表项等等。
下面是一个很好的独立于平台的错误代码示例,这些代码是在 C 标准库的基础上编写的:
char *str;
str = (char *)malloc(MAX_PATH);
str[0] = 0; // WRONG!!!
...
在此例中,如果内存耗尽,则 malloc 将返回 NULL,而 str 的反引用将是 NULL 指针的反引用。这会造成访问冲突,从而导致程序崩溃。除非您是在内核模式下运行(例如,设备驱动程序),否则访问冲突将导致蓝屏或可利用的安全漏洞。
解决方案非常简单。检查返回值是否为 NULL,并执行正确的操作。
char *str;
str = (char *)malloc(MAX_PATH);
if (str != NULL)
{
str[0] = 0;
...
}
与返回值检查一样,许多人相信实际上不会发生内存分配问题。诚然,该问题并不总是发生,但这并不意味着它永远不会发生。如果您让成千上万(或数以百万)的用户运行您的软件,即使该问题每月仅对每个用户发生一次,后果也是严重的。
许多人相信,在内存耗尽时做什么都是无所谓的。程序应该退出。但是,这在许多方面都不适用。首先,假设程序在内存耗尽时退出,那么将不会保存数据文件。其次,人们通常期望服务和应用程序能够长期运行,因此它们能够在内存暂时不足时继续正常运行是至关重要的。第三,对于在嵌入式环境中运行的软件而言,退出不是可行的选择。处理内存分配可能非常麻烦,但这件事情必须做。
有时,意外的 NULL 指针可能不会导致程序崩溃,但这仍然不是件好事情。
HBITMAP hBitmap;
HBITMAP hOldBitmap;
hBitmap = CreateBitmap(. . .);
hOldBitmap = SelectObject(hDC, hBitmap); // WRONG!!!
...
SelectObject 的文档在对 NULL 位图执行哪些操作方面含糊不清。这可能不会导致崩溃,但它显然是不可靠的。代码很可能出于某种原因而创建位图,并希望用它来进行一些绘图工作。但是,因为它未能创建位图,所以绘图操作将不会发生。即使代码没有崩溃,这里也明显存在一个错误。同样,您需要进行检查。
HBITMAP hBitmap;
HBITMAP hOldBitmap;
hBitmap = CreateBitmap(. . .);
if (hBitmap != NULL)
{
hOldBitmap = SelectObject(hDC, hBitmap);
...
}
else
...
当您使用 C++ 运算符 new 时,事情开始变得更加有趣。例如,如果您要使用 MFC,则全局运算符 new 将在内存耗尽时引发一个异常。这意味着您不能执行以下操作:
int *ptr1;
int *ptr2;
ptr1 = new int[10];
ptr2 = new int[10]; // WRONG!!!!
如果第二次内存分配引发异常,则第一次分配的内存将泄漏。如果您的代码嵌入到将要长期运行的服务或应用程序中,则这些泄漏会累积起来。
只是捕捉异常是不够的,您的异常处理代码还必须是正确的。不要掉到下面这个诱人的陷阱中:
int *ptr1;
int *ptr2;
try {
ptr1 = new int[10];
ptr2 = new int[10];
}
catch (CMemoryException *ex) {
delete [] ptr1; // WRONG!!!
delete [] ptr2; // WRONG!!!
}
如果第一次内存分配引发了异常,您将捕捉该异常,但要删除一个未初始化的指针。如果您足够幸运,这将导致即时访问冲突和崩溃。更有可能的是,它将导致堆损坏,从而造成数据损坏和/或在将来难以调试的崩溃。尽力初始化下列变量是值得的:
int *ptr1 = 0;
int *ptr2 = 0;
try {
ptr1 = new int[10];
ptr2 = new int[10];
}
catch (CMemoryException *ex) {
delete [] ptr1;
delete [] ptr2;
}
应该指出的是,C++ 运算符 new 有许多微妙之处。您可以用多种不同的方式来修改全局运算符 new 的行为。不同的类可以具有它们自己的运算符 new,并且如果您不使用 MFC,则可能会看到不同的默认行为。例如,在内存分配失败时返回 NULL,而不是引发异常。有关该主题的详细信息,请参阅 Bobby Schmidt 的“Deep C++”系列文章中有关处理异常的第 7 部分。
返回页首
小结
如果您要编写可靠的代码,则至关重要的一点是从一开始就考虑如何处理异常事件。您不能事后再考虑对异常事件的处理。在考虑此类事件时,错误处理是一个关键的方面。
错误处理很难正确执行。尽管本文只是粗浅地讨论了这一问题,但其中介绍的原则奠定了一个强大的基础。请记住以下要点:
在设计应用程序(或 API)时,应预先考虑您喜欢的错误处理范例。
在使用 API 时,应了解它的错误处理范例。
如果您处于存在多个错误处理范例的情况下,请警惕可能造成混乱的根源。
总是检查返回状态。
总是检查内存分配。
如果您执行了上述所有操作,就能够编写出可靠的应用程序。