首页 > 学院 > 开发设计 > 正文

深入浅出话异常

2019-11-17 05:26:02
字体:
来源:转载
供稿:网友
标准C的处理机制

  标准C提供了几个异常治理机制,这些在标准C++也可用,虽然相关的头文件名有了改变:旧的C标准头文件名从<name.h>映射到新的C++标准头文件名<cname>。(头文件名的前缀C是为了记忆,指明它们是标准C的库文件)

  虽然在C++的向后兼容里保留了C的头文件,但我劝告你在任何可能的地方使用新的头文件。对于许多实际使用中,最大的改变是在新的头文件与namespace std内进行声明。请看以下示例:

#include <stdio.h>//在标准C里被替换成#include <cstdio>

FILE *f = fopen("blarney.txt", "r");

std::FILE *f = std::fopen("blarney.txt", "r");//or the more C-esque

#include <cstdio>
using namespace std;

FILE *f = fopen("blarney.txt", "r");
  不幸的是,Microsoft's Visual C++不能在新的头文件与namespace std同时具备的条件下进行声明,即使这种行为是标准C必需的。除非等到Visual C++支持这种行为,我将在本行使用旧的C风格名字。

  (对于像Microsft的库供给商来说,实现这些C库头文件的正确性需要维护与测试两套不同的代码,这是一项艰苦的任务,且不会带来任何价值)

  绝对终止

  这是一种彻底忽略异常的方法,大概这种简单的响应是一种安全的退出方法。在一些情形里,这是最正确的方法。

Before you scoff, consider that some exceptions betray a condition so severe that reasonable recovery is unlikely anyway. Perhaps the best example is malloc of a small object returning NULL. If the free store manager can't scrape together a few spare contiguous bytes, your PRogram's robustness is severely compromised, and the odds of elegant recovery are slim.
  C库头文件<stdlib.h>提供了两个不是相当完美的程序函数:abort与exit,这些函数实现了异常的生命期在第4、5阶段。两者不返回它的调用者,并且结束程序。

  虽然两者在概念上是相关的,但使用它们的结果是不同的:

  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;
}

/* When run yields
within 'atexit_handler_2'
within 'atexit_handler_1'

and returns a success code to calling environment.
*/
(注重:假如你的程序在main函数结束时没有显式调用exit,那么你用atexit注册的处理函数也会被调用)。

abort与exit都不将控制权返回给调用者,并且结束程序。 条件结束
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终止程序。

  //*******************************************************//

  在这个定义里,当定义了预处理符号NDEBUG的时候,断言是无效的,这意味着assert断言宏只在你的Debug版本中有效。在Release版本里,assert断言宏不进行任何计算。由于这个而会引起一些侧面效里,比如:


/* debug version */
#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 version */
#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表达式不能包含有侧面影响的代码。

  (译注:切记assert中不能存在任何实质性的动作。Microsoft提供了ASSERT和VERIFY等等宏,其中VERIFY可以用在发布版内,不过这既非C/C++标准,功能亦不够强大。)

  只在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典型是用在调试逻辑错误,它永远不会存在于发行程序里。

/* 'f' never called by other programs */
static void f(int *p)
{
assert(p != NULL);
/* ... */
}
  我说明一下逻辑错误与其它运行里的错误关于使用assert的区别:

/* ...get file 'name' from user... */
FILE *file = fopen(name, mode);
assert(file != NULL); /* 相当可疑的用法???questionable use */
  这种错误出现在异常表达式里,但它不是BUG,它是运行时异常,assert可能会不正确地响应,你应该使用其它机制,我在下面介绍。

  非局部Goto

  对比于abort与exit,goto 让你有更多地治理异常的方法,不幸的是gotos是局部的(Local):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)

By having two classes of return values, setjmp lets you determine how it's being used. When setting j, setjmp works as you normally expect; but as the target of a long jump, setjmp "wakes up" from outside its normal context.
  假如使用longjmp来引发终止异常,setjmpgoto到正确的异常处理方法里。

#include <setjmp.h>
#include <stdio.h>

jmp_buf j;
void raise_exception(void)
{
printf("exception raised/n");
longjmp(j, 1); /* jump to exception handler */
printf("this line should never appear/n");
}

int main(void)
{
if (setjmp(j) == 0)
{
printf("'setjmp' is initializing 'j'/n");
raise_exception();//Restore context
printf("this line should never appear/n");
}
else
{
printf("'setjmp' was just jumped into/n");
/* this code is the exception handler */
}
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治理包(虽然较原始)。这个治理包定义了设置event与signal,连同标准的引发与处理方法。那些signals可在异常表达式或不同的扩展event里引发它们。这也是要讨论的目的。我只集中在异常signal. 

  对于使用这些治理包,应该包含标准头文件<signal.h>,这个头文件定义了raise与signal函数,sig_atomic_t类型与开始执行signal event的宏SIG。在标准要求里有6个signal宏,但你的库实现者可以增加其它。但设置signal的函数定义固定在<signal.h>里,你不能扩展你自已的signal的设置函数。调用raise来引发signal,并进入到相应的处理过程。运行时系统提供了默认的处理方法,但你可以安装你自已的signal行为。处理方法通过sig_atomic_t来与外部程序进行通信.对于类型名字的建议,分配给每一对象是原子方式,或中断安全(interrupt-safe)。

  当你注册signal处理方法的时候,一般你要提供处理函数地址。每一个函数必需接受int值,且返回void。在这种方法,signal处理方法象setjmp;只有异常上下文能接收单个整数:

void handler(int signal_value);

void f(void)
{
signal(SIGFPE, handler); /* 注册处理过程*/
/* ... */
raise(SIGFPE); /* 通过 'SIGFPE'来调用处理过程 */
}
  有两种安装指定处理方法可供选择:

  signal(SIGxxx, SIG_DFL),//使用系统默认的处理方法.

  signal(SIGxxx, SIG_IGN), //告诉系统忽略signal。

  在所有情形里,signal返回指向先前的处理过程的指针或SIG_ERR(意味着注册失败)

  当处理方法被调用的时候,这意味signal开始异常处理。而且你可以在处理方法里自由调用abort,exit或longjmp来效地结束异常。一些有趣的地方:实际上,abort自已在内部也调用raise(SIGABRT),默认的SIGABRT异常处理方法显示诊断信息与结束程序。但你可以安装你自已的SIGABRT异常处理方法来改变这种行为:

  但你不能改变abort的终止程序的行为,以下是abort的相似代码:

void abort(void)
{
raise(SIGABRT);
exit(EXIT_FAILURE);
}

  这儿,假如你SIGABRT异常处理方法返回后,abort也结束程序。

  在标准C库里,在signal异常处理方法行为也是有限制的。请看标准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) /* Stage 1: error occurred */
return -1; /* Stage 2: generate exception object */
/* ... */
}

int main(void)
{
if (f() != 0) /* Stage 3: detect exception */
{
/* Stage 4: handle exception */
}
/* Stage 5: recover */
}
  返回值是标准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)。

发表评论 共有条评论
用户名: 密码:
验证码: 匿名发表