C++之异常处理
为什么引入异常
在C语言中错误的处理,通常采用返回值的方式或是使用全局变量的方式。这就存在两个问题,一是如果返回值正是我们需要的数据,这就导致了返回数据同出错数据容错差不高。二是全局变量,在多线程中易引发竞争,而且,当错误发生时,上级函数要出错处理,层层上报,造成过多的出错处理代码,且传递的效率低下。
因此C++引入了面向对象级别的异常处理机制。
在C++中异常的处理和具体逻辑的处理不比在同一个函数中,这样就可以做到底层逻辑专注于功能的实现,具体错误处理交由上层业务逻辑去处理。
异常如何使用?
使用关键字throw
可以抛出异常,使用try catch
代码块捕获异常,以下是一个简单的例子:
using namespace std;
void func(){
// 抛出异常
int a = 100;
int b = 0;
try {
// 可能抛出异常
if(b == 0){
throw string("除数为0");
} else{
int c = a / b;
}
}catch(int &e) {
}
}
int main() {
try {
//捕获可能发生的异常
func();
}catch (string &e){
// 处理异常
std::cout << e << std::endl;
}
return 0;
}
正如上面的例子所示,当函数func
抛出异常时throw后面的 try...catch... 块内找不到匹配该异常对象的catch语句,则由更外层的 try...catch... 块来处理该异常;如果当前函数内所有的 try...catch... 块都不能匹配该异常,则递归回退到调用栈的上一层去处理该异常。如果一直退到主函数 main() 都不能处理该异常,则调用系统函数 terminate() 终止程序。
栈展开
当抛出一个异常后,程序暂停当前函数的执行过程并立即开始寻找与异常类型匹配的catch代码块,如果找到了匹配的catch,就使用该catch处理异常,如果这一步没找到匹配的catch且该try语句嵌套在其他try块中,则继续检查与外层try匹配的catch代码块, 如果还是找不到匹配的catch代码块则退出当前的函数,在调用当前函数的外层函数继续进行寻找。
最终如果都没有找到与之匹配的catch块则将异常交由系统进行处理,而系统一般的默认处理是调用系统函数terminate()
来终止程序。
例如在上面的例子中,函数func
抛出了一个string类型的异常,但是函数func
内部只捕获了int类型的异常,所以经过栈展开后在main
函数内才找到匹配的catch块。
当一个异常被抛出时,沿着调用链的块将依次退出直至找到与异常匹配的处理代码的过程就是栈展开的过程。
重新抛出
有时,一个单独的catch语句不能完整地处理某个异常。在执行了某些校正操作之后,当前的catch可能会交由调用链更上一层的函数接着处理异常,这时候就可以使用不带参数的关键字throw
将异常再次往上抛出。注意这里的重新抛出仍然是一条throw语句,只不过不包含任何表达式
using namespace std;
void func(){
// 抛出异常
int a = 100;
int b = 0;
try {
// 可能抛出异常
if(b == 0){
throw string("除数为0");
} else{
int c = a / b;
}
}catch(string &e) {
std::cout << "捕获到了异常,往网上抛出" << std::endl;
e = e + ",内部经过了部分处理";
// 重新抛出异常
throw;
}
}
int main() {
try {
//捕获可能发生的异常
func();
}catch (string &e){
// 处理异常
std::cout << e << std::endl;
}
return 0;
}
如果使用指针的方式抛出异常,然后该异常又被重新抛出了呢?到底在哪一步删除这个指针呢?这是一个纠结的问题?
异常捕获类型
就像函数可以通过值传递、指针传递、引用传递一样,在catch代码块中我们也可以异常捕获值、异常捕获指针、异常捕获引用,既然选择这么多,那么到底捕获什么才是最优解呢?
对于好东西来说当然是多多益善,但是对于异常来说就是洪水猛兽呀。。。
在《More Effective C++》中条款13:
以 by reference方式捕捉 exceptions
也就是说大师建议我们通过异常捕获引用的方式来传递异常信息。为什么这样建议呢,异常捕获值、异常捕获指针是有什么弊端吗?更多具体的细节童鞋们可以去看看《More Effective C++》这本书, 笔者在这里大概简述下原因:
1、C++是以面向对象思想为主的一门语言,所以抛出的异常难免存在继承关系,而继承一般就涉及到多态,而触发多态所需的动态绑定需要指针或引用才能实现,所以异常使用值传递的意义基本是没有了。
2、异常捕获指针,那么到底谁来管理释放这个指针呢?什么时候释放这个指针呢?所以捕获异常指针会带来内存治理的难题。
3、看来异常捕获引用是仅剩的一个可选方案,而且不存在上面的两个问题,遇上需要将异常重新抛出的话,引用所做的修改也能进行保留继续异常传递,因此异常捕获引用就是天选之子。
既然建议使用异常捕获引用的方式处理异常,而异常发生时抛出的对象一般又是局部变量。在平时写程序的过程中我们总是被不断地强调说不能返回一个局部变量的引用,那异常引用在这里不是矛盾了么?
在这里需要注意的一个点就是:一个对象被抛出作为exception时,总是会发生复制的。即使被抛出的对象并没有瓦解的危险,复制行为还是会发生,所以如果有自定义类型,要考虑好自定义类型的拷贝问题。
我们用以下的代码验证一下:
using namespace std;
class A{
public:
A(int a,string text):age(a),name(text){
std::cout << "自定义构造函数" << endl;
}
A(const A &a){
std::cout << "拷贝构造" << endl;
}
~A(){
std::cout << "析构函数" << endl;
}
public:
int age;
std::string name;
};
A& createA(){
A a(10,"小明");
return a;
}
void testException(){
A a(10,"小明");
throw a;
}
int main() {
A a = createA();
cout << "age:" << a.age << endl; // 因为返回的临时对象的引用已经被销魂了,所以输出的age是不对的
cout << "------------------------------" << endl;
try {
testException();
}catch (A &a){
cout << "捕获到异常:" << a.age << endl;
}
return 0;
}
输出:
自定义构造函数
析构函数
拷贝构造
age:83738720
------------------------------
自定义构造函数
拷贝构造
析构函数
捕获到异常:0
析构函数
析构函数
在上面程序中我们发现返回在普通函数中返回临时对象的引用和在异常中使用引用捕获他们的拷贝函数和析构函数的执行顺序是不一样的。所以使用引用的方式捕获异常和不要在函数中返回临时对象的引用这两点并没有冲突。
那么又有一个问题了,不是说以引用传递参数的方式可以减少拷贝吗?为什么在异常中使用引用就发生了拷贝呢?在这里我们需要记住一个特殊的情况就是:在C++特别要声明,和普通函数的调用不同,一个对象被抛出作为exception时,总是会发生拷贝行为的,但是也有一个例外就是当在catch中捕获到了异常的引用,再次使用throw抛出时则不会发生拷贝行为。
noexcept
在C++11新标准中,我们可以通过提供noexcept指定某个函数不会抛出异常。
noexcept说明符接受一个可选的实参,该实参必须能转换为bool类型:如果实参是true,则函数不会抛出异常;如果实参是false,则函数可能抛出异常:
void testPrint() noexcept(true){
// 不会抛出异常
}
void testPrint2() noexcept(false){
// 可能会抛出异常
}
void testPrint() noexcept{
// 不带参数,表示不会抛出异常
}
用noexcept声明了函数是告诉编译器这个函数是不会抛出异常的,编译器可以根据声明优化代码。但是noexcept仅仅是告诉编译器不会抛出异常,但函数不一定真的不会抛出异常, 当我们在声明了noexcept的函数中抛出异常时,程序会调用std::terminate去结束程序的生命周期。
更多关于异常处理的相关注意条款可以参考《More Effective C++》及《Effective C++》这两本圣经。
推荐阅读
C++之指针扫盲
C++之智能指针
C++之指针与引用
C++之右值引用C++之多线程一
C++之多线程二
关注我,一起进步,人生不止coding!!!