文章目录
一. 什么是异常?
异常是一种处理错误的方式:当一个函数发现自己无法处理的错误时就会停止往下运行,然后把这个异常抛出,让这个函数的直接或间接调用者去处理这个错误。
C++中异常相关的关键字有如下三个:
- throw:当问题出现时,程序可以通过 throw 关键字来抛出一个异常(异常其实就是一个内置类型或自定义类型的实例化对象)
- try:try 块中的代码标识“将被激活的特定异常”,即可能抛出异常的代码块必须在 try 内,它后面通常跟着一个或多个 catch 块
- catch:在您想要处理问题的地方,通过 catch 异常处理程序捕获异常对象。
使用 try/catch 语句的语法格式如下所示:
try
{
// 存放可能抛出异常对象的代码块
throw int(); //抛出一个int类型的匿名对象
throw string();//抛出一个string类型的匿名对象
throw A(); //抛出一个自定义类型A的匿名对象
...
}
catch( int num ) //捕获 try 中抛出的类型为 int 的异常对象并完成异常的处理
{
// 处理异常
}
catch( string str ) //捕获 try 中抛出的类型为字符串的异常对象并完成异常的处理
{
// 处理异常
}
catch( A a ) //捕获 try 中抛出的类型为 A 的异常对象并完成异常的处理
{
// 处理异常
}
PS:try 块中放置可能抛出异常的代码(try 块中的代码被称为保护代码),try 代码块之后通常跟着一个或多个 catch 块用来捕获 try 中抛出的不同类型的异常对象(异常对象可以是内置类型,也可以是自定义类型,这个由自己设定)。
二. 为什么要引入异常机制?
在C语言中,传统处理错误的方式有如下三种:
方法一:直接终止程序
- 如发生内存访问错误、除0错误时,操作系统会给进程发送 SIGSEGV 信号、SIGFPE 信号直接终止进程
- 如 assert(…) 函数,若断言为真,则直接终止进程
// 在 memcpy 函数的实现里,一开始就要进行断言检查
void* memcpy(void* dest, const void* src, unsigned int num)
{
//断言,判断传入地址的有效性,防止野指针
assert(dest!=NULL);
assert(src!=NULL);
//...
}
方法二:返回错误码
比如系统的很多库的接口函数,它们调用失败时都会把错误码放到 errno 这个全局变量中:
又比如父进程等待子进程时,通过一个输出型参数来解析子进程的退出状态:
缺点:需要程序员自己去获取 errno 的值,然后倒相应的表中去查找对应的错误信息。
总结:关于C语言处理异常的方式
实际中,C语言大部分都是使用返回错误码的方式来处理异错误的,部分情况下使用 exit 终止程序的方式来处理非常严重的错误。
每次出错要么直接崩溃,要么返回一个错误码(可读性差,还要自己对照错误码去找错误信息)这两种简单暴力的出错处理方式对我们程序员是非常不友好的。
使用抛异常的话,当一个函数发现自己无法处理的错误时就抛出这个异常信息,然后让相应的捕捉函数去处理这个异常。
三. 如何进行抛异常?
1. 抛异常的方法
方法:在 try 块内,通过 throw 抛出异常对象
说明:异常是通过抛出对象而引发的,该对象的类型决定了应该激活对应 try 之下哪个 catch 的处理代码:
2. 抛出的异常对象一定要有对应类型的捕获
运行时,如果检测到抛出的异常对象没有对应类型的 catch 捕捉的话,程序会直接终止:
补充: 使用catch(…)可以捕获任意类型的异常对象
如果有异常被 catch(…) 捕获到了,说明我们当前程序中还存在某种异常没被考虑到和处理:
int Test()
{
try{
throw "exception";
}
catch(...){
cout << "未知异常" << endl;
}
return 0;
}
//------输出结果------
未知异常
3. 异常被捕捉后的执行流顺序
异常对象被抛出后,执行流会直接跳转到捕获它的地方,然后一直往下执行,不会再折回到之前抛出异常的位置:
4. 在函数调用链中异常栈展开匹配原则
- 首先检查 throw 本身是否在 try 块内部,如果在的话就去查找匹配的 catch 语句。如果有匹配的,则调到 catch 的地方进行错误处理。
- 没有匹配的 catch 则退出当前函数栈,继续在调用函数的栈中查找匹配的 catch。
- 如果到达 main 函数的栈后,依旧没有匹配的匹配的 catch,则终止程序。上述这个沿着调用链查找匹配的 catch 子句的过程称为栈展开。所以实际中我们最后都要加一个 catch(…) 捕获任意类型的异常,否则当有异常没捕获,程序就会报错终止。
- 找到匹配的 catch 子句并处理以后,会继续沿着 catch 子句后面继续执行。
核心:被选中的 catch 处理代码是调用链中与该对象类型匹配且离抛出异常位置最近的那一个。
示例:函数多层调用时,异常对象的被捕捉情况
PS:在实际应用中,一般都是把异常统一抛到最外层调用链(main 函数)去处理,然后最外层调用链拿到这些异常后,会写日志记录它们。
5. 规范的异常处理
在实际情况中,可能存在许多种不同类型的异常,我们可以包装一个类来统一描述这些异常对象的信息(包括异常的错误码和错误描述),然后抛异常时,直接抛出这个类的对象即可:
#include <iostream>
using namespace std;
// 自定义一个类,用来封装异常对象(包括异常信息和异常编号)
class MyException
{
public:
// 构造函数传入异常编号和异常信息
MyException(const int id, const string& msg)
:_errid(id)
, _errmsg(msg)
{}
// 获取异常编号
int GetErrId() const
{
return _errid;
}
// 获取异常信息
string What() const
{
return _errmsg;
}
private:
int _errid; //异常编号
string _errmsg;//异常信息
};
int Division()
{
int a, b;
cin >> a >> b;
if (b == 0) throw MyException(1, "除法:除0错误");
return a / b;
}
int Remainder()
{
int a, b;
cin >> a >> b;
if (b == 0) throw MyException(1, "取模:除0错误");
return a % b;
}
int main()
{
try
{
Division();
Remainder();
}
catch (const MyException& e)
{
cout << "错误码:" << e.GetErrId() << endl;
cout << "错误信息:" << e.What() << endl;
}
return 0;
}
现实中,很多公司都会去定义自己公司专属的异常体系来让自己的异常管理规范化。如果一个项目中每个人都随意抛异常,那么外层的调用者基本就没办法玩了,因为针对这些不同类型的异常,他要对每种类型的异常对象设定专门的 catch 捕捉处理。
更实用的是去定义一套继承的异常体系,这样大家抛出的都是继承的派生类对象,最外层调用者只需捕获一个基类对象就可以了,因为派生类对象可以切片赋值给基类对象:
总结:建立一个异常继承体系:抛出派生类对象,使用基类对象来捕获,这样只需写一个 catch 就能捕捉到多种类型的异常;此外我们还可以借助多态来增强异常体系的灵活性。
四. 异常的重新抛出
异常机制的跳转性在使用时可能会带来异常安全的问题:
- 比如在 new 和 delete 中间抛出了异常,导致内存泄漏
- 在 lock 和 unlock 中间抛出了异常导致死锁
下面演示在 new 和 delete 中间抛出异常,导致内存泄漏的场景:
解决办法:在自己调用链中先把这个异常对象给捕了,然后在 catch 中完成 delete 资源清理的工作之后,再把这个异常重新抛出到上面的调用链:
五. 异常安全
在使用异常机制时,应该考虑到以下几点:
- 构造函数完成对象的构造和初始化,最好不要在构造函数中抛出异常,否则可能导致对象构造不完整或没有完全初始化。
- 析构函数主要完成资源的清理工作,最好也不要在析构函数内抛出异常,否则可能导致资源清理不干净,出现资源泄漏(内存泄漏、句柄未关闭等)的问题。
- C++中使用异常时稍不注意就会内存泄漏,比如在 new 和 delete 中间抛出了异常,导致内存泄漏;在 lock 和 unlock 之间抛出了异常导致死锁,不过这类情况可以通过 RAII 来避免。
六. 异常规范约束
C++11定义异常规范的目的是为了让函数调用者更直观的了解到,这个函数可能抛出的异常的情况:
- 可以在函数的后面接 throw(类型),列出这个函数可能抛掷的所有异常的类型。
- 函数的后面接 throw() 或者 noexcept,表示这个函数不抛异常
- 若无异常接口声明,则表示此函数内可以抛掷任意类型的异常
// 1、这里表示这个函数会抛出 A/B/C/D 中的某种类型的异常
void fun() throw(A,B,C,D);
// 2、这里表示这个函数只会抛出bad_alloc的异常
void* operator new (std::size_t size) throw (std::bad_alloc);
// 3、这里表示这个函数不会抛出异常(下面两种写法等价)
void* operator new (std::size_t size, void* ptr) noexcept;
void* operator new (std::size_t size, void* ptr) throw();
PS:查阅 C++ 官方文档时可以看到有些函数标明了 noexcept,表示该函数不会抛出异常:
总结:函数的异常规范可以使函数更干净,但它并不是强制的。C++ 标准委员会也不敢给这种规范设计成强制的,因为之前的很多企业已经累计了巨量的 C++ 代码,如果把这个规范设计成强制的话,之前的代码都会编译不通过。不过我们自己写代码还是遵守这个规范比较好。
七. 异常的优缺点
C++ 异常的优点
- 异常对象定义好了,相比只返回一个错误码的方式,异常对象可以清晰准确的展示出错误的各种信息,甚至可以包含堆栈调用的信息,这样可以帮助我们更好的定位程序的 bug。
- 返回错误码的传统方式有个很大的问题就是:在函数调用链中,深层函数返回了错误码之后,之前每个调用该函数的地方都需要检查返回值,那么我们得层层返回错误,直到最终最外层拿到错误码后才结束;而 C++ 中的异常让错误的处理简化了,出错的地方只管抛出异常即可,然后执行流会直接跳转到对这个异常对象的捕捉处。
- 很多知名的第三方库都包含异常,比如 boost、gtest、gmock 等等常用的库,如果我们的项目不用的话,就不能很好的完成配合。
- 很多测试框架都使用异常,这样能更好的使用单元测试等进行白盒的测试。
- 部分函数使用异常更好处理,比如 T& operator[ ] (size_t pos) 这样的函数,如果 pos 越界了只能使用异常或者终止程序处理,没办法通过返回值表示错误。
C++ 异常的缺点
- 异常会导致程序的执行流乱跳,并且非常的混乱。特别是在调试的时候,如果抛异常了它会直接跳过断点而去到对应的 catch 中,这会导致我们跟踪调试信息以及分析程序时非常困难。
- 异常会有一些性能的开销。当然在现代硬件速度很快的情况下,这个影响基本忽略不计。
- C++ 没有垃圾回收机制,资源需要自己管理。有了异常非常容易导致内存泄漏、死锁等异常安全问题。这个需要使用 RAII 来处理资源的管理问题,学习成本较高。
- C++ 标准库的异常体系定义得不好,导致大家各自定义各自的异常体系,统一性不够好。
- 异常尽量规范使用,否则后果不堪设想。随意抛异常,会让外层捕获的用户苦不堪言。所以异常规范有两点:一、抛出异常对象的类型都继承自一个基类。二、函数是否抛异常、抛什么异常,都使用 func() throw(); 的方式规范化。
总结:异常总体而言,利大于弊,所以工程中我们还是鼓励使用异常的。另外 OO 的语言基本都是用异常来处理错误的,异常一定是未来的大势所趋。
八. C++ 标准库的异常体系
C++ 提供了一系列标准的异常,定义在 < exception > 头文件中,我们可以在程序中使用这些标准的异常。它们总体是通过一系列继承关系组织起来的,如下所示: