文章目录
- 一. 什么是异常?
- 二. 为什么要引入抛异常机制?
- 方法一:直接终止程序
- 方法二:返回错误码
- 方法三:C 标准库中的 setjmp 和 longjmp 组合
- 总结 C 中处理异常的方式
- 三. 如何进行抛异常?
- 1. 关于抛出的异常对象的类型
- 2. 抛出的异常对象一定要有对应类型的捕获
- 3. 异常的执行流顺序
- 4. 在函数调用链中异常栈展开匹配原则
- 5. 规范的异常处理
- 四. 异常的重新抛出
- 五. 异常安全
- 六. 异常规范
- 七. 异常的优缺点
- 八. C++ 标准库的异常体系
一. 什么是异常?
异常是一种处理错误的方式:当一个函数发现自己无法处理的错误时就会停止往下运行,然后把这个异常抛出去,让这个函数的直接或间接调用者去处理这个错误。
C++ 中异常相关的关键字有如下三个:
- throw:当问题出现时,程序可以通过 throw 关键字来抛出一个异常(异常其实就是一个对象,可以是基本类型,也可以是自定义类型)
- try:try 块中的代码标识“将被激活的特定异常”,即可能抛出异常的代码块必须在 try 内,它后面通常跟着一个或多个 catch 块。
- catch:在您想要处理问题的地方,通过异常处理程序捕获异常,catch 关键字用于捕获异常,可以有多个 catch 进行捕获。
PS:如果有一个块抛出一个异常,捕获异常的方法会使用 try 和 catch 关键字。try 块中放置可能抛出异常的代码(try 块中的代码被称为保护代码),然后后面通常跟着一个或多个 catch 块用来捕获不同类型的异常对象(异常对象可以是基本类型,也可以是自定义类型,这个由自己来设定)。
使用 try/catch 语句的语法格式如下所示:
try
{// 可能抛出异常对象的代码
}
catch( A a ) //捕获 try 中抛出的类型为 A 的异常对象
{// 处理异常
}
catch( B b ) //捕获 try 中抛出的类型为 B 的异常对象
{// 处理异常
}
catch( C c ) //捕获 try 中抛出的类型为 C 的异常对象
{// 处理异常
}
...
二. 为什么要引入抛异常机制?
在 C 语言中,传统的处理错误的方式有如下三个:
方法一:直接终止程序
- 如发生内存错误、除 0 错误时,操作系统会给进程发送 SIGSEGV 信号、SIGFPE 信号来直接终止进程。
- 如 assert(…) 函数。缺陷:用户难以接受。
// 在 memcpy 函数的实现里,一开始就要进行断言检查
void* memcpy(void* dest, const void* src, unsigned int num)
{//断言,判断传入地址的有效性,防止野指针assert(dest!=NULL);assert(src!=NULL);//...
}
方法二:返回错误码
缺陷:需要程序员自己去查找对应的错误
比如系统的很多库的接口函数,调用失败时都是通过把错误码放到 errno 中:
又比如父进程等待子进程时,通过一个输出型参数来解析子进程的退出状态
方法三:C 标准库中的 setjmp 和 longjmp 组合
setjmp 和 longjmp是 C 标准库中的函数,它们用于实现非局部跳转(non-local jumps)。这意味着你可以在程序的不同位置之间跳转,而不仅仅是在函数之间跳转。
setjmp
函数原型如下:
int setjmp(jmp_buf env);
函数说明:setjmp 函数用于保存程序的当前执行环境,包括 CPU 寄存器和栈信息,并将这些信息保存在 jmp_buf 类型的变量 env 中。然后,setjmp 函数返回 0,表示保存环境成功。
longjmp
函数的原型如下:
void longjmp(jmp_buf env, int val);
函数说明:longjmp 函数用于从一个先前保存的环境(通过 setjmp 函数保存的)恢复程序的执行。它接受两个参数,第一个参数是保存的环境 env,第二个参数是一个整数值 val,它指定了程序应该跳转到setjmp调用的位置,并且可以传递一个值给 setjmp 函数。程序将继续执行,就好像从 setjmp 返回一样。
总结 C 中处理异常的方式
实际中 C 语言基本都是使用返回错误码的方式处理错误,部分情况下使用终止程序的方式处理非常严重的错误。
每次出错要么直接崩溃,要么返回一个错误码(可读性差,还要自己对照错误码去找错误信息)。使用抛异常的话,当一个函数发现自己无法处理的错误时就可以抛出异常,让这个函数的直接或间接调用者去自定义处理这个错误的方式。
三. 如何进行抛异常?
1. 关于抛出的异常对象的类型
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 处理代码是调用链中与该对象类型匹配且离抛出异常位置最近的那一个。
示例:函数多层调用时,异常对象的被捕捉情况
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 就能捕捉到多种类型的异常;此外我们还可以借助多态来增强异常体系的灵活性。
四. 异常的重新抛出
C++ 中异常经常会导致资源泄漏的问题:
- 比如在 new 和 delete 中间抛出了异常,导致内存泄漏
- 在 lock 和 unlock 中间抛出了异常导致死锁
下面演示在 new 和 delete 中间抛出异常,导致内存泄漏的场景:
解决办法:在自己调用链中先把这个异常对象给捕了,然后在 catch 中完成 delete 资源清理的工作之后,再把这个异常重新抛出到上面的调用链
PS:不过这种写法太挫了,不推荐这样处理。在 C++ 更推荐使用 RAII 来解决这种情况。
五. 异常安全
在使用异常机制时,应该考虑到以下几点:
- 构造函数完成对象的构造和初始化,最好不要在构造函数中抛出异常,否则可能导致对象不完整或没有完全初始化。
- 析构函数主要完成资源的清理,最好不要在析构函数内抛出异常,否则可能导致资源清理的不干净,出现资源泄漏(内存泄漏、句柄未关闭等)的问题。
- 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();
查阅 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 > 头文件中,我们可以在程序中使用这些标准的异常。它们是以父子类层次结构组织起来的,如下所示: