C++11 异常
作者:@小萌新
专栏:@C++进阶
作者简介:大二学生 希望能和大家一起进步!
本篇博客简介:介绍C++11的异常机制
异常
- c语言处理错误的方式
- C++异常概念
- 异常的用法
- 异常的抛出和捕获
- 异常的重新抛出
- 异常安全
- 异常规范
- 自定义异常
- 标准异常库
- 异常的优缺点
- 优点:
- 缺点
- 思维导图
c语言处理错误的方式
c语言传统的错误处理机制是这样子的
- 终止程序,如assert。缺陷:用户难以接受。如发生内存错误,除0错误时就会终止程序。
- 返回错误码。缺陷:需要程序员自己去查找对应的错误。如系统的很多库的接口函数都是通过把错误码放到errno中,表示错误。
- C标准库中setjmp和longjmp组合。(不常用)
我们在实际写c语言的过程中基本都是用错误码在报错 在发生很严重的错误时使用断言报错
C++异常概念
异常是面向对象语言常用的一种处理错误的方式 当一个函数发现自己无法处理的错误时就可以抛出异常 让函数直接或间接的调用者处理这个错误
这里有三个关键字
- throw:当程序出现问题时 可以通过throw关键字抛出一个异常
- try:try块中放置的是可能抛出异常的代码 该代码块在执行时将进行异常错误检测 ry块后面通常跟着一个或多个catch块
- catch:如果try块中发生错误 则可以在catch块中定义对应要执行的代码块
语法示例如下
try
{
//被保护的代码
}
catch (ExceptionName e1)
{
//catch块
}
catch (ExceptionName e2)
{
//catch块
}
catch (ExceptionName eN)
{
//catch块
}
异常的用法
异常的抛出和捕获
异常的抛出和捕获的匹配原则:
- 异常是通过抛出对象而引发的 该对象的类型决定了应该激活哪个catch的处理代码 如果抛出的异常对象没有捕获 或是没有匹配类型的捕获 那么程序会终止报错
- 被选中的处理代码(catch块)是调用链中与该对象类型匹配且离抛出异常位置最近的那一个
- 抛出异常对象后 会生成一个异常对象的拷贝 因为抛出的异常对象可能是一个临时对象 所以会生成一个拷贝对象 这个拷贝的临时对象会在被catch捕获以后销毁(类似于函数的传值返回)
- catch(…)可以捕获任意类型的异常 但捕获后无法知道异常错误是什么
- 实际异常的抛出和捕获的匹配原则有个例外 捕获和抛出的异常类型并不一定要完全匹配 可以抛出派生类对象 使用基类进行捕获 这个在实际中非常有用
在函数调用链中异常栈展开的匹配原则:
- 当异常被抛出后 首先检查throw本身是否在try块内部 如果在则查找匹配的catch语句 如果有匹配的则跳到catch的地方进行处理
- 如果当前函数栈没有匹配的catch则退出当前函数栈 继续在上一个调用函数栈中进行查找匹配的catch 找到匹配的catch子句并处理以后 会沿着catch子句后面继续执行 而不会跳回到原来抛异常的地方
- 如果到达main函数的栈 依旧没有找到匹配的catch 则终止程序
现在我们使用一段代码来解释上面的匹配原则
void func1()
{
throw string("这是一个异常");
}
void func2()
{
func1();
}
void func3()
{
func2();
}
int main()
{
try
{
func3();
}
catch (const string& s)
{
cout << "错误描述:" << s << endl;
}
catch (...)
{
cout << "未知异常" << endl;
}
return 0;
}
上面的代码中有四个函数 分别是main函数 func1 func2 func3
其中main函数调用了func3 func3调用了func2 func2调用了func1 再然后func1抛出了一个异常
针对这段代码我们来分析下函数调用链中的匹配问题
当func1中的异常被抛出之后
- 当func1中的异常被抛出之后 首先它会判断自己是否再try的内部 判断出不在之后他会返回上一层的函数栈帧
- 在func2中它仍然不在try的内部 继续返回上一层的函数栈帧
- 在func3中它仍然不在try的内部 继续返回上一层的函数栈帧
- 在main函数中它判断出自己在try的内部了 并且开始找符合要求的catch语句 捕获异常成功
演示效果图如下
代码实际运行结果如下
大家也许注意到了 我们在最后加上了一个catch(…) 这是因为有可能出现未知类型的异常 而此时如果该异常未被捕获 则有可能导致程序崩溃
下面是示例
而如果加上了最后捕获未知异常的代码就不会出现这种问题了
异常的重新抛出
有时候单个的catch可能不能完全处理一个异常 在进行一些校正处理以后 希望再交给更外层的调用链函数来处理 比如最外层可能需要拿到异常进行日志信息的记录 这时就需要通过重新抛出将异常传递给更上层的函数进行处理
如果直接让最外层捕获异常进行处理可能会引发一些问题 比如
void func1()
{
throw string("这是一个异常");
}
void func2()
{
int* array = new int[10];
func1();
//do something...
delete[] array;
}
int main()
{
try
{
func2();
}
catch (const string& s)
{
cout << s << endl;
}
catch (...)
{
cout << "未知异常" << endl;
}
return 0;
}
- 上面这段代码中 有三个函数 分别是func1 func2 main
- 其中main函数调用func2 func2开辟了一块空间之后调用func1
- func1抛出了一个异常开始往前找匹配的catch
- func1 和 func2的函数栈帧里面都没有找到 于是找到了main函数中并且异常被捕获
- 但是再销毁func2函数栈帧的过程中我们并没有销毁func2开辟出来的内存 于是乎造成了内存泄漏
为了解决内存泄漏问题 我们可以这样子重构代码
我们再func2中捕获func1中抛出的异常 捕获后将申请的内存释放 并且重新抛出
代码如下
void func2()
{
int* array = new int[10];
try
{
func1();
//do something...
}
catch (...)
{
delete[] array;
throw; //将捕获到的异常再次重新抛出
}
delete[] array;
}
这里有两点需要注意的
- 因为我们不确定抛出异常的是什么类型 所以我们要使用 catch (…) 捕获
- 此外重新抛出的时候我们直接throw就好 不用指定类型
异常安全
对于异常安全问题这里有三点建议
- 构造函数完成对象的构造和初始化 最好不要在构造函数中抛出异常 否则可能导致对象不完整或没有完全初始化
- 析构函数主要完成对象资源的清理 最好不要在析构函数中抛出异常 否则可能导致资源泄露(内存泄露、句柄未关闭等)
- C++中异常经常会导致资源泄露的问题 比如在new和delete中抛出异常 导致内存泄露 在lock和unlock之间抛出异常导致死锁 C++经常使用RAII的方式来解决以上问题。
(RAII其实就是利用对象来管理资源的一种方式)
异常规范
为了让函数使用者知道某个函数可能抛出哪些类型的异常 C++标准规定:
- 在函数的后面接throw(type1, type2, …) 列出这个函数可能抛掷的所有异常类型
- 在函数的后面接throw()或noexcept(C++11)表示该函数不抛异常。
- 若无异常接口声明 则此函数可以抛掷任何类型的异常(异常接口声明不是强制的)
代码示例如下
//表示func函数可能会抛出A/B/C/D类型的异常
void func() throw(A, B, C, D);
//表示这个函数只会抛出bad_alloc的异常
void* operator new(std::size_t size) throw(std::bad_alloc);
//表示这个函数不会抛出异常
void* operator new(std::size_t size, void* ptr) throw();
// C++11
中新增的noexcept,表示不会抛异常
thread() noexcept;
thread (thread&&x) noexcept;
但是由于这个规范并不是强制性的 所以说其实对于现实写代码的影响并不是那么大 也很少有人能够按照这个规范标准来写
自定义异常
实际中很多公司都会自定义自己的异常体系进行规范的异常管理
- 公司中的项目一般会进行模块划分 让不同的程序员或小组完成不同的模块 如果不对抛异常这件事进行规范那么负责最外层捕获异常的程序员就非常难受了 因为他需要捕获大家抛出的各种类型的异常对象
- 因此实际中都会定义一套继承的规范体系 先定义一个最基础的异常类 所有人抛出的异常对象都必须是继承于该异常类的派生类对象 因为异常语法规定可以用基类捕获抛出的派生类对象 因此最外层就只需捕获基类就行了
下面是基础的异常类代码展示
class Exception
{
public:
Exception(int errid, const char* errmsg)
:_errid(errid)
, _errmsg(errmsg)
{}
int GetErrid() const
{
return _errid;
}
virtual string what() const
{
return _errmsg;
}
protected:
int _errid; //错误编号
string _errmsg; //错误描述
//...
};
这里基类异常类中有两个保护成员分别是错误编号和错误描述
它还有一个虚函数waht()来发出错误描述
接着我们来看它的子类对象
class CacheException : public Exception
{
public:
CacheException(int errid, const char* errmsg)
:Exception(errid, errmsg)
{}
virtual string what() const
{
string msg = "CacheException: ";
msg += _errmsg;
return msg;
}
protected:
//...
};
class SqlException : public Exception
{
public:
SqlException(int errid, const char* errmsg, const char* sql)
:Exception(errid, errmsg)
, _sql(sql)
{}
virtual string what() const
{
string msg = "CacheException: ";
msg += _errmsg;
msg += "sql语句: ";
msg += _sql;
return msg;
}
protected:
string _sql; //导致异常的SQL语句
//...
};
这里要说明两点
- 因为在子类中要使用父类的成员变量 所以父类中的成员变量不能是私有的
- 基类Exception中的what成员函数最好定义为虚函数 方便子类对其进行重写 从而达到多态的效果
标准异常库
下面是C++中的标准异常库体系
下表是对上面继承体系中出现的每个异常的说明
异常的优缺点
优点:
- 异常对象定义好了 相比错误码的方式可以清晰准确的展示出错误的各种信息 甚至可以包含堆栈调用等信息 这样可以帮助更好的定位程序的bug。
- 返回错误码的传统方式有个很大的问题就是 在函数调用链中 深层的函数返回了错误 那么我们得层层返回错误码 最终最外层才能拿到错误。
- 很多的第三方库都会使用异常 比如boost、gtest、gmock等等常用的库,如果我们不用异常就不能很好的发挥这些库的作用
- 很多测试框架也都使用异常 因此使用异常能更好的使用单元测试等进行白盒的测试
- 部分函数使用异常更好处理 比如T& operator这样的函数 如果pos越界了只能使用异常或者终止程序处理 没办法通过返回值表示错误。
缺点
- 异常会导致程序的执行流乱跳 并且非常的混乱 这会导致我们跟踪调试以及分析程序时比较困难
- 异常会有一些性能的开销 当然在现代硬件速度很快的情况下 这个影响基本忽略不计
- C++没有垃圾回收机制 资源需要自己管理 有了异常非常容易导致内存泄露 死锁等异常安全问题 这个需要使用RAII来处理资源的管理问题 学习成本比较高
- C++标准库的异常体系定义得不够好 导致大家各自定义自己的异常体系 非常的混乱
- 异常尽量规范使用 否则后果不堪设想 随意抛异常 也会让外层捕获的用户苦不堪言
- 异常接口声明不是强制的 对于没有声明异常类型的函数 无法预知该函数是否会抛出异常