异常概念
讲解异常前,先回顾下C语言传统的错误处理机制:
- 终止程序:过于粗暴,如发生内存错误,除0错误时就会直接终止程序
- 返回错误码:需要程序员自己去查找对应的错误。如系统的很多库的接口函数都是通过把错误码放到
errno
中,表示错误
异常是一种处理错误的方式,当一个函数发现自己无法处理的错误时就可以抛出异常,让函数的直接或间接的调用者处理这个错误。
异常的抛出与捕获
C++中使用try-catch
块来处理异常,throw
关键字用于抛出异常。
throw
: 当问题出现时,程序会抛出一个异常,这是通过使用throw
关键字来完成的catch
: 在想要处理问题的地方,通过异常处理程序捕获异常,catch
关键字用于捕获异
常try
:try
块中的代码标识将被激活的特定异常,它后面通常跟着一个或多个catch
块。如果有一个块抛出一个异常,捕获异常的方法会使用try
和catch
关键字。try
块中放置可能抛出异常的代码,try
块中的代码被称为保护代码
我们通过以下案例来了解异常的基本语法:
double Division(int a, int b) { // 当b == 0时抛出异常 if (b == 0) throw "error!!!"; else return ((double)a / (double)b); } void Func() { int a, b; cin >> a >> b; cout << Division(a, b) << endl; } int main() { try { Func(); } catch (const char* errmsg) { cout << errmsg << endl; } return 0; }
Division函数用于完成两个浮点数的除法,但是除法中被除数不可以是0,因此我们要检测参数b,如果参数b = 0,就抛出异常throw "error!!!"。
此处的throw关键字用于抛出异常,"error!!!"是一个const char*类型的字符串,throw可以抛出任何类型变量。
由于main调用了Func,Func调用了Division,所以Func函数是有可能间接发生异常的,此时把调用Func的语句放到try块中,说明我们要检测这个Func函数会不会发生异常。
一旦Division抛出异常,那么就是Func发生了异常,此时try就可以检测到,由于抛出的异常是const char*,我们要检测该类型的异常,所以catch的参数就是const char*了,catch (const char* errmsg)。
一旦catch
匹配到了相同类型的异常,就会执行{ }
中的代码。
以上就是一个简单的异常执行过程。
异常匹配原则
- 异常是通过抛出对象而引发的,该对象的类型决定了应该激活哪个
catch
的处理代码
比如以下代码:
try { Func(); } catch (const char* errmsg) { cout << "const char*" << endl; } catch (int errmsg) { cout << "int" << endl; } catch (double errmsg) { cout << "double" << endl; } catch (int* errmsg) { cout << "int*" << endl; }
此处有4个catch
块,当在Func
内部检测到异常时,根据被抛出的异常类型,选择执行哪一个catch
来输出信息。
catch(...)
可以捕获任意类型的异常,问题是不知道异常错误是什么
如果所有的catch都没有合适类型来匹配对应的异常,此时会直接终止程序然后报错,对于要一直执行程序的服务器开发来说,这将导致服务器停止工作。
但是抛出的异常是不确定的,C++中有那么多类型,何况还有自定义类型,不可能把每种异常都写出catch
来的,于是C++提供了catch(...)
,其可以接收任何类型的异常。
try { Func(); } catch (const char* errmsg) { cout << "const char*" << endl; } catch (...) { cout << "unknow error" << endl; }
以上代码中,catch(...)就接收所有类型的异常,如果是const char*就匹配现有的catch块。这样就可以避免程序崩溃,是异常的最后一道防线。建议任何catch块末尾都加上catch(...)。
被选中的处理代码是调用链中与该对象类型匹配且离抛出异常位置最近的那一个
函数是可以嵌套调用的,那么此时就会生成一个调用链,一旦抛出异常,那么就会一层一层往上找,按照最近的那一个try
来处理异常。
示例:
void func3() { throw "error!"; } void func2() { func3(); } void func1() { try { func2(); } catch (const char* errmsg) { cout << errmsg << endl; } } int main() { try { Func(); } catch (const char* errmsg) { cout << errmsg << endl; } catch (...) { cout << "unknow error" << endl; } return 0; }
以上代码的调用链为main -> func1 -> func2 -> func3,其中func3抛出了异常,于是异常开始往回查找,先看看当前的func3抛出的异常在不在try中,再回到func2发现其也没有try,再回到func1,此时发现func1中在try内部调用了func2,因此执行func1内的catch。
虽然最高层main也有try-catch,但是异常只匹配离自己最近的那个。
抛出异常对象后,会生成一个异常对象的拷贝,因为抛出的异常对象可能是一个临时对象,所以会生成一个拷贝对象,这个拷贝的临时对象会在被catch以后销毁
由于被throw的异常,本质上也是一个变量,当往回找catch的时候,很有可能会出函数作用域,那么局部变量就会被销毁。因此catch()内部是一个传值调用,通过不断拷贝被抛出的异常,最后把异常送到catch中。
实际中抛出和捕获的匹配原则有个例外,并不都是类型完全匹配,可以抛出的派生类对象,使用基类捕获,这个在实际中非常实用,我们后面会详细讲解这个
这是一个很重要的知识点,简单来说就是可以在catch内部写基类类型,此时如果抛出了派生类,那么也可以匹配这个基类的catch。
示例:
// 定义基类异常 class Base { public: virtual const char* what() { return "Base Exception"; } }; // 定义派生类异常 class Derived : public Base { public: const char* what() { return "Derived Exception"; } }; int main() { try { // 抛出派生类异常 throw Derived(); } catch (Base& e) { // 捕获基类异常,但实际接收到的是派生类异常 cout << e.what() << endl; } catch (...) { cout << "Catch all exception" << endl; } return 0; }
在这个例子中,我们定义了一个基类异常 Base 和一个派生类异常 Derived。在 main() 函数中,故意抛出 Derived 对象。
在 catch 块中,捕获 Base 类型的异常。由于 Derived 是 Base 的派生类,所以它也可以被捕获到。最终,输出结果是 "Derived Exception",说明成功捕获到了派生类异常。
至于这个特性的具体用法,我们稍后讲解。
异常重新抛出
有可能单个的catch
不能完全处理一个异常,在进行一些校正处理以后,希望再交给更外层的调用链函数来处理,catch
则可以通过重新抛出将异常传递给更上层的函数进行处理。
double Division(int a, int b) { // 当b == 0时抛出异常 if (b == 0) throw "error!!!"; else return ((double)a / (double)b); } void Func() { int a, b; cin >> a >> b; try { cout << Division(a, b) << endl; } catch (const char* errmsg) { cout << "第1次处理异常" << endl; throw errmsg; } } int main() { try { Func(); } catch (const char* errmsg) { cout << "第2次处理异常" << endl; } catch (...) { cout << "unknow error" << endl; } return 0; }
这段代码演示了异常处理在多层函数调用中的传递机制
Division
函数中,当b
为 0 时会抛出一个const char*
类型的异常。Func
函数中,调用Division
函数并将异常捕获。在catch
块中,对异常进行了一次处理,输出了 “第1次处理异常”。然后通过throw errmsg;
将异常重新抛出,传递给更外层的调用函数。main
函数中,调用Func
函数,并在外层try-catch
块中捕获异常。捕获const char*
类型的异常,输出 “第2次处理异常”。
这种异常传递机制使得我们可以在不同层级的函数中对异常进行分层处理,提高代码的健壮性和可维护性。
如果我们需要重新抛出异常,其实不用throw errmsg;
,重新抛出异常时,被抛出的异常是可以省略的,也就是throw;
即可。
异常安全问题
构造函数完成对象的构造和初始化,最好不要在构造函数中抛出异常,否则可能导致对象不完整或没有完全初始化
示例:
class A { public: A(int n) { if (n == 0) throw 0; _ptr = new int[n]; } ~A() { delete[] _ptr; } private: int* _ptr; };
以上代码中,A类的构造函数在n = 0的时候,直接抛出异常throw 0。此时会直接结束析构函数,去找catch。但是一旦出了A的作用域,A就会被销毁,调用析构函数,而析构函数要delete[],_ptr没有初始化,此时是野指针,这就导致了严重的问题,程序直接崩溃。
因此最好不要在构造函数中抛出异常。
析构函数主要完成资源的清理,最好不要在析构函数内抛出异常,否则可能导致资源泄漏(内存泄漏、句柄未关闭等)
同样的,由于析构函数要完成资源的清理,假如在析构函数中要delete某一块动态内存,但是还没有delete就直接抛出异常了,那么这块内存就永远不能被delete了,造成内存泄漏。
因此最好也不要在析构函数中抛出异常。
C++中异常经常会导致资源泄漏的问题,比如在new和delete中抛出了异常,导致内存泄漏,在lock和unlock之间抛出了异常导致死锁,C++经常使用RAII来解决以上问题
先看到一个普通的异常造成的内存泄漏:
void func2() { throw "error"; } void func1() { int* array = new int[10]; func2(); delete[] array; } int main() { try { func1(); } catch (const char* errmsg) { cout << errmsg << endl; } return 0; }
以上代码中,func2
抛出了异常,由于在调用func2
之前,func1
已经new
了一块动态内存,此时如果直接去处理异常,那么delete
就会被忽略,导致内存泄漏。
对于这种内存泄漏,我们就可以用到异常的重新抛出了,将func1
写为如下形式:
void func1() { int* array = new int[10]; try { func2(); } catch (const char* errmsg) { delete[] array; throw; } delete[] array; }
此时就算发生了异常,func1
先截获了异常,然后通过delete
释放当前的资源,再把异常重新抛出给上层处理。
这个过程中,func1
截获异常的目的不是为了处理异常,而是为了释放自己的资源,真正处理异常的地方在main
函数中。
另外的,由于delete
,new
这样的操作符,本身就会抛出异常,所以我们还要额外防止这个异常的问题。
比如以下代码:
void func() { int* p1 = new int[10]; int* p2 = new int[10]; int* p3 = new int[10]; delete[] p1; delete[] p2; delete[] p3; }
我们在一个func函数中开辟了三个额外资源p1,p2,p3。由于new本身就会抛出异常,那么这一块的异常就会很难处理,比如如果p3发生了异常,处理异常时,就要delete掉p1和p2。如果p2发生异常,那么就要delete掉p1。这太麻烦了,而且很不优雅。
为了解决这个问题,C++提供了RAII(Resource Acquisition Is Initialization)机制,通过在构造函数中申请资源,在析构函数中释放资源,确保资源的安全释放。
示例:
class SmartPtr { public: SmartPtr(int* ptr) : _ptr(ptr) {} ~SmartPtr() { delete[] _ptr; } private: int* _ptr; }; void func() { SmartPtr p1 = new int[10]; SmartPtr p2 = new int[10]; SmartPtr p3 = new int[10]; }
我们将原先的new和delete放到了类SmartPtr中,此时就算哪个地方出现了异常,要去上层找catch,由于自定义类型出了作用域,会自动调用析构函数,此时就会调用~SmartPtr内部的delete,就完成了自动释放资源,不用我们复杂的截获,然后一个一个delete了。
异常规范
为了让函数的调用者可以更加清晰地知道一个函数有可能会抛出什么类型的异常,方便处理,C++98给出了异常规格说明
。
- 异常规格说明的目的是为了让函数使用者知道该函数可能抛出的异常有哪些。 可以在函数的后面接throw(类型),列出这个函数可能抛掷的所有异常类型
- 函数的后面接throw(),表示函数不抛异常
- 若无异常接口声明,则此函数可以抛掷任何类型的异常
看几个案例来理解:
void* fun() throw(int, double, string) { return nullptr; }
throw(int, double, string)
代表函数func
只有可能抛出这三种类型的异常。
void* fun() throw (const char*) { return nullptr; }
throw(const char*)
代表函数func
只有可能抛出const char*
类型的异常。
void* fun() throw() { return nullptr; }
throw()
代表函数func
不会抛出任何异常。
void* fun() { return nullptr; }
这种函数不被异常规格说明
修饰,可以抛出任何类型的异常。
这种异常规格说明
不是强制的,所以很多时候就算写了,也可以抛出其它类型的异常,因此不是很好用,而且如果抛出异常的类型多了,就会导致很冗余。
C++11引入了noexcept
关键字,用于指定函数不会抛出任何异常。如果一个noexcept
函数内部抛出异常,程序会直接调用std::terminate()
终止。
也就是C++11后,以更加简单粗暴的方式来规范异常的抛出:
- 如果一个函数有可能抛出异常,就不对该函数进行任何修饰
- 如果一个函数一定不会抛出异常,那么用
noexcept
来修饰该函数
当其他用户看到一个函数被noexcept
修饰,说明这个函数一定是不会抛出异常的,就可以放心使用该函数了,而且如果被noexcept
修饰抛出了异常,程序会直接报错。
异常体系
在实际开发中,由于异常抛出的类型是不确定的,公司内部一般会约定好如何抛异常,而异常继承体系是最优秀的一套体系。
在很早的时候,我讲过这个特性:
- 实际中抛出和捕获的匹配原则有个例外,并不都是类型完全匹配,可以抛出的派生类对象,使用基类捕获
我们先定义一个基类:
class Exception { public: Exception(const string& errmsg, int id) :_errmsg(errmsg) , _id(id) {} virtual string what() const { return _errmsg; } protected: string _errmsg; int _id; };
在这个基类Exception
中,有两个成员变量:
_errmsg
:字符串,描述当前异常
_id
:该异常的编号
一个成员函数:
what
:返回_errmsg
一般来说,公司内部会约定好,什么类型的错误对应的编号,当一个程序员想要抛异常的时候,就继承这个基类,然后修改这个_errmsg和_id。
而what是一个虚函数,其可以被派生类重写,各个程序员在抛异常时,可以重写这个虚函数,当别人检测异常的时候,通过调用what函数来了解到底这里发生了什么异常。
比如说某个SQL
的程序员想要抛异常,于是它写出来以下派生类
:
class SqlException : public Exception { public: SqlException(const string& errmsg, int id, const string& sql) :Exception(errmsg, id) , _sql(sql) {} virtual string what() const { string str = "SqlException:"; str += _errmsg; str += "->"; str += _sql; return str; } private: const string _sql; };
在这个函数的what
中,其返回了一个字符串,字符中第一个字段就是"SqlException:"
,就说明当前异常是在SQL
层面抛出的,然后其它详细信息再自己表述即可。
那么我们再看到外层捕获异常的方式:
void SQLMgr() { if (true) { throw SqlException("权限不足", 100, "select * from name = '张三'"); } } int main() { try { SQLMgr(); } catch (Exception e) { cout << e.what() << endl; } }
我们在外层只捕获基类Exception ,此时所有程序员重写的派生类都可以被捕获到。捕获到异常后,就去调用what函数,拿到返回值并输出,此时就可以直到到底是什么问题了。
比如这个地方,异常最后就会输出:
SqlException:权限不足->select * from name = '张三'
看到这样一个异常,我们可以知道:该异常来自于SQL
,原因在于权限不足
,问题语句是select * from name = '张三'
。
这样的异常看的就让人很舒服,可以很快定位到错误,而且整个抛异常的过程也很简洁,不会发生接收不到异常,或者接收到未知类型的异常等问题。
在C++库中,就有这样一套继承体系,关系图如下:
其中exception
是最大的基类,剩余的所有类都是派生类,它们各自代表了不同的异常,比如vector
越界时,就会抛出out_of_range
异常。
越界时,就会抛出out_of_range
异常。
而exception
类也和我们讲的几乎一模一样:
可以看到,其存在一个what
函数,最后处理异常的时候,就通过调用what
得知具体异常信息。
其它继承的派生类代表的异常如下: