深入浅出话异常-(1)
Robert Schmidt
May 10, 1999
本期讨论要点: 标准C的异常处理机制。
前言
标准C提供了几种异常管理机制,这些机制在标准C++里也可用,但是相关的头文件名称作了改变:旧的标准C头文件名会从<name.h>映射到新的标准C++里的头文件名<cname>。(头文件名的前缀C是为了记忆,指明它们是标准C的库文件)
虽然在C++的向后兼容里保留了C的头文件,但我劝告你在任何可能的地方使用新的头文件。对于许多实际使用中,最大的改变是在新的头文件与namespace std内进行声明。请看以下三种不同类型的示例:
//旧的使用使用方法,在标准C++里被替换成#include <cstdio>
#include <stdio.h>
FILE *f = fopen("blarney.txt", "r");
//现在的用法,与旧方法很相似
std::FILE *f = std::fopen("blarney.txt", "r");
//混合使用,Visual C++支持这种用法?????
#include <cstdio>
using namespace std;
FILE *f = fopen("blarney.txt", "r");
不幸的是,Microsoft's Visual C++不能在新的头文件与namespace std同时具备的条件下进行声明,即使这种行为是标准C必需的。除非等到Visual C++支持这种行为,我将在本行使用旧的C风格名字。
(对于像Microsft这样的库供应商来说,实现这些C库头文件的正确性需要维护与测试两套不同的代码,这是一项艰巨的任务,且不带来任何商业价值)
一、绝对终止:
这是一种彻底忽略异常的方法,大概这种简单的响应是一种安全的退出方法。在一些情形里,这是最正确的方法。
C库头文件<stdlib.h>提供了两个不是相当完美的函数:abort与exit,两者都不返回它的调用者,并且结束程序运行。
虽然两者在概念上是相同的,但使用它们的结果是不同的:
abort: 粗鲁地结束程序。这是默认的,在运行时诊断里调用abort来安全结束程序。这种结束方式可能会或可能不会刷新与关闭打开的文件或删除临时文件。
exit:文明地结束程序。它附加了关闭打开的文件与返回状态码给执行环境,exit还调用你用atexit注册的回调函数。
你通常是在发生严重异常的情况下调用abort,由于abort默认行为是立即结束程序,你需要在调用abort之前保存你的数据。(在讨论<signal.h>里会再提到)
对于两者的差异,exit执行客户用atexit注册的清除代码,它们的调用顺序是按它们被注册的相反顺序来的。示例:
#include <stdio.h>
#include <stdlib.h>
static void atexit_handler_1(void)
{
printf("within 'atexit_handler_1'\n");
}
static void atexit_handler_2(void)
{
printf("within 'atexit_handler_2'\n");
}
int main(void)
{
atexit(atexit_handler_1);
atexit(atexit_handler_2);
exit(EXIT_SUCCESS);
printf("this line should never appear\n");
return 0;
}
/* 运行后的结果:
within 'atexit_handler_2'
within 'atexit_handler_1'
并返回退出码给调用环境.
*/
(注意:如果你的程序在main函数结束时没有显式调用exit,那么你用atexit注册的处理函数也会被调用)。
二、条件结束:
abort与exit无条件终止你的程序。你也可以有条件地结束你的程序,这种机制是每一个程序员喜受的诊断工具:assert宏定义在<assert.h>,如下相似代码:
#if defined NDEBUG
#define assert(condition) ((void) 0)
#else
#define assert(condition) _assert((condition), #condition, __FILE__, __LINE__)
#endif
//译注:各家产品提供的assert的实现并不一样,比如:
Visual C++ 6.0的实现是:#define assert(exp) (void)((exp)||(_assert(#exp, __FILE__, __LINE__), 0));
Borland C++ 5.5的实现是:#define assert(exp) ((exp) ? (void)0 : _assert(#exp, __FILE__, __LINE__))
至于函数_assert(在gcc的库中_assert是一个宏)是各家的内部实现,不一定得非要_assert这个名字,其内容一般是利用printf函数(在WIN平台上往往是调用MessageBox)输出出错信息(文件名及行号)并调用abort终止程序。//end 译注
在这个定义里,当定义了预处理符号NDEBUG的时候,断言是无效的,这意味着assert断言宏只在你的Debug版本中有效。在Release版本里,assert断言宏不进行任何计算。由于这个而会引起一些侧面效应,比如:
/* 调试版本 */
#undef NDEBUG
#include <assert.h>
#include <stdio.h>
int main(void)
{
int i = 0;
assert(++i != 0);
printf("i is %d\n", i);
return 0;
}
/* 当运行后输出:
i is 1
*/
那么现在改变代码版本到release版本,定义NDEBUG:
/* release版本*/
#defing NDEBUG
#include <assert.h>
#include <stdio.h>
int main(void)
{
int i = 0;
assert(++i != 0);
printf("i is %d\n", i);
return 0;
}
/* 当运行后输出:
i is 0
*/
所以在assert中只能是比较而不能有实质性的动作,否则调试和发布版的结果可能会大相径庭。
因此,为了避免这种差异,确保在assert表达式不能包含有侧面影响的代码。
只在Debug版本里,assert会调用_assert函数。以下是相似代码:
void _assert(int test, char const *test_image,
char const *file, int line)
{
if (!test)
{
printf("Assertion failed: %s, file %s, line %d\n",
test_image, file, line);
abort();
}
}
在断言失败将产生出详细的诊断信息,包含源程序文件名与行号,之后调用abort,我给这种机制的示例是相当的粗糙;你的库实现者可能更复杂。
assert典型是用在调试逻辑错误,它永远不会存在于release程序里。
static void f(int *p)
{
assert(p != NULL);//这儿!
/* ... */
}
在使用assert中要注意逻辑错误与运行时错误的区别:
/* ...让用户输入文件名... */
FILE *file = fopen(name, mode);
assert(file != NULL); /* 相当可疑的用法??? */
这种错误出现在assert表达式里,但它不是BUG,它是运行时异常,assert可能会不正确地响应,你应该使用其它机制,我在下面介绍。
三、非局部goto:
对比于abort与exit,goto 让你有更多地管理异常的方法,不幸的是gotos是局部的,goto只能在它们函数的内部跳转,因此不能在程序的任意地方控制它。
为了克服这种限制,标准C提供了setjmp与longjmp函数,它可以goto到任何地方。头文件 <setjmp.h>定义了这些函数,包括间接的jmp_buf,这种机制简单直接:
setjmp(j)设置goto指针,jmp_buf用当前程序上下文信息来初始对象j。这种上下文信息典型包括程序位置指针、堆栈与框架指针,还有其寄存器与内存值。当初始化上下文信息后,setjmp返回0.
稍后调用longjmp(j, r)来goto到对象j指定的地方(之前调用setjmp进行初始化j),当调用的目标非局部goto,setjmp返回r,如果r是0返回1.(记住:setjmp在这个上下文中不能返回0)
这里有两种类型的返回值,setjmp让你来如何使用它。当设置j的时候,setjmp工作在正常预期的行为,但当目标是long jump, setjmp "wakes up" from outside its normal context.
如果使用longjmp来引发终止异常,setjmp可以标记相应的异常处理过程。
#include <setjmp.h>
#include <stdio.h>
jmp_buf j;
void raise_exception(void)
{
printf("exception raised\n");
longjmp(j, 1); /* jump到异常处理过程 */
printf("this line should never appear\n");
}
int main(void)
{
if (setjmp(j) == 0)
{
printf("'setjmp' is initializing 'j'\n");
raise_exception();//恢复上下文
printf("this line should never appear\n");
}
else
{
printf("'setjmp' was just jumped into\n");
/* 异常处理过程 */
}
return 0;
}
/* 运行结果:
'setjmp' is initializing 'j'
exception raised
'setjmp' was just jumped into
*/
注意:用jmp_buf来恢复其它上下文是无效的,请看以下示例:
jmp_buf j;
void f(void)
{
setjmp(j);
}
int main(void)
{
f();
longjmp(j, 1); /* 逻辑错误 */
return 0;
}
你必须在当前调用上下文中只认为setjmp是非局部goto。
四、信号(Signals):
标准C也标准化事件(event)管理包(虽然较原始)。这个管理包定义了设置事件与信号,连同标准的引发与处理方法。那些信号可在异常表达式或不同的扩展事件里引发它们。这也是要讨论的目的。我只集中在异常信号.
对于使用这些管理包,应该包含标准头文件<signal.h>,这个头文件定义了raise与signal函数,sig_atomic_t类型与开始执行信号事件的宏SIG。在标准要求里有6个信号宏,但你的库实现者可以增加其它。但设置信号的函数定义固定在<signal.h>里,你不能扩展你自已的信号设置函数。调用raise来引发信号,并进入到相应的处理过程。运行时系统提供了默认的处理方法,但你可以安装你自已的信号处理行为。处理方法通过sig_atomic_t来与外部程序进行通信.对于类型名字的建议,分配给每一对象是原子方式或中断安全(interrupt-safe)。
当你注册信号处理过程的时候,一般你要提供处理函数地址。每一个函数必需接受int值,且返回void。在这种方法,信号处理方法象setjmp;只有异常上下文能接收单个整数:
void handler(int signal_value);
void f(void)
{
signal(SIGFPE, handler); /* 注册处理过程*/
/* ... */
raise(SIGFPE); /* 通过 'SIGFPE'来调用处理过程 */
}
有两种安装指定处理方法可供选择:
signal(SIGxxx, SIG_DFL),//使用系统默认的处理方法.
signal(SIGxxx, SIG_IGN), //告诉系统忽略信号。
在所有情形里,信号返回指向先前的处理过程的指针或SIG_ERR(意味着注册失败)
当处理方法被调用的时候,这意味信号开始进行异常处理。你可以在处理方法里自由调用abort,exit或longjmp来效地结束异常。一些有趣的地方:实际上,abort自已在内部也调用raise(SIGABRT),默认的SIGABRT异常处理方法显示诊断信息与结束程序。但你可以安装你自已的SIGABRT异常处理方法来改变这种行为:
但你不能改变abort的终止程序的行为,以下是abort的相似代码:
void abort(void)
{
raise(SIGABRT);
exit(EXIT_FAILURE);
}
这儿,如果你SIGABRT异常处理方法返回后,abort也结束程序。
在标准C库里,在信号异常处理方法行为也是有限制的。请看标准7.7.1.1的细节。
(译者注:以下是标准C的草案文件:http://anubis.dkuug.dk/JTC1/SC22/WG14/www/docs里的n843.pdf)
五、公共变量:
<setjmp.h>与<signal.h>正常用于检测到异常后进行通知处理过程:当得到异常事件的通知的时候,异常处理过程将被唤醒。如果你更喜欢检查错误码的方法,那么标准库提供了这种行为,包含在头文件<errno.h>里。这个头文件定义了errno,再加上errno一些常用到的值。标准库要求三个这样的值:EDOM, ERANGE,EILSEQ ,它们分别是domain,range与multibyte-sequence error,但编译器提供商可能增加其它。
errno,包含设置与获取:当代码产生异常对象(单个整数)时,拷贝异常对象的值给予errno,然后在用户模式中检测异常。
主要使用errno的库函数集中在<math.h>与<stdio.h>。在程序开始时errno被设置为0,而且没有任何库代码会自动再一次设置errno为0(也就是说当你处理了错误之后,一定要将errno设置为0才能再调用标准库代码)。因此,对于检测错误,你必须设置0,然后继续调用标准库程序。以下是示例:
#include <errno.h>
#include <math.h>
#include <stdio.h>
int main(void)
{
double x, y, result;
/* ... 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;
}
说明:errno不需要引用到对象:
int *_errno_function()
{
static int real_errno = 0;
return &real_errno;//不需要这样做
}
#define errno (*_errno_function())
int main(void)
{
errno = 0;
/* ... */
if (errno == EDOM)
/* ... */
}
六、返回值与参数:
errno-像异常对象但没有限制:
所有相关部分必须集中在一起,允许设置与检测相同对象.
随时可以改变对象.
如果在调用其它程序之前你没有重置对象或检测它们,那么你将错过异常.
宏与内部对象名会隐藏异常对象。
静态对象天生不具线程全安。
总结:每一个对象都是脆弱的:你太容易滥用它们,在你的编译器没有警告信息里,你的程序可能出现不可预测的行为。
去掉这些缺陷,你需要的对象应该是:
由两部分组成:一部分产生异常,另一部分检测异常。
取得正确的值.
不要隐藏它们.
是线程安全.
函数的返回值应该符合这些标准,因为它们是调用函数里创建未命名的临时对象,且只能被调用者理解。当一个调用完成,调用者可能检测或拷贝返回对象的值;之后,返回的原始对象消失了,因此不能在使用这个对象了。由于对象是未命名的对象,它是不能被隐藏的。
(在C++里,我假定在函数调用表达式只返回左值,意味着调用者不能返回引用,我的这种限制只在我讨论的C兼容技术这部分里,而且C没有引用(C标准-C98也加入支持引用),所以我的这个假设是合理的)
int f()
{
int error;
/* ... */
if (error) /* 存在错误 */
return -1; /* 产生异常对象 */
/* ... */
}
int main(void)
{
if (f() != 0) /* 检测异常 */
{
/* 处理异常 */
}
/* 再次运行 */
}
返回值是标准C库用来传播异常的较好的方法,请思考以下示例:
if ((p = malloc(n)) == NULL)
/* ... */
if ((c = getchar()) == EOF)
/* ... */
if ((ticks = clock()) < 0)
/* ... */
说明:这种在一个语句里进行捕捉返回值与测试异常的方法是较典型的惯用法。它有两个不同的含义:合法的数据值与异常值。代码必须解释这两种计算路径在哪儿知道它是正确的。
函数返回值的方法被运用于许多公共语言,Microsoft运用在它的COM模型。COM方法通过返回HRESULT来通报异常对象,Microsoft对这个值使用32位无符号整数。不像当才的例子只是讨论。COM的返回值只返回状态与异常信息,其它信息通过指针指向参数。
外部指针与C++引用参数是变种的函数返回值,但它们有以下几点不同:
你可以忽略或丢弃返回值。可是,外部参数绑定到相应的信息,你不能完全忽略它们,与返回值对比,函数与调用者把参数紧紧耦合着。
任何数值都可以经过外部参数返回,虽然函数返值只能发送一个值,但外部参数可以提供多个逻辑返回值。
返回值是临时对象:在调用函数之前它们是不存在的,它们在调用者结束后消失。异常对象的生命期比被调用函数更长。
结尾
本期围绕着介绍标准C支持的一般异常的处理方法。在第二期,我将介绍Microsoft扩展了这些标准C的方法:专用的异常处理宏与结构化异常处理(SEH)。
(译注:说来惭愧,本人对C是一知半解,对于在C++里,这些方法都不被推荐,以至于没有深入过,这篇文章我翻译得挺吃力的,如果有错误,请大家指点。)