7 Exceptions¶
静态类型语言会在编译阶段对代码进行类型检查,确保变量、函数等使用正确。编译器会检查代码是否符合语法和语义规则,如果代码存在明显错误(如拼写错误、类型不匹配等),编译器会报错
但是即使代码通过了编译时检查,仍然可能在运行时遇到问题。这是因为编译时检查无法覆盖所有潜在的运行时情况。于是我们引入异常的处理机制以检测这种在运行时发生的错误。
比如说读取一个文件,需要打开文件并确定文件大小,然后分配相应内存,将文件读入内存最后关闭文件。加入异常处理的部分就如下所示
try {
// -----------------------
// main logic here
open the file;
determine its size;
allocate that much memory;
read the file into memory;
close the file;
// -----------------------
} catch ( fileOpenFailed ) {
doSomething;
} catch ( sizeDeterminationFailed ) {
doSomething;
} catch ( memoryAllocationFailed ) {
doSomething;
} catch ( readFailed ) {
doSomething;
} catch ( fileCloseFailed ) {
doSomething;
}
为什么使用异常处理?一个主要的优势是:它能够将错误处理代码与正常运行情况下的代码分开。下面以向量为例举一个例子
template <typename T>
class Vector {
private:
T* m_elements;
int m_size;
public:
Vector(int size = 0) : m_size(size) { /* ... */ }
~Vector() { delete[] m_elements; }
/* ... */
int length() { return m_size; }
T& operator[](int); // How to implement?
};
对于运算符 []
,可能会出现越界的情况
-
如果不做处理,直接返回
return m_elements[idx]
这显然是存在隐患的 -
可以选择返回一个特殊的值来代表错误。
if (idx < 0 || idx >= m_size) { T error_marker("some magic value"); return error_marker; } return m_elements[idx];
但是这也造成一个问题,我们将错误码和正常的返回值混在了一起【然后相应的返回值也要去掉错误值这个值】,可能会需要大量的错误检测,说明这也不太是一个很好的解决方式。
-
可以选择直接退出程序
-
可以通过
assert
机制来退出,也可以打印一些信息
什么时候需要用异常?当遇到无法确定如何处理的错误时,应该抛出异常,将问题交给调用者或更上层的代码来处理,而不是在当前层级强行解决,这样可以避免做出错误的操作,并让更适合处理该问题的地方来进行适当的应对。
template <class T>
T& Vector<T>::operator[](int idx) {
if (idx < 0 || idx >= m_size) {
// throw is a *keyword*
// exception is raised at this point
throw <<something>>;
}
return m_elements[idx];
}
比如说下面这个错误类,就是用来扔出相应的错误信息的
class VectorIndexError {
public:
VectorIndexError(int v) : m_badValue(v) { }
~VectorIndexError() { }
void diagnostic() {
cerr << "index " << m_badValue<< "out of range!";
}
private:
int m_badValue;
};
template <class T>
T& Vector<T>::operator[](int idx){
if (idx < 0 || idx >= m_size) {
throw VectorIndexError(idx); // the data object
}
return m_elements[idx];
}
对于调用者,如何处理抛出来的异常?
- 直接无视,相应的语句并未执行
int func() {
Vector<int> v(12);
v[3] = 5;
int i = v[42]; // out of range
// control never gets here!
return i * 5;
}
- 如果我们需要关系,我们将可能抛出异常的语句放到
try
块里面去,catch
用来接住抛出的异常,解决相应的异常,参数的抛出异常的类型
void outer() {
try {
func();
func2();
} catch (VectorIndexError& e) {
e.diagnostic();
// This exception does not propagate
}
cout << "Control is here after exception";
}
- Mildly interested 只是看一下,但是不处理
void outer2() {
string err_msg("exception caught");
try {
func();
} catch (VectorIndexError&) {
cout << err_msg;
throw; // propagate the exception
}
}
- Doesn’t care about the details. 不考虑异常类型,
...
的意思是匹配任意异常类型
void outer3() {
try {
outer2();
} catch (...) {
// ... catches ALL exceptions!
cout << "The exception stops here!";
}
}
上述这个异常抛出链如下
throw
语句触发异常,控制沿调用链回溯至第一个匹配的异常处理器,同时确保栈上的对象被正确销毁。
throw exp;
用于主动抛出一个新的异常,而 throw;
则是在处理异常的过程中,如果需要,可以将当前异常再次抛出,以便进一步处理【只在 handler 中有效】。
一个 try
后面可以加很多的 catch
以捕获不同的异常
对于异常处理的 handlers,可以
-
Select exception by type 通过类型选择异常
-
Can re-raise,可以再次抛出,
throw
-
Two forms
- 总是只接受一个参数
对于异常处理程序 (handler) 可以有任意数量的处理程序,那么是如何选择相应的异常处理程序呢?
- 精确匹配:当抛出异常时,系统会首先尝试找到与异常类型完全匹配的处理程序。例如,如果抛出了
SomeException
类型的异常,那么系统会优先查找catch (SomeException e)
这样的处理程序。但是【要注意将子类的catch
写在父类的前面,否则会向上造型】。 - 应用基类转换:如果找不到精确匹配的处理程序,系统会尝试将异常对象转换为其基类类型,并寻找相应的处理程序。这种转换仅适用于引用类型和指针类型。例如,如果
SomeException
继承自BaseException
,而没有找到catch (SomeException e)
,系统会继续查找catch (BaseException e)
。 - 通用捕获处理程序:如果前两步都没有找到合适的处理程序,系统会使用通用的
catch (...)
处理程序来捕获所有未被其他处理程序捕获的异常。相应的catch(...)
也要写在最后,否则也会拦截掉【甚至会直接报错】。
所以写 catch
类型的顺序的时候,按照子类往父类写,如果存在 catch(...)
要放在最后
继承可用于结构化异常:通过定义异常类的继承关系,可以更好地组织和管理异常。例如,可以创建一个基异常类 MathErr
,然后根据不同的错误类型派生出具体的异常类,如 OverflowErr
、UnderflowErr
等。这样,在编写异常处理程序时,可以根据需要选择捕获特定类型的异常或其基类异常。
class MathErr {
...
virtual void diagnostic();
};
class OverflowErr : public MathErr { ... }
class UnderflowErr : public MathErr { ... }
class ZeroDivideErr : public MathErr { ... }
try {
// code to exercise math options
throw UnderFlowErr();
} catch (ZeroDivideErr& e) {
// handle zero divide case
} catch (UnderFlowErr& e) {
// handle underflow errors
} catch (MathErr& e) {
// handle other math errors
} catch (...) {
// any other exceptions
}
标准库里面也给我们定义了一些异常类型
Exceptions and new: new 出现错误的时候并不会返回 0,而是会抛出一个异常 bad_alloc()
noexcept
标识符,用于标识一个函数不会抛出异常
以便编译器可以更有效地运行代码,但是如果这个函数真抛出了异常,那么 std::terminate
就会被执行。有些函数比如说 destructors 或者 move constructor 等等函数就要求不抛出异常
Design considerations
异常机制还是要用来检测错误的,设计的时候最好不要利用这个机制去处理一些不是错误的内容,比如说
try {
for (;;) {
p = list.next()
// ...
}
} catch (List::end_of_list) {
// handle end of list here
}
把链表的末尾当作一个异常来处理,这就不太好。
还有一些不必要的异常,比如说
void func() {
File f;
if (f.open("somefile")) {
try {
// work with f
} catch (...) {
f.close()
}
}
}
完全可以选择将 f.close()
这个函数放到析构函数里面去完成,抛出异常时会退栈,而退栈的时候就会调用相应的析构函数。
构造函数中发生的错误,因为没有返回值,所以不能用错误码【但是可以在这个类中加一个字段以标识是否正常完成构造函数】,然后将相应的工作在构造函数外面再加上一层函数,以实现相应的构造函数。但是这个方法总归来说不太好,多一个字段,多一层调用函数。于是我们依然使用抛出异常的方式来完成。
但是如果 constructor 抛出了异常,那么会不会出现内存泄漏的问题呢?
int main(){
X x;//不会发生内存泄漏,因为栈上的动态内存会被收回的,这里的内存指的是 x 的内存
Y* y = new Y()//new执行两个阶段,一个阶段是分配内存,另一个阶段是初始化,如果初始化出现异常,那么栈上的内存会被收回,同时由于new,并没有做完,所以指针y也不会发生内存泄漏
}
class A
{
private:
int* vdata;
public:
A():vdata(new int[10]){
std::cout<<"A()"<<std::endl;
if (true){
throw 2;
}
}
~A(){
std::cout<<"~A()"<<std::endl;
delete[] vdata;
std::cout<<"deleting vdata......"<<std::endl;
}
}
int main(){
try{
A a;
} catch (...){
std::cout<<"caught"<<std::endl;
}
}
运行之后发现输出为
说明我并没有执行语句 delete[] vdata;
,那么 vdata
就会出现内存泄漏。可以通过一个两段式的方式来解决这个问题
在构造函数中初始化所有成员对象,并将所有指针设置为 nullptr
,然后通过一个 init()
函数来分配资源,这样构造相应的对象就需要两步
如果 A a
发生错误,相应的成员对象都在栈上被正常销毁,然后如果 a.init()
发生错误,仍然会调用相应的析构函数(因为 a
需要被销毁),相应的指针也会被释放。但是这样两段式的构造方式显然不够好。
我们可以在抛出异常前释放所有分配的函数。
但是这样相应的释放部分要写两份,同时释放的时候也许也会产生错误。更好的方式是将数据进行打包,打包到一个类中
class Wrapper{
private:
int* vdata;
public:
Wrapper(int* data):vdata(data){
cout<<"W::W()"<<endl;
}
Wrapper(){
delete[] vdata;
cout<<"W::~W()"<<endl;
}
}
class A
{
private:
Wrapper w;
public:
A():w(new int[10]){
std::cout<<"A()"<<std::endl;
if (true){
throw 2;
}
}
~A(){
std::cout<<"~A()"<<std::endl;
}
}
int main(){
try{
A a;
} catch (...){
std::cout<<"caught"<<std::endl;
}
}
由于这样的情况实在是太常见了,所有库中给我们提供了智能指针。std::unique_ptr
, std::shared_ptr
,将上述代码替换为
class A
{
private:
std::unique_ptr up;
public:
A():up(new int[10]){
std::cout<<"A()"<<std::endl;
if (true){
throw 2;
}
}
~A(){
std::cout<<"~A()"<<std::endl;
}
}
析构函数在两种情况下会被调用,一种是正常的生命周期结束的时候调用,另一种就是在异常抛出的时候,销毁栈上的变量的时候调用。如果析构函数进行过程中发生的异常,程序就会终止