潜心研究C++异常处理机制数日,有所得,与大家共享:
C++异常处理机制核心观点:
0.如果使用普通的处理方式:ASSERT,return等已经
足够简洁明了,请不要使用异常处理机制.
1.比C的setjump,longjump优秀.
2.可以处理任意类型的异常.
你可以人为地抛出任何类型的对象作为异常.
throw 100;
throw "hello";
...
3.需要一定的开销,频繁执行的关键代码段避免使用
C++异常处理机制.
4.其强大的能力表现在:
A.把可能出现异常的代码和异常处理代码隔离开,结构更清晰.
B.把内层错误的处理直接转移到适当的外层来处理,化简了处理
流程.传统的手段是通过一层层返回错误码把错误处理转移到
上层,上层再转移到上上层,当层数过多时将需要非常多的判断,
以采取适当的策略.
C.局部出现异常时,在执行处理代码之前,会执行堆栈回退,即为
所有局部对象调用析构函数,保证局部对象行为良好.
D.可以在出现异常时保证不产生内存泄漏.通过适当的try,catch
布局,可以保证delete pobj;一定被执行.
E.在出现异常时,能够获取异常的信息,指出异常原因.
并可以给用户优雅的提示.
F.可以在处理块中尝试错误恢复.保证程序几乎不会崩溃.
通过适当处理,即使出现除0异常,内存访问违例,也能
让程序不崩溃,继续运行,这种能力在某些情况下及其重要.
以上ABCDEF可以使你的程序更稳固,健壮,不过有时让程序崩溃似乎更
容易找到原因,程序老是不崩溃,如果处理结果有问题,有时很难查找.
5.并不是只适合于处理'灾难性的'事件.普通的错误处理也可以用异常机制
来处理,不过如果将此滥用的话,可能造成程序结构混乱,
因为异常处理机制本质上是程序处理流程的转移,不恰当的,过度的转移显然
将造成混乱.许多人认为应该只在'灾难性的'事件上使用异常处理,以避免异常
处理机制本身带来的开销,你可以认为这句话通常是对的.
6.先让程序更脆弱,再让程序更坚强.首先,它使程序非常脆弱,稍有差错,马上
执行流程跳转掉,去寻找相应的处理代码,以求适当的解决方式.
很像一个人身上带着许多药品,防护工具出行,稍有头晕,马上拿出清凉油;
遇到蚊子立刻拿出电蚊拍灭之.
WINDOWS:
7.将结构化异常处理结合/转换到C++异常对象,可以更好地处理WINDOWS程序
出现的异常.
8.尽一切可能使用try,catch,而不是win32本身的结构化异常处理或者
MFC中的TRY,CATCH宏.
用得恰到好处,方显C++异常之美妙!
1. 异常处理的使用
首先说明,千万别对异常处理钻牛角尖,那样会死人的(当然是烦死的)!
在C++编程处理中,我秉承这样一个思想,就是:能不用异常处理的就不用。因为造成的混乱实在是太——多了。如果能
用其他方法捕捉到错误并处理的话,誓死不用异常处理!呵呵,或许有点偏激,但我认为,这不失为一个避免不必要的错误的一个好办法。当什么分配内存失败,打
开文件失败之类的通常错误,我们只需用assert,abort之类的函数就解决问题了。也就是说,假如有足够的信息去处理一个错误,那么这个错误就不是
异常。
当然了,异常处理的存在也有它本身的意义和作用。不是你说不用就不用的,有些地方还非得用不可!
比如说,在当前上下文环境中,无法捕捉或确定的错误类型,我们就得用一个异常抛出到更大的上下文环境当中去。还有,异常处理的使用呢,可以使出错处理程序与“通常”代码分离开来,使代码更简洁更灵活。另外就是程序必不可少的健壮性了,异常处理往往在其中扮演着重要的角色。
OK,下面阐述一下。
2. 抛出异常
关——键字(周星驰的语气):throw
例——句:throw ExceptionClass(“oh, shit! it’s a exception!L “);
例句中,ExceptionClass是一个类,它的构造函数以一个字符串做为参数,用来说明异常。也就是说,在throw的时候,C++的编译器先构造一个ExceptionClass的对象,让它作为throw的返回值,抛——出去。同时,程序返回,调用析构。看下面这个程序:
#include <iostream.h>
class ExceptionClass{
char* name;
public:
ExceptionClass(char* name="default name") {
cout<<"Construct "<<name<<endl;
this->name=name;
}
~ExceptionClass() {
cout<<"Destruct "<<name<<endl;
}
void mythrow(){
throw ExceptionClass("o,my god");
}
};
void main(){
ExceptionClass e("haha");
try {
e.mythrow();
} catch(...) {
}
}
大家看看结果就知道了,throw后,调用当前类的析构,整个结束了这个类的历史使命。唉~~
3. 异常规格说明
如果我们调用别人的函数,里面有异常抛出,我用去查看它的源代码去看看都有什么异常抛出吗?可以,但是太——烦躁。比较好的解决办法,是编写带有异常抛出的函数时,采用异常规格说明,使我们看到函数声明就知道有哪些异常出现。
异常规格说明大体上为以下格式:
void ExceptionFunction(argument…) throw(ExceptionClass1, ExceptionClass2, ….)
对了,所有异常类都在函数末尾的throw()的括号中得以说明了,这样,对于函数调用者来说,是一清二楚了!
注意下面一种形式:
void ExceptionFunction(argument…) throw()
表明没有任何异常抛出。
而正常的void ExceptionFunction(argument…)则表示:可能抛出任何一种异常,当然就,也可能没有异常,意义是最广泛的哦。
4. 构造和析构中的异常抛出
55555,到了应该注意的地方了。
先看个程序,假如我在构造函数的地方抛出异常,这个类的析构会被调用吗?可如果不调用,那类里的东西岂不是不能被释放了??
程序:
#include <iostream.h>
#include <stdlib.h>
class ExceptionClass1{
char* s;
public:
ExceptionClass1(){
cout<<"ExceptionClass1()"<<endl;
s=new char[4];
cout<<"throw a exception"<<endl;
throw 18;
}
~ExceptionClass1(){
cout<<"~ExceptionClass1()"<<endl;
delete[] s;
}
};
void main(){
try{
ExceptionClass1 e;
}catch(...)
{}
}
结果为:
ExceptionClass1()
throw a exception
没了,没了,到此为止了!可是,可是,在这两句输出之间,我们已经给S分配了内存,哪里去了?内存释放了吗?没有,没有,因为它是在析构函数中释放的,哇!问题大了去了。怎么办?怎么办?
为了避免这种情况,应避免对象通过本身的构造函数涉及到异常抛出。即:既不在构造函数中出现异常抛出,也不应在构造函数调用的一切东西中出现异常抛出。否则,只有完蛋。
那么,在析构函数中的情况呢?我们已经知道,异常抛出之后,就要调用本身的析构函数,如果这析构函数中还有异常抛出的话,则已存在的异常尚未被捕获,会导致异常捕捉不到哩。
完,也就是说,我们不要在构造函数和析构函数中存在异常抛出。
5. 异常捕获
上边的程序不知道大家看懂了没,异常捕获已经在上面出现了也。
没错,就是try{…}catch(…){…}这样的结构!
Try后面的花括号中,就是有可能涉及到异常的各种声明啊调用啊之类的,如果有异常抛出,就会被异常处理器截获捕捉到,转给catch处理。先把异常的类和catch后面小括号中的类进行比较,如果一致,就转到后面的花括号中进行处理。
例如抛出异常是这么写的:
void f(){throw ExceptionClass(“ya, J”);}
假设类ExceptionClass有个成员函数function()在有异常时进行处理或相应的消息显示(只是做个例子哦,别挑我的刺儿)。
那么,我可以这么捕捉: try{f()}catch(ExceptionClass e){e.function()};
当然,象在上面程序中出现的一样,我可以在catch后用三个点来代表所有异常。如try{f()}catch(…){}。这样就截断了所有出现的异常。有助于把所有没出现处理的异常屏蔽掉(我是这么认为的J)。
异常捕获之后,我可以再次抛出,就用一个不带任何参数的throw语句就可以了,例如:try(f())catch(…){throw}
6. 标准异常
正象许多人想象的一样,C++肯定有自己的标准的异常类。
一个总基类:
exception 是所有C++异常的基类。
下面派生了两个异常类:
logic_erro 报告程序的逻辑错误,可在程序执行前被检测到。
runtime_erro 顾名思义,报告程序运行时的错误,只有在运行的时候才能检测到。
以上两个又分别有自己的派生类:
由logic_erro派生的异常类
domain_error 报告违反了前置条件
invalid_argument 指出函数的一个无效参数
length_error 指出有一个产生超过NPOS长度的对象的企图(NPOS为size_t的最大可表现值
out_of_range 报告参数越界
bad_cast 在运行时类型识别中有一个无效的dynamic_cast表达式
bad_typeid 报告在表达式typeid(*p)中有一个空指针P
由runtime_error派生的异常
range_error 报告违反了后置条件
overflow_error 报告一个算术溢出
bad_alloc 报告一个存储分配错误
呼呼,这是我这两天研究异常的总结报告。呼呼,累。
C++编译器如何实现异常处理1 -- 摘自互联网
与传统语言相比,
C++的一
项革命性创新就是它支持异常处理。传统的错误处理方式经常满足不了要求,而异常处理则是一个极好的替代解决方案。它将正常
代码和错误处理代码清晰的划分开来,程序变得非常干净并且容易维护。本文讨论了编译器如何实现异常处理。我将假定你已经熟悉异常处理的语法和机制。本文还
提供了一个用于V
C++的异常处理库,要用库中的处理程序替换掉VC++提供的那个,你只需要调用下面这个函数:
之后,程序中的所有异常,从它们被
抛出到堆栈展开(stack unwinding),再到调用catch块,最后到程序恢复正常运行,都将由我的异常处理库来管理。
与其它C++特性一样,C++标准并没有规定编译器应该如何来实现异常处理。这意味着每一个编译器的提供商都可以用它们认为恰当的方式来实现它。下面我
会描述一下VC++是怎么做的,但即使你使用其它的编译器或操作系统①,本文也应该会是一篇很好的学习材料。V
C++的实现方式是以windows系统的 结构化异常处理(SEH)②为基础的。
结构化异常处理—概述
在本文的讨论中,我认为异常或者是被明确的
抛出的,
或者是
由于除零溢出、空指针访问等引起的。当它发生时会产生一个中断,接下来控制权就会传递到操作系统的手中。操作系统将调用异常处理程序,检查从异常发生位置
开始的函数调用序列,进行堆栈展开和控制权转移。Windows定义了结构“EXCEPTION_REGISTRATION”,使我们能够向操作系统注册
自己的异常处理程序。
struct EXCEPTION_REGISTRATION
{
EXCEPTION_REGISTRATION* prev;
DWORD handler;
}; |
注册时,只需要创建这样一个结构,然后把它的地址放到FS段偏移0的位置上去就行了。下面这句汇编代码演示了这一操作:
mov FS:[0], exc_regp
prev字段用于建立一个EXCEPTION_REGISTRATION结构的链表,每次注册新的EXCEPTION_REGISTRATION时,我们都要把原来注册的那个的地址存到prev中。
那么,那个异常
回调函数长什么样呢?在excpt.h中,windows定义了它的原形:
EXCEPTION_DISPOSITION (*handler)(
_EXCEPTION_RECORD *ExcRecord,
void* EstablisherFrame,
_CONTEXT *ContextRecord,
void* DispatcherContext); |
不要管它的参数和返回值,我们先来看一个简单的例子。下面的程序注册了
一个异常处理程序,然后通过除以零产生了
一个异常。异常处理程序捕获了它,打印了一条消息就完事大吉并退出了。
#include <iostream>
#include <windows.h>
using std::cout;
using std::endl;
struct EXCEPTION_REGISTRATION
{
EXCEPTION_REGISTRATION* prev;
DWORD handler;
};
EXCEPTION_DISPOSITION myHandler(
_EXCEPTION_RECORD *ExcRecord,
void * EstablisherFrame,
_CONTEXT *ContextRecord,
void * DispatcherContext)
{
cout << "In the exception handler" << endl;
cout << "Just a demo. exiting..." << endl;
exit(0);
return ExceptionContinueExecution; //不会运行到这
}
int g_div = 0;
void bar()
{
//初始化一个EXCEPTION_REGISTRATION结构
EXCEPTION_REGISTRATION reg, *preg = ?
reg.handler = (DWORD)myHandler;
//取得当前异常处理链的“头”
DWORD prev;
_asm
{
mov EAX, FS:[0]
mov prev, EAX
}
reg.prev = (EXCEPTION_REGISTRATION*) prev;
//注册!
_asm
{
mov EAX, preg
mov FS:[0], EAX
}
//产生一个异常
int j = 10 / g_div; //异常,除零溢出
}
int main()
{
bar();
return 0;
}
/*-------输出-------------------
In the exception handler
Just a demo. exiting...
---------------------------------*/ |
注意EXCEPTION_REGISTRATION必须定义在栈上,并且必须位于比上一个结点更低的内存地址上,Windows对此有严格要求,达不到的话,它就会立刻终止进程。
函数和堆栈
堆栈是用来保存局部对象的连续内存区。更明确的说,每个函数都有一个相关的栈桢(stack
frame)来保存它所有的局部对象和表达式计算过程中用到的临时对象,至少理论上是这样的。但现实中,编译器经常会把一些对象放到寄存器中以便能以更快
的速度访问。堆栈是一个处理器(CPU)层次的概念,为了操纵它,处理器提供了一些专用的寄存器和指令。
图1是一个典型的堆栈,它示出了函数foo调用bar,bar又调用widget时的情景。请注意堆栈是向下增长的,这意味着新压入的项的地址低于原有项的地址。
通常编译器使用EBP寄存器来指示当前活动的栈桢。本例中,CPU正在运行widget,所以图中的EBP指向了widget的栈桢。编译器在编译时将
所有局部对象解析成相对于栈桢指针(EBP)的固定偏移,函数则通过栈桢指针来间接访问局部对象。举个例子,典型的,widget访问它的局部变量时就是
通过访问栈桢指针以下的、有着确定位置的几个字节来实现的,比如说EBP-24。
上图中也画出了ESP寄存器,它叫栈指针,指向栈的最后一项。在本例中,ESP指着widget的栈桢的末尾,这也是下一个栈桢(如果它被创建的话)的开始位置。
处理器支持两种类型的栈操作:压栈(push)和弹栈(pop)。比如,pop
EAX的作用是从ESP所指的位置读出4字节放到EAX寄存器中,并把ESP加上(记住,栈是向下增长的)4(在32位处理器上);类似的,push
EBP的作用是把ESP减去4,然后将EBP的值放到ESP指向的位置中去。
编译器编译一个函数时,会在它的开头添加一些代码来为其创建并初始化栈桢,这些代码被称为序言(prologue);同样,它也会在函数的结尾处放上代码来清除栈桢,这些代码叫做尾声(epilogue)。
一般情况下,序言是这样的:
Push EBP ; 把原来的栈桢指针保存到栈上
Mov EBP, ESP ; 激活新的栈桢
Sub ESP, 10 ; 减去一个数字,让ESP指向栈桢的末尾 |
第一条指令把原来的栈桢指针EBP保存到栈上;第二条指令通过让EBP指向主调函数的EBP的保存位置来激活被调函数的栈桢;第三条指令把ESP减去了
一个数字,这样ESP就指向了当前栈桢的末尾,而这个数字是函数要用到的所有局部对象和临时对象的大小。编译时,编译器知道函数的所有局部对象的类型和
“体积”,所以,它能很容易的计算出栈桢的大小。
尾声所做的正好和序言相反,它必须把当前栈桢从栈上清除掉:
Mov ESP, EBP
Pop EBP ; 激活主调函数的栈桢
Ret ; 返回主调函数 |
它让ESP指向主调函数的栈桢指针的保存位置(也就是被调函数的栈桢指针指向的位置),弹出EBP从而激活主调函数的栈桢,然后返回主调函数。
一旦CPU遇到返回指令,它就要做以下两件事:把返回地址从栈中弹出,然后跳转到那个地址去。返回地址是主调函数执行call指令调用被调函数时自动压
栈的。Call指令执行时,会先把紧随在它后面的那条指令的地址(被调函数的返回地址)压入栈中,然后跳转到被调函数的开始位置。图2更详细的描绘了运行
时的堆栈。如图所示,主调函数把被调函数的参数也压进了堆栈,所以参数也是栈桢的一部分。函数返回后,主调函数需要移除这些参数,它通过把所有参数的总体
积加到ESP上来达到目的,而这个体积可以在编译时知道:
当然,也可以把参数的总体积写在被调函数的返回指令的后面,让被调函数去移除参数,下面的指令就在返回主调函数前从栈中移去了24个字节:
取决于被调函数的调用约定(call convention),这两种方式每次只能用一个。你还要注意的是每个线程都有自己独立的堆栈。
C++和异常
回忆一下我在第一节中介绍的EXCEPTION_REGISTRATION结构,我们曾用它向操作系统注册了发生异常时要被调用的回调函数。VC++也是这么做的,不过它扩展了这个结构
的语义,在它的后面添加了两个新字段:
struct EXCEPTION_REGISTRATION
{
EXCEPTION_REGISTRATION* prev;
DWORD handler;
int id;
DWORD ebp;
}; |
VC++会为绝大部分函数③添加一个EXCEPTION_REGISTRATION类型的局部变量,它的最后一个字段(ebp)与栈桢指针指向的位置重
叠。函数的序言创建这个结构并把它注册给操作系统,尾声则恢复主调函数的EXCEPTION_REGISTRATION。id字段的意义我将在下一节介
绍。
VC++编译函数时会为它生成两部分数据:
a)异常回调函数
b)一个包含函数重要信息的数据结构,这些信息包括catch块、这些块的地址和这些块所关心的异常的类型等等。我把这个结构称为funcinfo,有关它的详细讨论也在下一节。
图3是考虑了异常处理之后的运行时堆栈。widget的异常回调函数位于由FS:[0]指向的异常处理链的开始位置(这是由widget的序言设置
的)。异常处理程序把widget的funcinfo结构的地址交给函数__CxxFrameHandler,__CxxFrameHandler会检查
这个结构看函数中有没有catch块对当前的异常感兴趣。如果没有的话,它就返回ExceptionContinueSearch给操作系统,于是操作系
统会从异常处理链表中取得下一个结点,并调用它的异常处理程序(也就是调用当前函数的那个函数的异常处理程序)。
这一过程将一直进行下去——直到处理程序找到一个能处理当前异常的catch块为止,这时它就不再返回操作系统了。但是在调用catch块之前(由于有
funcinfo结构,所以知道catch块的入口,参见图3),必须进行堆栈展开,也就是清理掉当前函数的栈桢下面的所有其他的栈桢。这个操作稍微有点
复杂,因为:异常处理程序必须找到异常发生时生存在这些栈桢上的所有局部对象,并依次调用它们的析构函数。后面我将对此进行详细介绍。
异常处理程序把这项工作委托给了各个栈桢自己的异常处理程序。从FS:[0]指向的异常处理链的第一个结点开始,它 依次调用每个结点的处理程序,告诉它堆栈正在展开。与之相呼应,这些处理程序会调用每个局部对象的析构函数,然后返回。此过程一直进行到与异常处理程序自 身相对应的那个结点为止。
由于catch块是函数的一部分,所以它使用的也是函数的栈桢。因此,在调用catch块之前,异常处理程序必须激活它所隶属的函数的栈桢。
其次,每个catch块都只接受一个参数,其类型是它希望捕获的异常的类型。异常处理程序必须把异常对象本身或者是异常对象的引用拷贝到catch块的栈
桢上,编译器在funcinfo中记录了相关信息,处理程序根据这些信息就能知道到哪去拷贝异常对象了。
拷贝完异常并激活栈桢后,处理程序将调用catch块。而catch块将把控制权下一步要转移到的地址返回来。请注意:虽然这时堆栈已经展开,栈桢也都
被清除了,但它们占据的内存空间并没有被覆盖,所有的数据都还好好的待在栈上。这是因为异常处理程序仍在执行,象其他函数一样,它也需要栈来存放自己的局
部对象,而其栈桢就位于发生异常的那个函数的栈桢的下面。catch块返回以后,异常处理程序需要“杀掉”异常对象。此后,它让ESP指向目标函数(控制
权要转移到的那个函数)的栈桢的末尾——这样就把(包括它自己的在内的)所有栈桢都删除了,然后再跳转到catch块返回的那个地址去,就胜利的完成整个
异常处理任务了。但它怎么知道目标函数的栈桢末尾在哪呢?事实上它没法知道,所以编译器把这个地址保存到了栈桢上(由前言来完成),如图3所示,栈桢指针
EBP下面第16个字节就是。
当然,catch块也可能抛出新异常,或者是将原来的异常重新抛出。处理程序必须对此有所准备。如果是抛出新异常,它必须杀掉原来的那个;而如果是重新抛出原来的异常,它必须能继续传播(propagate)这个异常。
这里我要特别强调一点:由于每个线程有自己独立的堆栈,所以每个线程也都有自己独立的、由FS:[0]指向的EXCEPTION_REGISTRATION链。