C++异常机制
异常机制概述
异常处理是C++的一项语言机制,用于在程序中处理异常事件。异常事件在C++中表示为异常对象(主要针对类来说)。
1. 基本概述
首先try块试图运行代码,若该代码出现异常,这时异常事件发生,程序使用throw关键字抛出异常表达式,抛出点称为异常出现点,由操作系统为程序设置当前异常对象,然后执行程序的当前异常处理代码块,依次匹配与发生异常的try块相对应的catch语句中的异常对象(只进行类型匹配,catch参数有时在catch语句中并不会使用到,比如throw数值时)。
若匹配成功,则执行catch块内的异常处理语句,然后接着执行try...catch...块之后的代码。如果在当前的try...catch...块内找不到匹配该异常对象的catch语句,则由更外层的try...catch...块来处理该异常;如果当前函数内所有的try...catch...块都不能匹配该异常,则递归回退到调用栈的上一层去处理该异常。如果一直退到主函数main()都不能处理该异常,则调用系统函数terminate()终止程序。
1 int myDevide(int a, int b){ 2 if (b == 0){ 3 4 //C++处理异常 5 //throw 1; 6 throw 1.1; 7 8 //C语言返回异常 9 //return -1; 当a = -b会误报错 10 } 11 return a / b; 12 } 13 14 void test02(){ 15 int a = 10; 16 int b = 0; 17 18 try{ //试一试 19 myDevide(a, b); 20 } 21 catch (int){ //捕获异常, 这种情况的catch参数在代码块中不会使用到 22 cout << "int类型异常" << endl; 23 } 24 catch (double){ 25 throw; //如果当前代码块不想处理该异常,可以通过throw向上抛出,当前代码块不能处理,也会向上抛出,调用上一层处理 26 cout << "double类型异常" << endl; 27 } 28 catch (...){ 29 cout << "其他类型异常" << endl; 30 } 31 } 32 33 int main(){ 34 35 try{ 36 test02(); 37 } 38 catch (char){ //上层异常处理,如果这里仍然不处理,会调用terminate函数,使程序中断 39 cout << "" << endl; 40 } 41 42 43 system("pause"); 44 return 0; 45 }
2. 自定义异常类
自定义的异常类进行异常抛出,这时会涉及到异常对象的复制构造,也会出现对象的构造、析构、销毁等一系列问题,这部分会在栈展开部分讨论。
1 class myException{ 2 public: 3 void printError(){ 4 cout << "自定义类抛出异常" << endl; 5 } 6 }; 7 8 void test02(){ 9 10 int a = 10; 11 int b = 0; 12 13 myException e; 14 15 try{ //试一试 16 if (b == 0){ 17 throw e; 18 } 19 } 20 catch (myException e){ //捕获异常, 这种情况的catch参数会在代码块中使用到 21 e.printError(); 22 } 23 }
throw关键字
throw语句必须包含在try块中,也可以是被包含在调用栈的外层函数的try块中,如上述代码中的myDevide()函数。
执行throw语句时,throw的表达式将作为对象被复制构造为一个新的对象,称为异常对象。异常对象放在内存的特殊位置,该位置既不是栈也不是堆,在window上是放在线程信息块TIB中。这个构造出来的新对象与本级的try所对应的catch语句进行类型匹配,类型匹配的原则在下面介绍。
在本例中,依据score构造出来的对象类型为int,与catch(int score)匹配上,程序控制权转交到catch的语句块,进行异常处理代码的执行。如果在本函数内与catch语句的类型匹配不成功,则在调用栈的外层函数继续匹配,如此递归执行直到匹配上catch语句,或者直到main函数都没匹配上而调用系统函数terminate()终止程序。
注意:当执行一个throw语句时,跟在throw语句之后的语句将不再被执行,throw语句的语法有点类似于return,因此导致在调用栈上的函数可能提早退出。
catch关键字
catch语句匹配被抛出的异常对象。
1. catch语句参数
如果catch语句的参数是引用类型,则该参数可直接作用于异常对象,即参数的改变也会改变异常对象,而且在catch中重新抛出异常时会继续传递这种改变。如果catch参数是传值的,则复制构函数将依据异常对象来构造catch参数对象。在该catch语句结束的时候,先析构catch参数对象,然后再析构异常对象。
在进行异常对象的匹配时,编译器不会做任何的隐式类型转换或类型提升。除了以下几种情况外,异常对象的类型必须与catch语句的声明类型完全匹配:
(1)允许从非常量到常量的类型转换(const,非const互转);
(2)允许派生类到基类的类型转换;
(3)数组被转换成指向数组(元素)类型的指针;
(4)函数被转换成指向函数类型的指针;
1 class myException{ 2 public: 3 void printError(){ 4 cout << "自定义类抛出异常" << endl; 5 } 6 }; 7 8 class sonException:public myException{ 9 public: 10 void printError_(){ 11 cout << "son自定义类抛出异常" << endl; 12 } 13 }; 14 15 void test02(){ 16 17 int a = 10; 18 int b = 0; 19 20 sonException e; 21 22 try{ //试一试 23 if (b == 0){ 24 throw e; //抛出的为子类异常,但父类捕获在子类捕获前,因此显示的为父类捕获 25 } 26 } 27 catch (myException e){ //父类捕获异常 28 e.printError(); 29 } 30 catch (sonException e0){ //子类捕获异常 31 e0.printError_(); 32 } 33 }
寻找catch语句的过程中,匹配上的未必是类型完全匹配那项,而在是最靠前的第一个匹配上的catch语句(我称它为最先匹配原则)。
注意:(1)派生类的处理代码catch语句应该放在基类的处理catch语句之前,否则先匹配上的总是参数类型为基类的catch语句,而能够精确匹配的catch语句却不能够被匹配上;
(2)使用catch(...){}可以捕获所有类型的异常,根据最先匹配原则,catch(...){}应该放在所有catch语句的最后面,否则无法让其他可以精确匹配的catch语句得到匹配。
2. catch抛出异常
在catch块中,如果在当前函数内无法解决异常,可以继续向外层抛出异常,让外层catch异常处理块接着处理。此时可以使用不带表达式的throw语句将捕获的异常重新抛出。
被重新抛出的异常对象为保存在TIB中的那个异常对象,与catch的参数对象没有关系,若catch参数对象是引用类型,可能在catch语句内已经对异常对象进行了修改,那么重新抛出的是修改后的异常对象;若catch参数对象是非引用类型,则重新抛出的异常对象并没有受到修改。
栈解旋、异常变量生命周期、RAII
1. 栈解旋
栈解旋,从try开始,到throw抛出异常之前,所有在栈上的对象都会被释放。
1 class Person 2 { 3 public: 4 Person() :m_A(0){ cout << "调用默认构造函数" << endl; } 5 Person(const Person&){ cout << "调用复制构造函数" << endl; } 6 ~Person(){ cout << "调用析构函数" << endl; } 7 private: 8 int m_A; 9 }; 10 void test02() 11 { 12 try 13 { 14 Person p; 15 throw 1; 16 } 17 catch (int) 18 { 19 cout << "int类异常"<< endl; 20 } 21 }
上述程序代码输出:
2. 异常变量生命周期
我们知道,在函数调用结束时,函数的局部变量会被系统自动销毁。另外,由于栈解旋,throw可能会导致调用链上的语句块提前退出,此时,语句块中的局部变量将按照构成生成顺序的逆序,依次调用析构函数进行对象的销毁,为此我们讨论一下异常变量的生命周期。
(1)如果catch参数为Person p,会调用拷贝构造函数,多一分开销。
1 class Person 2 { 3 public: 4 Person() :m_A(0){ cout << "调用默认构造函数" << endl; } 5 Person(const Person&){ cout << "调用复制构造函数" << endl; } 6 ~Person(){ cout << "调用析构函数" << endl; } 7 private: 8 int m_A; 9 }; 10 11 12 void test020() 13 { 14 try 15 { 16 Person p; //调用默认构造,throw前析构掉 17 throw p; //throw表达式p作为对象被复制构造为一个新的对象,又叫初始化异常对象 18 } 19 catch (Person p) //类对象作形参,调用拷贝构造函数,这里异常变量复制构造catch参数对象 20 { 21 cout << "Person类异常"<< endl; 22 } 23 }
(2)若catch参数为Person &p,则只有一份数据
1 void test021() 2 { 3 try 4 { 5 Person p; //调用默认构造,throw前析构掉 6 throw p; //throw表达式p作为对象被复制构造为一个新的对象,又叫初始化异常对象 7 } 8 catch (Person &p) //这里是引用,直接是异常对象 9 { 10 p.test(); 11 cout << "Person类异常" << endl; 12 } 13 }
(3)如果catch参数为Person *p,若在栈上开辟则提前释放掉,若在自由存储区开辟(new)则需手动释放。
1 void test022() 2 { 3 try 4 { 5 Person *p = NULL; //栈上开辟,没有调用任何构造函数和析构函数,throw自动销毁变量p,同时还有对应开辟的内存 6 throw p; //仅仅是复制地址 7 } 8 catch (Person *p) //栈上创建指针,匹配上述地址 9 { 10 p->test(); //虽然这里没报错,但是地址对应的内存已经销毁掉,这里相当于野指针 11 cout << "Person类异常" << endl; 12 } 13 }
1 void test023() 2 { 3 try 4 { 5 Person *p = new Person(); //自由存储区开辟,不会自动析构,要手动释放,调用默认构造 6 throw p; //throw地址时,不会调用构造和析构函数 7 } 8 catch (Person *p) //栈上创建指针,匹配上述地址 9 { 10 p->test(); 11 cout << "Person类异常" << endl; 12 delete p; //自觉释放对象 13 } 14 }
RAII机制有助于解决这个问题,RAII(Resource acquisition is initialization,资源获取即初始化)。它的思想是以对象管理资源。为了更为方便、鲁棒地释放已获取的资源,避免资源死锁,一个办法是把资源数据用对象封装起来。程序发生异常,执行栈展开时,封装了资源的对象会被自动调用其析构函数以释放资源。C++中的智能指针便符合RAII。关于这个问题详细可以看《Effective C++》条款13.
异常多态引出标准库异常
利用多态实现printError同一个接口的调用,抛出不同的错误对象显示不同的错误提示。
1 class myException{ 2 public: 3 void printError(){ 4 cout << "自定义类抛出异常" << endl; 5 } 6 }; 7 8 class sonException:public myException{ 9 public: 10 void printError_(){ 11 cout << "son自定义类抛出异常" << endl; 12 } 13 }; 14 15 void test02(){ 16 17 int a = 10; 18 int b = 0; 19 20 sonException e; 21 22 try{ 23 if (b == 0){ 24 throw e; 25 } 26 } 27 catch (sonException e0){ //子类捕获异常 28 e0.printError_(); 29 } 30 catch (myException e){ //父类捕获异常 31 e.printError(); 32 } 33 }
使用标准库异常
标准库异常头文件: #include <stdexcept> ,抛出异常: throw out_of_range("越界"); ,异常捕获: catch(out_of_range& e){ e.what(); }
其中,标准库中的异常函数有很多,全部继承于基类exception,其中的what方法是将我们抛出异常的输入字符串输出。
异常机制与构造函数
构造函数可以通过函数体进行对象初始化,也可以通过初始化列表进行对象初始化。
(1)对象生命周期何时开始
一个构造函数成功执行完毕,并成功返回之时,也就是构造函数成功执行到函数体尾,没有发生异常。
(2)对象生命周期何时结束
当一个对像的析构函数开始执行,也就是达到析构函数开始指出,这里暂且不讨论析构函数是否发生异常,只要进入析构函数体,该对象生命周期就已经结束
(3)在生命周期开始之前,与生命结束之后,对象处于什么状态
这时候“对象”已不是对象。理论上“它”根本就不存在
(4)接着第三个答案,如果构造函数异常,对象处于什么状态?
构造函数异常,即构造函数甚至没有到达函数体的尾部,即对象的生命周期还没有开始,所以他根本不是一个的对象,或者说它什么都不是,所以更不会执行析构函数了。
1. 构造函数可以抛出异常吗,有什么问题?
构造函数中应该避免抛出异常。
(1)构造函数中抛出异常后,对象的析构函数将不会被执行;(2)构造函数抛出异常时,本应该在析构函数中被delete的对象没有被delete,会导致内存泄露;(3)当对象发生部分构造时,已经构造完毕的子对象(非动态分配)将会逆序地被析构。
注意:构造函数中如果发生异常,必须在构造函数抛出异常之前,把系统资源释放掉,以防止内存泄露。
对于函数体初始化对象的内存泄漏可以重新设计构造函数,捕获所有异常,释放掉申请的所有内存空间,或者使用智能指针;如果利用初始化列表初始化对象的内存泄漏请看下一小节。
1 class C{ 2 public: 3 //利用函数体初始化对象,发生异常时,析构函数不能调用,无法释放掉动态分配的内存 4 //C(){ 5 // this->m_Data = new char[1]; 6 // cout << "construct C default" << endl; 7 // throw 1.1; 8 //} 9 10 11 //对函数体中的异常进行捕获,处理,必须将动态分配内存先释放掉 12 C(){ 13 try{ 14 this->m_Data = new char[1]; 15 cout << "调用C类构造函数" << endl; 16 throw 1.1; //故意在默认构造函数中抛出异常 17 } 18 catch (double){ 19 if (this->m_Data != NULL){ 20 delete this->m_Data; 21 this->m_Data = NULL; 22 } 23 24 cout << "C类构造异常" << endl; 25 } 26 } 27 28 ~C(){ 29 cout << "调用C类析构函数" << endl; 30 if (this->m_Data != NULL){ 31 delete this->m_Data; 32 this->m_Data = NULL; 33 } 34 } 35 36 char *m_Data; 37 }; 38 39 void test01(){ 40 C c; 41 }
2. 初始化列表的异常怎么捕获?
初始化列表构造,当初始化列表出现异常时,程序还未进入函数体,因此函数体中的try-catch不能执行,catch也无法处理异常。可以通过函数try块解决该问题。
注意:构造函数的function try block处理初始化列表异常,主要用于转化(translate)从基类或成员子对象的构造函数抛出异常。
1 //B作为A的成员对象, B中构造函数抛出异常 2 //此时A中的构造函数和B构造函数为同一层,最外层为A a;语句 3 //如果B构造函数单独处理异常,则可以不向上抛出异常 4 //如果A构造函数单独处理异常,采用函数try块,必须向上抛出 5 //如果采用普通try块可以不用向上抛出,将指针先置空,再采用普通try块 6 class B{ 7 public: 8 B(){ 9 try{ 10 cout << "construct B default" << endl; 11 throw 1.1; //故意在默认构造函数中抛出异常 12 } 13 catch (double){ cout << "B自己捕获异常" << endl; } 14 } 15 16 B(int num){ 17 age = num; 18 cout << "constructor B ,age =" << num << endl; 19 } 20 ~B(){ 21 cout << "destructor B ,age=" << age << endl; 22 } 23 private: 24 int age; 25 }; 26 27 class A{ 28 public: 29 30 //指针置空,普通try块 31 A() :_data(new char[1]), b(B(10)), bp(NULL){ 32 33 try{ 34 this->bp = new B(); 35 cout << "construct A " << endl; 36 *_data = '\0'; 37 38 } 39 catch (double){ 40 if(_data != NULL){ 41 cout << "释放_data" << endl; 42 delete[] _data; 43 _data = NULL; 44 } 45 cout << "B构造异常" << endl; 46 //throw; 47 } 48 49 50 } 51 52 //函数try块 53 //A() try :_data(new char[1]), b(B(10)), bp(new B()){ 54 // cout << "construct A " << endl; 55 // 56 // *_data = '\0'; 57 // 58 //} 59 //catch (double){ 60 // if (_data != NULL){ 61 // cout << "释放_data" << endl; 62 // delete[] _data; 63 // _data = NULL; 64 // } 65 // 66 // cout << "B构造异常" << endl; 67 // throw; 68 //} 69 70 ~A(){ 71 cout << "destructor A" << endl; 72 delete[] _data; 73 delete bp; 74 75 } 76 private: 77 char *_data; 78 B b; 79 B *bp; 80 }; 81 82 83 int main(){ 84 85 try{ A a; } 86 catch (double){ 87 cout << "B" << endl; 88 } 89 system("pause"); 90 return 0; 91 }
注意:函数try块中的try出现在表示构造函数初始值列表的冒号以及表示构造函数体的花括号之前,与这个try关联的catch既能处理构造函数体抛出的异常,也能处理成员初始化列表抛出的异常。如果函数try块中单独处理异常,则需向上抛出异常,单独处理还是会调用terminate函数终止。
异常机制与析构函数
1. 析构函数可以抛出异常吗,有什么问题?
C++标准指明析构函数不禁止、不应该抛出异常。如果对象在运行期间出现了异常,C++异常处理模型有责任清除那些由于出现异常所导致的已经失效了的对象,并释放对象原来所分配的资源, 这就是调用这些对象的析构函数来完成释放资源的任务,所以从这个意义上说,析构函数已经变成了异常处理的一部分。
(1)其他正常,仅析构函数异常。 如果析构函数抛出异常,则异常点之后的程序不会执行,如果析构函数在异常点之后执行了某些必要的动作比如释放某些资源,则这些动作不会执行,会造成诸如资源泄漏的问题。
(2)其他异常,且析构函数异常。 通常异常发生时,c++的机制会调用已经构造对象的析构函数来释放资源,此时若析构函数本身也抛出异常,则前一个异常尚未处理,又有新的异常,会造成程序崩溃的问题。
2. 析构函数如何处理异常?
(1)若析构函数抛出异常,调用std::abort()来终止程序;
(2)在析构函数中catch捕获异常并作处理,吞下异常;
(3)如果客户需要对某个操作函数运次期间抛出的异常做出反应,class应该提供普通函数执行该操作,而非在析构函数中。
noexcept修饰符和noexcept操作符