C++之异常处理
目录
- 总说
- throw表达式
- try……catch语句
- noexcept
- 构造/析构与异常
- 标准异常类
- 问题汇总
总说
- 异常安全两个条件:
a. 不泄漏任何资源;——通过资源管理类的方式:用自定义类封装锁之类的资源;用智能指针
b. 不允许数据败坏 - 异常安全函数提供以下三个保证之一:
a. 基本承诺:
(1). 异常被抛出时程序内的所有事物仍然保持在有效的状态下,没有任何数据结构遭到破坏。
(2). 程序的现实状态可以出错,如抛出异常就难以拥有之前定好的背景图等业务类的东西
b. 强烈保证:成功便罢,如果失败就回复到函数调用前的状态——copy and swap:为打算修改的对象做出一份副本,然后在副本上做必要的修改,待完全成功再置换
c. 不抛掷异常 - 当一个异常发生时,编译系统必须完成以下事情:
a. 检验发生throw操作的函数
b. 决定throw操作是否发生在try区段内
(1). 若是,把发生异常的类型和每一个catch子句进行对比
(2). 吻合:交到catch子句中
(3). 不吻合:
□ 摧毁所有当前局部对象
□ 从堆栈中将当前对象推出去
□ 进行到程序堆栈的下一个函数中去,重复上述第二点开始的操作 - 异常安全编程基本原则
a. 明确哪些操作不会抛出异常
b. 确保析构函数不会抛出异常
c. 避免异常发生时的资源泄露,这些资源包括动态内存,文件,互斥锁等
throw表达式
- throw关键字后紧随一个表达式,其中表达式的类型就是抛出的异常类型;
- 如果抛出一个空的异常,是捕获不到的。
- 当执行一个throw时,跟在throw后面的语句将不再执行,程序的控制权从throw转移到与之匹配的catch模块,有两个重要含义:
- 沿着调用链的函数会提早退出,前面如果分配了资源,然后发生了异常,则后面释放资源的操作不会执行,就会造成资源泄露,所以要注意
- 一旦程序开始执行异常处理代码,则沿着调用链创建的对象将被销毁
- 如果没有找到catch的子句,程序将调用标准库函数terminate终止程序
- 重新抛出:
- 只写一个throw:将当前的异常对象沿着调用链向上传递
- 捕获到异常对象w后再throw w:额外多拷贝一次;如果异常类型是派生类,捕获的是基类型,这个操作抛出去的是静态类型:基类型;综合,不建议使用这种方式——未验证
- 异常对象:
- 编译器使用异常抛出表达式来对异常对象进行拷贝初始化,因此throw语句中的表达式必须要有完全类型,如果该表达式是类类型的话则相应的类必须拥有一个可访问的析构函数和一个可访问的拷贝构造或移动构造;如果该表达式是数组类型或函数类型,则表达式将被转换成与之对应的指针类型;
- 异常对象位于编译器管理的空间中(到底在堆上还是栈上),编译器最终无论调用哪个catch子句都能访问该空间,当异常处理完后异常对象被销毁。如同从函数中返回指向局部对象的指针,抛出一个指向局部对象的指针也是错误的行为
- 当抛出一条表达式时,该表达式的静态编译类型决定了异常对象的类型,如果一条throw表达式解引用一个基类指针,而该指针实际指向的是派生类对象,则抛出的对象将被切割掉一部分,只有基类部分被抛出
- 坑:
- throw "xxx"这种形式用
catch (const char c)/catch (char* c)/catch (const string& c)
都是捕获不到的,throw string("xxx")
这样可以用catch (const string& c)
捕获到 - catch子句引用类型捕获和值类型捕获不能同时存在,否则编译器报错
- throw "xxx"这种形式用
try……catch语句
- catch参数:
- 参数声明的类型决定了所能捕获的异常类型。这个类型必须是完全类型,可以是左值引用,但不能是右值引用
- 匹配规则与函数参数类似,但catch异常声明和匹配受到更到限制,调用函数时程序的控制权会返回到函数调用处,抛出异常时控制权不会回到抛出异常的地方:
- 类型转换:
□ 允许从非常量向常量的类型转换
□ 允许派生类向基类转换,但如果catch的参数是非引用类型,异常对象将被切掉一部分
□ 数组转换成指针,函数转换成函数指针
□ 除此而外,包括算术类型和类类型转换在内,其他所有转换规则都不能在匹配catch的过程中使用,比如catch double无法捕获抛出的int——还有哪些转换规则? - 不论通过传值捕获还是引用捕获,被抛出的异常对象不论是局部还是全局的(静态或全局变量)都将进行拷贝工作,C++规范要求为异常抛出的对象必须被复制
□ 所以抛出异常运行速度要比参数传递慢
□ 如果是传值捕获,会建立两个对象的拷贝:拷贝一个临时对象;将临时对象拷贝到catch的参数中;如果是传引用捕获,只拷贝一个临时对象;vs实测发现传值只有一次拷贝构造,传引用没有拷贝,是不是编译器做了优化? - 当异常对象被拷贝时,拷贝操作是由对象的静态类型而不是动态类型的拷贝构造函数完成的
○ 捕获参数类型:
a. 传指针捕获:优点:避免对象拷贝;缺点:异常对象很难管理,且不符合C++语言本身的规范,四个标准异常都不是指向对象的指针;结论:避免使用
b. 传值捕获:缺点:异常对象被拷贝两次;会产生切割问题,派生类的异常对象被作为基类异常对象捕获时派生类的行为就被切掉了;结论:不推荐使用
c. 传引用捕获:优点:异常对象只被拷贝一次,避免切割;结论:推荐使用
- 类型转换:
- catch语句是按照出现的顺序逐一匹配,所以越是专门的catch越应该置于整个catch列表的前端
- catch捕获异常并处理完之后不同于throw后面的语句不执行,catch所在的函数会正常走完
- 可以通过只写一个throw不包含任何表达式在一条catch子句中重新抛出异常,将当前的异常对象沿着调用链向上传递;如果catch子句改变了参数的内容,则只有当catch异常声明是引用类型时对参数所做的改变才会被保留并继续传播,注意:只是在继续传播的时候改变了参数值,throw出来的对象值并没有做任何改变。如下
catch(my_error &eObj) { eObj.status = errCodes::serverErr; throw; } // 修改了异常对象
catch(other_error eObj) { eObj.status = errCodes::badErr; throw; } // 异常对象的status成员并没有改变
- 捕获所有异常:既能单独出现,也能与其他几个catch子句一起出现
- 用省略号,形如catch(…)
- 用所有异常的基类Exception
noexcept
跟在函数的参数列表后面,指定某个函数不会抛出异常
- 好处:对编译器来说,预先知道某个函数不抛出异常大有裨益:
- 有助于简化调用该函数的代码
- 编译器确认不会抛出异常就能执行某些特殊的优化操作
- 两种用法
1. 跟在函数参数列表后面时是异常说明符;
a. 必须要出现在该函数的所有定义和声明语句之后
b. 可以在函数指针的声明与定义中指定noexpect
c. 在typedef和类型别名中则不能出现
d. 在类的成员函数中noexcept说明符需要在const及引用限定符之后,而在final,ovriride或虚函数的=0之前
e. 接受一个可选的实参,是个常量表达式,该实参必须可转换为bool类型,如果实参是true则不会抛出异常,如果是false则函数可能抛出异常。在C++11之前使用throw()——有啥实质性区别
2. 运算符:一元运算符,返回值是一个bool的右值常量表达式,用于表示给定的表达式会不会抛出异常,经常与noexcept说明符混合使用,例如:
void f() noexcept(noexcept(g())); // f和g的异常说明一致
- noexcept说明符使用场景:
- 确认函数不会抛出异常
- 根本不知道该如何处理异常
- 仍然抛出异常的情况:
- 编译器并不会在编译时检查noexcept,所以在一个函数说明了noexcept的同时又含有throw语句或调用可能抛出异常的其他函数,编译器是可以顺利通过编译的。
- 一个noexcept抛出异常,程序就调用terminate以确保遵守不在运行时抛出异常的承诺——已经对noexcept函数做了优化之类的怎么办?有可能是捕获不到的
- 异常说明与指针,虚函数和拷贝控制,析构:
- 函数指针和该指针所指函数必须具有一致的异常说明
- 如果某个指针做了不抛出异常的声明,则该指针将只能指向不抛出异常的函数
- 如果显式或隐式的说明指针可能抛出异常,则该指针可以指向任何函数,即使承诺了不抛出异常的函数也可以
- 与虚函数:如果虚函数不抛出异常,派生的虚函数必须做出同样的承诺;如果虚函数运行抛出异常,派生类的随便
- 当编译器合成拷贝控制成员时同时也生成一个异常说明
- 如果对所有成员和基类的所有操作都承诺了不抛出异常,则合成的是noexcept的;
- 如果合成成员调用的任意一个可能抛出异常,则合成的是noexcept(false)
- 定义了一个析构而且没有为他提供异常说明,则编译器合成一个,合成的异常说明将假设由编译器为类合成析构函数时所得的异常说明一致——为类合成析构函数所得的异常说明又是什么:C++11默认析构函数是noexcept(true)的
- 函数指针和该指针所指函数必须具有一致的异常说明
构造/析构与异常
聚焦点是构造时抛出异常后不会调用析构,已申请的资源怎样释放
- 处理构造函数初始值或语句异常的唯一方法是将构造函数写成函数try语句块,这样既能处理成员初始化列表抛出的异常也能处理构造函数抛出的异常。如下:
Blob::Blob(int x) try: data(x)
{ /* 函数体 */ }
catch(const std::bad_allco &e)
{ handle_out_of_memory(e); }
- C++拒绝为没有完成构造的对象调用析构,所以如果构造中完成部分资源申请随后抛出异常而不在构造内部处理,因为不会调用析构,所以这些已经申请好的资源会得不到释放
- 常用方法是在构造中捕获异常,然后执行一些清除代码,最后再重新抛出异常让它继续传递,但会产生一些重复代码,可以将通用的清除代码放在一个私有函数中让构造和析构调用
- 如果是在构造函数的参数列表中无法使用try/catch,可以用私有成员初始化,参数列表中调用,参考more effective C++ item10(p49)中的例子;在C++11中可以使用函数try语句块
- 最好的办法是使用RAII原则保证在发生异常时已申请完成的资源能得到及时释放:如智能指针
- 析构函数不应该抛出不能被它自身处理的异常,如果要执行某个可能抛出异常的操作,则该操作应该被放置在一个try语句块中,并且在析构函数内部得到处理,确保析构函数能完成它应该做的所有事情
标准异常类
- exception仅仅定义了拷贝构造函数/拷贝赋值运算符,虚析构,和一个名为what的虚成员:const char* what(),一般输出异常信息,如果是自定义的异常类,把要输出的异常信息传给父类构造,通过该函数可以输出。该成员确保不会抛出任何异常
- exception,bad_cast和bad_alloc定义了默认构造
- runtime_error和logic_error没有默认构造,担忧一个可接受c风格字符串或string类型实参的构造
标准异常类 | 描述 | 头文件 |
---|---|---|
exception | 最通用的异常类,只报告异常的发生而不提供任何额外的信息 | exception |
runtime_error | 只有在运行时才能检测出的错误 | stdexcept |
rang_error | 运行时错误:产生了超出有意义值域范围的结果 | stdexcept |
overflow_error | 运行时错误:计算上溢 | stdexcept |
underflow_error | 运行时错误:计算下溢 | stdexcept |
logic_error | 程序逻辑错误 | stdexcept |
domain_erro | 逻辑错误:参数对应的结果值不存在 | stdexcept |
invalid_argument | 逻辑错误:无效参数 | stdexcept |
length_error | 逻辑错误:试图创建一个超出该类型最大长度的对象 | stdexcept |
out_of_range | 逻辑错误:使用一个超出有效范围的值 | stdexcept |
bad_alloc | 内存动态分配错误 | new |
bad_cast | dynamic_cast类型转换出错 | type_info |
问题汇总
- 数组越界没有异常,因为它不会抛出异常
abort
:结束程序assert(expr)
:表达式expr为0,先向stderr打印一条信息,然后调用abort终止程序std::current_exception()
:将当前异常以类型std::exception_ptr生成出来,如果当前并无异常就生成nullptr