C++ Primer 有感(异常处理)
1.异常是通过抛出对象而引发的。该对象的类型决定应该激活哪个处理代码。被选中的处理代码是调用链中与该对象类型匹配且离抛出异常位置最近的那个。
2.执行throw的时候,不会执行跟在throw后面的语句,而是将控制从throw转移到匹配的catch,该catch可以是同一函数中局部的catch,也可以在直接或间接调用发生异常的函数的另一个函数中。控制从一个地方传到另一地方,这有两个重要含义:
(1)沿着调用链的函数提早退出。
(2)一般而言,在处理异常的时候,抛出异常的块中的局部存储不存在了。
因为在处理异常的时候会释放局部存储,所以被抛出的对象就不能再局部存储,而是用throw表达式初始化一个称为异常对象的特殊对象。异常对象由编译器管理,而且保证驻留在可能被激活的任意catch都可以访问的空间。这个对象由throw创建,并被初始化为被抛出的表达式的副本。异常对象将传给对应的catch,并且在完全处理了异常之后撤销。(异常对象通过复制被抛出表达式的结果创建,该结果必须是可以复制的类型。)
3.当抛出一个表达式的时候,被抛出对象的静态编译时类型决定异常对象的类型。
4.用抛出表达式抛出静态类型时,比较麻烦的一种情况是,在抛出中对指针解引用。对指针解引用的结果是一个对象,其类型与指针的类型匹配。如果指针指向继承层次中的一种类型,指针所指对象的类型就有可能与指针的类型不同。无论对象的实际类型是什么,异常对象的类型都与指针的静态类型相匹配。如果该指针是一个指向派生类对象的基类类型指针,则那个对象将被分割,只抛出基类部分。
如果抛出指针本身,可能会引发比分割对象更严重的问题。具体而言,抛出指向局部对象的指针总是错误的,其理由与从函数返回指向局部对象的指针时错误的一样。
5. 栈展开指的是:当异常抛出后,匹配catch的过程。
抛出异常时,将暂停当前函数的执行,开始查找匹配的catch子句。沿着函数的嵌套调用链向上查找,直到找到一个匹配的catch子句,或者找不到匹配的catch子句。当catch结束的时候,在紧接在与try块相关的最后一个catch子句之后的点继续执行。
注意事项:
1. 在栈展开期间,会销毁局部对象。
① 如果局部对象是类对象,那么通过调用它的析构函数销毁。
② 但是对于通过动态分配得到的对象,编译器不会自动删除,所以我们必须手动显式删除。通常,编译器不撤销内置类型的对象。
2. 析构函数应该从不抛出异常。如果析构函数中需要执行可能会抛出异常的代码,那么就应该在析构函数内部将这个异常进行处理,而不是将异常抛出去。
原因:在为某个异常进行栈展开时,析构函数如果又抛出自己的未经处理的另一个异常,将会导致调用标准库 terminate 函数。而默认的terminate 函数将调用 abort 函数,强制从整个程序非正常退出。
3. 构造函数中可以抛出异常。但是要注意到:如果构造函数因为异常而退出,那么该类的析构函数就得不到执行。所以要手动销毁在异常抛出前已经构造的部分成员。
6.不能不处理异常。如果找不到匹配的catch,程序就调用库函数terminate。
7.在查找匹配的catch期间,找到的catch不必是与异常最匹配的那个catch,相反,将选择第一个找到的可以处理该异常的catch。
catch的匹配过程中,对类型的要求比较严格。异常的类型与catch说明符的类型必须完全匹配,除了下面的3种:
(1) 允许从非const到const的转换。也就是说,非const对象的throw可以与指定接受const引用的catch匹配。
(2) 允许从派生类到基类类型的转换。
(3) 将数组转换为指向数组类型的指针,将函数转换为指向函数类型的适当的指针。
在查找匹配的catch的时候,不允许标准算术转换和类类型的转换。(类类型的转化包括两种:通过构造函数的隐式类型转化和通过转化操作符的类型转化)。
8. catch子句中的异常说明符必须是完全类型,不可以为前置声明,因为你的异常处理中常常要访问异常类的成员。例外:只有你的catch子句使用指针或者引用接收参数,并且在catch子句内你不访问异常类的成员,那么你的catch子句的异常说明符才可以是前置声明的类型。
9. 和函数参数相同的地方有:
① 如果catch中使用基类对象接收子类对象,那么会造成子类对象分隔(slice)为父类子对象(通过调用父类的复制构造函数);
② 如果catch中使用基类对象的引用接受子类对象,那么对虚成员的访问时,会发生动态绑定,即会多态调用。
③ 如果catch中使用基类对象的指针,那么一定要保证throw语句也要抛出指针类型,并且该指针所指向的对象,在catch语句执行是还存在(通常是动态分配的对象指针)。
10. 和函数参数不同的地方有:
① 如果throw中抛出一个对象,那么无论是catch中使用什么接收(基类对象、引用、指针或者子类对象、引用、指针),在传递到catch之前,编译器都会另外构造一个对象的副本。也就是说,如果你以一个throw语句中抛出一个对象类型,在catch处通过也是通过一个对象接收,那么该对象经历了两次复制,即调用了两次复制构造函数。一次是在throw时,将“抛出到对象”复制到一个“临时对象”(这一步是必须的),然后是因为catch处使用对象接收,那么需要再从“临时对象”复制到“catch的形参变量”中; 如果你在catch中使用“引用”来接收参数,那么不需要第二次复制,即形参的引用指向临时变量。
② 该对象的类型与throw语句中体现的静态类型相同。也就是说,如果你在throw语句中抛出一个指向子类对象的父类引用,那么会发生分割现象,即只有子类对象中的父类部分会被抛出,抛出对象的类型也是父类类型。(从实现上讲,是因为复制到“临时对象”的时候,使用的是throw语句中类型的(这里是父类的)复制构造函数)。
③ 不可以进行标准算术转换和类的自定义转换:在函数参数匹配的过程中,可以进行很多的类型转换。但是在异常匹配的过程中,转换的规则要严厉。
④ 异常处理机制的匹配过程是寻找最先匹配(first fit),函数调用的过程是寻找最佳匹配(best fit)。
11.因为catch子句按出现次序匹配,所以使用来自继承层次的异常的程序必须将它们的catch子句排序,以便派生类型的处理代码出现在其基类类型的catch之前。(带有因继承相关的类型的多个catch子句,必须从最低派生类型到最高派生类型)
12.异常重新抛出
语法:使用一个空的throw语句。即写成:throw;
注意问题:
① throw; 语句出现的位置,只能是catch子句中或者是catch子句调用的函数中。
②重新抛出的是原来的异常对象,即上面提到的“临时变量”,不是catch形参。
③ 如果希望在重新抛出之前修改异常对象,那么应该在catch中使用引用参数。如果使用对象接收的话,那么修改异常对象以后,不能通过“重新抛出”来传播修改的异常对象,因为重新抛出不是catch形参,应该使用的是 throw e; 这里“e”为catch语句中接收的对象参数。
13.捕获所有异常(匹配任何异常)
语法:在catch语句中,使用三个点(…)。即写成:catch (…) 这里三个点是“通配符”,类似 可变长形式参数。
常见用法:与“重新抛出”表达式一起使用,在catch中完成部分工作,然后重新抛出异常。
14.构造函数的函数测试块
对于在构造函数的初始化列表中抛出的异常,必须使用函数测试块(function try block)来进行捕捉。语法类型下面的形式:
MyClass::MyClass(int i) try :member(i) { //函数体 } catch(异常参数) { //异常处理代码 }
注意事项:在函数测试块中捕获的异常,在catch语句中可以执行一个内存释放操作,然后异常仍然会再次抛出到用户代码中。关键字try出现在成员成初始化列表之前,并且测试块的复合语句包围了构造函数的函数体。catch子句既可以处理从成员列表中抛出的异常,也可以处理从构造函数函数体中抛出的异常。
15.异常抛出列表(异常说明 exception specification)
就是在函数的形参表之后(如果是const成员函数,那么在const之后),使用关键字throw声明一个带着括号的、可能为空的 异常类型列表。形如:throw() 或者 throw (runtime_error, bad_alloc) 。
含义:表示该函数只能抛出 在列表中的异常类型。例如:throw() 表示不抛出任何异常。而throw (runtime_error, bad_alloc)表示只能抛出runtime_error 或bad_alloc两种异常。
意外异常的处理:
如果程序中出现了意外异常,那么程序就会调用函数unexpected()。这个函数的默认实现是调用terminate函数,即默认最终会终止程序。
虚函数重载方法时异常抛出列表的限制:
在子类中重载时,函数的异常说明 必须要比父类中要同样严格,或者更严格。换句话说,在子类中相应函数的异常说明不能增加新的异常。或者再换句话说:父类中异常抛出列表是该虚函数的子类重载版本可以抛出异常列表的 超集。
函数指针中异常抛出列表的限制:
异常抛出列表是函数类型的一部分,在函数指针中也可以指定异常抛出列表。但是在函数指针初始化或者赋值时,除了要检查返回值和形式参数外,还要注意异常抛出列表的限制:源指针的异常说明必须至少和目标指针的一样严格。比较拗口,换句话说,就是声明函数指针时指定的异常抛出列表,一定要实际函数的异常抛出列表的超集。 如果定义函数指针时不提供异常抛出列表,那么可以指向能够抛出任意类型异常的函数。
16.标准库中的异常类
每个类所在的头文件在图下方标识出来.
标准异常类的成员:
① 在上述继承体系中,每个类都有提供了构造函数、复制构造函数、和赋值操作符重载。
②logic_error类及其子类、runtime_error类及其子类,它们的构造函数是接受一个string类型的形式参数,用于异常信息的描述;
③ 所有的异常类都有一个what()方法,返回const char* 类型(C风格字符串)的值,描述异常信息。
标准异常类的具体描述:
异常名称 |
描述 |
exception | 所有标准异常类的父类 |
bad_alloc | 当operator new and operator new[],请求分配内存失败时 |
bad_exception | 这是个特殊的异常,如果函数的异常抛出列表里声明了bad_exception异常,当函数内部抛出了异常抛出列表中没有的异常,这是调用的unexpected函数中若抛出异常,不论什么类型,都会被替换为bad_exception类型 |
bad_typeid | 使用typeid操作符,操作一个NULL指针,而该指针是带有虚函数的类,这时抛出bad_typeid异常 |
bad_cast | 使用dynamic_cast转换引用失败的时候 |
ios_base::failure | io操作过程出现错误 |
logic_error | 逻辑错误,可以在运行前检测的错误 |
runtime_error | 运行时错误,仅在运行时才可以检测的错误 |
logic_error的子类:
异常名称 |
描述 |
length_error |
试图生成一个超出该类型最大长度的对象时,例如vector的resize操作 |
domain_error |
参数的值域错误,主要用在数学函数中。例如使用一个负值调用只能操作非负数的函数 |
out_of_range |
超出有效范围 |
invalid_argument |
参数不合适。在标准库中,当利用string对象构造bitset时,而string中的字符不是’0’或’1’的时候,抛出该异常 |
runtime_error的子类:
异常名称 |
描述 |
range_error |
计算结果超出了有意义的值域范围 |
overflow_error |
算术计算上溢 |
underflow_error |
算术计算下溢 |
17.编写自己的异常类
1. 为什么要编写自己的异常类?
① 标准库中的异常是有限的;
② 在自己的异常类中,可以添加自己的信息。(标准库中的异常类值允许设置一个用来描述异常的字符串)。
2. 如何编写自己的异常类?
① 建议自己的异常类要继承标准异常类。因为C++中可以抛出任何类型的异常,所以我们的异常类可以不继承自标准异常,但是这样可能会导致程序混乱,尤其是当我们多人协同开发时。
② 当继承标准异常类时,应该重载父类的what函数和虚析构函数。
③ 因为栈展开的过程中,要复制异常类型,那么要根据你在类中添加的成员考虑是否提供自己的复制构造函数。
18.用类来封装资源分配和释放(RAII)
为什么要使用类来封装资源分配和释放?
为了防止内存泄露。因为在函数中发生异常,那么对于动态分配的资源,就不会自动释放,必须要手动显式释放,否则就会内存泄露。而对于类对象,会自动调用其析构函数。如果我们在析构函数中显式delete这些资源,就能保证这些动态分配的资源会被释放。
如何编写这样的类?
将资源的分配和销毁用类封转起来。在析构函数中要显式的释放(delete或delete[])这些资源。这样,若用户代码中发生异常,当作用域结束时,会调用给该类的析构函数释放资源。这种技术被称为:资源分配即初始化。(resource allocation is initialization,缩写为"RAII")。
19.auto_ptr的使用(非常重要)
“用类封装资源的分配和释放”是如此的重要,C++标准库为我们提供了一个模板类来实现这个功能。名称为auto_ptr,在memory头文件中。
auto_ptr类的成员如下:
函数 |
功能 |
auto_ptr <T> ap() | 默认构造函数,创建名为ap的未绑定的auto_ptr对象 |
auto_ptr<T> ap(p); | 创建名为 ap 的 auto_ptr 对象,ap 拥有指针 p 指向的对象。该构造函数为 explicit |
auto_ptr<T> ap1(ap2); | 创建名为 ap1 的 auto_ptr 对象,ap1 保存原来存储在 ap2 中的指针。将所有权转给 ap1,ap2 成为未绑定的 auto_ptr 对象 |
ap1 = ap2 | 将所有权 ap2 转给 ap1。删除 ap1 指向的对象并且使 ap1 指向 ap2 指向的对象,使 ap2 成为未绑定的 |
~ap | 析构函数。删除 ap 指向的对象 |
*ap | 返回对 ap 所绑定的对象的引用 |
ap-> | 返回 ap 保存的指针 |
ap.reset(p) | 如果 p 与 ap 的值不同,则删除 ap 指向的对象并且将 ap 绑定到 p |
ap.release() | 返回 ap 所保存的指针并且使 ap 成为未绑定的 |
ap.get() | 返回 ap 保存的指针 |
auto_ptr类的使用:
1. 用来保存一个指向对象类型的指针。注意必须是动态分配的对象(即使用new非配的)的指针。既不能是动态分配的数组(使用new [])指针,也不能是非动态分配的对象指针。
2. 惯用的初始化方法:在用户代码中,使用new表达式作为auto_ptr构造函数的参数。(注意:auto_ptr类接受指针参数的构造函数为explicit,所以必须显式的进行初始化)。
3. auto_ptr的行为特征:类似普通指针行为。auto_ptr存在的主要原因就是,为了防止动态分配的对象指针造成的内存泄露,既然是指针,其具有"*"操作符和"->"操作符。所以auto_ptr的主要目的就是:首先保证自动删除auto_ptr所引用的对象,并且要支持普通指针行为。
4. auto_ptr对象的复制和赋值是有破坏性的。①会导致右操作数成为未绑定的,导致auto_ptr对象不能放到容器中;②在赋值的时候,将右操作符修改为未绑定,即修改了右操作数,所以要保证这里的赋值操作符右操作数是可以修改的左值(然而普通的赋值操作符中,右操作数可以不是左值);③和普通的赋值操作符一样,如果是自我赋值,那么没有效果;④导致auto_ptr对象不能放到容器中。
5. 如果auto_ptr初始化的时候,使用默认构造函数,成为未绑定的auto_ptr对象,那么可以通过reset操作将其绑定到一个对象。
6. 如果希望测试auto_ptr是否已经绑定到了一个对象,那么使用get()函数的返回值与NULL进行比较。
auto_ptr的缺陷:
1. 不能使用auto_ptr对象保存指向静态分配的对象的指针,也不能保存指向动态分配的数组的指针。
2. 不能讲两个auto_ptr对象指向同一个对象。因为在一个auto_ptr对象析构以后,造成另一个auto_ptr对象指向了已经释放的内存。造成这种情况的两种主要常见原因是:①用同一个指针来初始化或者reset两个不同的auto_ptr对象;②使用一个auto_ptr对象的get函数返回值去初始化或者reset另一个auto_ptr对象。
3. 不能将auto_ptr对象放到容器中。因为其复制和赋值操作具有破坏性。
20.常见的异常处理问题
动态内存分配错误
①分配动态内存使用的是new和new[]操作符,如果他们分配内存失败,就会抛出bad_alloc异常,在new头文件中,所以我们的代码中应该捕捉这些异常。常见的代码形式如下:
try { //其他代码 ptr = new int[num_max]; //其他代码 } catch(bad_alloc &e) { //这里常见的处理方式为:先释放已经分配的内存,然后结束程序,或者打印一条错误信息并继续执行 }
②可以使用类似C语言的方式处理,但这时要使用的nothrow版本,使用"new (nothrow)"的形式分配内存。这时,如果分配不成功,返回的是NULL指针,而不再是抛出bad_alloc异常。
③可以定制内存分配失败行为。C++允许指定一个new 处理程序(newhandler)回调函数。默认的并没有new 处理程序,如果我们设置了new 处理程序,那么当new和new[] 分配内存失败时,会调用我们设定的new 处理程序,而不是直接抛出异常。通过set_new_handler函数来设置该回调函数。要求被回调的函数没有返回值,也没有形式参数。