C++异常处理相关用法及底层机制

一、异常的定义及C++中的异常处理

异常是指存在于运行时的反常行为,这些行为超出了函数正常功能的范围。典型的异常包括失去数据库连接以及遇到意外输入等。

异常检测:当程序的某部分检测到一个它无法处理的问题时,需要用到异常处理。此时,检测出问题的部分应该发出某种信号以表明程序遇到了故障,无法继续下去了,而且信号的发出方无须知道故障将在何处得到解决。一旦发出异常信号,检测出问题的部分也就完成了任务。

异常处理:如果程序中含有可能引发异常的代码,那么通常也会有专门的代码处理问题。异常处理部分就是用来对捕获的异常进行处理的。

异常处理机制 为程序中异常检测和异常处理这两部分的协作提供支持。

在C++中,异常处理包括:

  • throw表达式,异常检测部分使用throw表达式来表示它遇到了无法处理的问题。throw引发了异常

  • try-catch语句块,异常处理部分使用try-catch语句块处理异常。try{}后以一个或多个catch(){}子句结束,try{}中的代码抛出的异常通常会被某个catch子句处理。catch子句通常被称为异常处理代码。catch(...)意为捕获所有异常,如果有多个catch子句,catch(...)必须放在最后,否则后面的catch语句不可能得到执行。

  • 异常类,用于在throw表达式和相关的catch子句之间传递异常的具体信息。


二、异常的基本使用方式

所需头文件

#include<exception>    //定义了最通用的异常类exception,只报告异常的发生,不提供任何额外信息
#include<stdexcept>    //定义了几种最常用的exception类

抛出异常

void fun(){
    ···;
    throw runtime_error("xx error!");//抛出异常
    ···;
}

捕获异常

try{//try语句块中调用的fun()函数抛出异常
    fun();
}catch(runtime_error& e){//捕获runtime_error异常
    cout<<e.what()<<endl;//打印异常错误信息
}

三、异常处理

作用

​ 异常处理机制允许程序中独立开发的部分能够在运行时就出现的问题进行通信并做出相应的处理。异常能使得我们能够将问题的检测与解决过程分离开来。程序的一部分负责检测问题的出现,另一部分负责问题的解决。

(1)抛出异常

​ C++中通过throw表达式来引发(raised)异常,被抛出的表达式的类型(即异常类型)以及当前函数调用链共同决定了哪段处理代码(handler,即哪个try-catch块)将被用来处理该异常。

​ 当执行一个throw时,跟在throw后面的语句将不再被执行。程序的控制权从throw转移到与之最先匹配到的catch模块。这会引发两个后果:

  • catch至throw之间的所有函数提早退出
  • 被退出的函数内的对象将被销毁

​ 抛出一个异常后,程序将暂停当前函数的执行过程,并寻找与之匹配的catch子句。将会沿着函数调用链从内到外逐步检查,如果throw语句或调用该throw语句的函数或在该throw语句的调用链上的函数在try语句块内,且该try子句对应的catch子句中存在对该异常的处理,则程序进入该catch子句并从这里开始继续执行。这个过程被称为栈展开,即从最内层函数逐步向外层展开,直到找到匹配的catch语句或到最外层还没有找到匹配。如果一直到main函数还没有找到匹配的catch子句,程序将会调用标准库中的terminate函数,终止程序的执行。

​ 在栈展开的过程中,将沿着函数调用链逐步向外,内层的函数将会退出,这些函数中的局部对象将会被随之销毁。对于内置对象(int,double等),编译器无需任何操作,因为找到匹配的catch块后程序的函数栈将会退栈,调用链上的栈帧将会被销毁,这些对象自然失去了意义,也不会再被访问到。对于类对象而言,将会自动执行析构函数,具体原理后面将会介绍。对于动态分配的指针,退栈后无法再得到这些指针,因此没有办法找到相应的动态分配内存,会造成内存泄漏的问题。因此对于动态分配相关的资源管理问题,最好用类来管理,例如用构造函数new,析构函数delete,或直接使用智能指针。

以下是一个例子,可以看出异常处理链的执行情况以及类对象析构情况。

#include<iostream>
#include<stdexcept>
#include<memory>
using namespace std;
//A调用B,B调用C,C抛出异常,B catch所有异常并全部抛出,A捕获异常
void funA();
void funB();
void funC();

class ClassA
{
public:
	ClassA()try:pi(new int[len]) { cout << "ClassA building" << endl; }
	~ClassA() { cout << "ClassA deconstruction" << endl; }
};

void funA()
{
	try {
		funB();
	}catch (exception& e) {
		cout << "funA() -- catch --> ";
		cout << e.what() << endl;
	}
}
void funB()
{
	try {
		funC();
	}catch (runtime_error& e) {
		cout << "funB() -- catch --> ";
		cout << e.what() << endl;
	}
}

void funC()
{//该函数用于抛出异常,函数会提前退出,动态分配内存无法被释放,但类对象会被成功析构
	cout << "funC() -- throw之前" << endl;
	ClassA ca;
	int* a = new int(10);
	cout << "函数体内 new 分配内存" << endl;
	throw runtime_error("xx异常");           //抛出异常,之后的代码得不到执行
	cout << "funC() -- throw之后" << endl;
	delete a;
	cout << "函数体内 delete 释放内存" << endl;
}

int main()
{
    funA();
}

程序运行结果如下:

funC() -- throw之前
ClassA building
函数体内 new 分配内存
ClassA deconstruction
funB() -- catch --> xx异常

​ 可以看到,throw语句后面的代码全部没有执行,因此指针a所指向的动态内存空间永远也得不到释放,造成了内存泄漏,同时也能看到,ClassA的构造、析构均得到了执行,析构应该是在栈展开的过程中完成的。

不能让异常逃离析构函数,由于抛出异常后的栈展开过程将会执行析构函数来销毁对象,因此析构函数内部如果存在异常,是不能让异常逃离析构函数的,必须在析构函数内部就将异常捕获。否则,由于已经存在了一个异常,析构函数又向外抛出一个,异常调用链将会被破坏,程序将被终止。

(2)捕获异常

异常声明

catch(exception e)  //√
catch(exception& e) //√
catch(exception&& e)//×,不允许右值引用

​ 如果catch的参数是基类类型,可以用派生类类型对其进行初始化。如果catch参数是引用类型,将会动态绑定;如果是非引用类型,异常对象将被切割为基类对象。

重新抛出

在catch语句或catch语句调用的函数中,使用空的throw语句可以将异常重新抛出,交给更上层的函数处理异常。

void funA()
{
	try {
		funB();
	}catch (exception& e) {//捕获funB重新抛出的异常
		cout << "funA() -- catch --> ";
		cout << e.what() << endl;
	}
}
void funB()
{
	try {
		funC();
	}catch (runtime_error& e) {
		cout << "funB() -- catch --> ";
		cout << e.what() << endl;
		throw;//重新抛出
	}
}

捕获所有异常

使用catch(...)来捕获所有异常,catch(...)通常与throw重新抛出语句一起使用

try{
    ···
}catch(...){
    ···do something
    throw;
}
(3)构造函数中捕获异常

​ 由于构造函数分为初始化列表和函数体这两个部分。如果在函数体中使用try-catch语句块,是无法捕获初始化列表中可能出现的异常的。需要function try block形式的try-catch语句来处理构造函数初始值抛出的异常。

如下:

class ClassA
{
public:
	//函数try语句块,函数体内的try无法处理构造函数初始值列表中抛出的异常
	ClassA(long long len)try:pi(new int[len]) { cout << "ClassA building" << endl; }
	catch(bad_alloc& e){
		cout << "动态内存分配失败: ";
		cout << e.what() << endl;
	}
	~ClassA() { cout << "ClassA deconstruction" << endl; delete[] pi; }
private:
	int* pi;
};

其中try出现在构造函数初始值列表的冒号前,catch出现在函数体之后。

(4)noexcept异常说明

noexcept用于说明一个函数不会抛出异常。

void fun1() noexcept {
	cout << "fun1()" << endl;
}

void fun2() throw()  //throw()与noexcept等价
{
	fun1();
	cout <<"noexcept(fun1()) -> "<<noexcept(fun1()) << endl;;
}
void fun3() noexcept(true); //不抛出异常,等价于noexcept
void fun4() noexcept(false);//默认情况

如果一个函数提供了noexcept说明,却违反了它,仍然会编译通过,但是编译器将会调用terminate终止程序。

noexcept运算符

用于表示给定的表达式是否会抛出异常。

noexcept(fun1());//true
noexcept(funA());//false

函数指针的异常说明

1.函数指针与该指针指向的函数必须有一致的异常说明
2.做出不抛出异常声明的的指针只能指向不抛出异常的函数
3.可能抛出异常的指针可以指向不论是否抛出异常的函数
//fun1与fun2定义如上
void (*pf1)() noexcept = fun1; //√
void (*pf2)() = fun1;          //√
pf2 = funC;                    //√
pf1 = funC;    //不兼容的异常规范×                  

继承体系中的异常说明

对于存在虚函数的继承体系而言
如果一个虚函数承诺了它不抛出异常,派生的虚函数也必须做出同样的承诺
如果基类的虚函数允许抛出异常,派生类既可以抛出也可以不允许抛出异常

class Base {
public:
	virtual double f1(double) noexcept;
	virtual int f2() noexcept(false);
	virtual void f3();
};

class Derived : public Base {
public:
	double f1(double) override;
	//“double Derived::f1(double)”: 重写虚函数的限制性异常规范比基类虚成员函数“double Base::f1(double) noexcept”少
	int f2()noexcept(false);
	void f3() noexcept;
};

异常类继承体系

标准异常类继承体系
	exception
		|_________bad_cast(类型转换异常:例如将派生类指针指向基类)
		|
		|_________bad_alloc(内存分配异常:例如new分配空间时堆区没有足够的空间)
		|
		|                                    _____overflow_error(上溢)
		|_________runtime_error(运行时错误) |_____underflow_error(下溢)
		|               |___________________|_____range_error(生成的结果超出了有意义的值域范围)
		|                                   
		|_________logic_error(逻辑错误)
		                |________________________domain_error(域错误)
						                    |____invalid_argument(无效参数)
											|____out_of_range(使用一个超出有效范围的值,例如访问超出容器范围的变量)
											|____length_error(试图创建一个超出该类型最大长度的对象)

这里可以查看各个异常的作用(http://www.cplusplus.com/reference/stdexcept/),例如对于domain_error,解释如下:
This class defines the type of objects thrown as exceptions to report domain errors.
此类定义作为异常引发的对象类型,以报告域错误。
Generally, the domain of a mathematical function is the subset of values that it is defined for. For example, the square root function is only defined for non-negative numbers. Thus, a negative number for such a function would qualify as a domain error.
一般来说,数学函数的域是定义它的值的子集。例如,平方根函数只为非负数定义。因此,这样一个函数的负数将被视为域错误。
No component of the standard library throws exceptions of this type. It is designed as a standard exception to be thrown by programs.
标准库的任何组件都不会抛出这种类型的异常。它被设计成程序抛出的标准异常。


四、异常实现机制

​ C++的异常处理,改变了栈帧的结构,相比传统的无异常处理代码或C函数代码,栈帧中多出了包含piPrev、piHandler、nStep等部分的结构体EXP。异常处理部分采用链式结构,piPrev用于指向前一个栈帧中的异常处理部分,nStep用于定位try块的位置,piHandler指向完成异常捕获和栈展开所必须的数据结构(包括try块表和栈展开表)。是不是感觉一头雾水?关于这些奇怪的变量更加详细的解释可以在参考文献中找到原文,这里就讲点简单的。

​ 我们知道,函数中的局部变量、返回地址、寄存器中不够存储的参数等等都是存储在栈中的,每个函数中的这些信息组合起来称为一个栈帧(stack frame)。函数调用完后,栈指针将会指向上一个栈帧,而调用完的函数所在的栈帧将会消亡,其中所有的局部变量都失效,如果有指针指向其中的局部变量,对该指针的使用将会造成未定义的行为。

​ 对于C++中的类,其中的数据成员类似结构体,成员函数类似普通的函数,唯一的不同就是需要传入this指针。哪怕是含有virtual函数的类,其结构中多了vptr虚表指针,这样的类也能一如常规的方式存放在栈帧里。

​ 而存在异常处理的函数的栈帧与传统栈帧不同。它在栈帧中存在一个保存异常处理相关信息的结构体EXP,这个结构体是编译器生成的。假如存在如下的函数调用栈funA->funB->funC(a->b表示a调用b),由于EXP是链式存储的,而且异常捕获的原则是调用栈更近的catch块优先,因此funC.EXP.pre->funB.EXP(子函数的EXP指向调用它的函数的EXP)。EXP中有一个指针指向了一个处理异常相关的结构体EHDL,EHDL中保存了两个表:

  • tblUnwind,其中存放了栈展开过程中需要销毁的对象指针及其析构函数指针
  • tblTryBlocks,其中存放了try块的开始、结束位置,以及该try块对应的catch块表

捕获:当程序抛出异常后,首先在栈捕获表tblTryBlocks中,对其中每一个保存的try块信息,查看抛出异常的位置是否在try块的覆盖范围内。如果在,查看try块对应的catch块表,是否有匹配的catch块;如果不在,查看下一个try块;如果该栈帧的try块或catch块遍历完了还没有找到匹配的catch块,则说明该函数未能捕获异常,异常将交给调用它的函数来解决。因此当前函数后面的内容将不会得到执行,而且局部类变量将被析构,这时需要用到栈展开来析构类变量。

栈展开:在栈展开表tblUnwind中,对其中保存的每个局部 类对象(内置类型无需析构)通过保存的析构函数指针调用析构函数。这也是不能让异常逃离析构函数的原因,发生异常会进行栈展开,栈展开时会调用析构函数,如果这时候再遇到异常,异常处理的结构便会被破坏,程序将会终止。所有局部变量都被成功析构后,异常处理结构体利用指针指向上一个节点,处理上一个函数。

五、参考文献

异常处理的基础知识主要参考了C++ primer第5版。

异常处理的实现机制主要参考了这篇文章:C++异常机制的实现方式和开销分析,里面介绍了异常机制的实现细节,写得非常好,看完后对异常将会有更深刻的认知。

原文地址:https://www.cnblogs.com/sgawscd/p/13870406.html