以前,刚开始学C++的时候,异常都是放在最后面的,前期写的那些百来行代码,也根本用不上这个,所以一直没有尝试过C++的异常处理。直到最近的服务端开发,面对客户端有时不规范的报文格式,服务端有点受不住,会停止运行,这时候我就想起了有这么一个异常处理的东西,真别说,用了之后发现很好用。
下面也就是我对C++异常处理的一些学习总结。
异常,也就可以理解成“异于正常”,就是正常流程之外发生的一些特殊情况、严重错误。程序一旦遇到这种错误,就会跳出正常流程,难以继续执行。
归根到底,异常只是 C++ 为了处理错误而提出的一种解决方案,当然也不会是唯一的一种。
在 C++ 之前,处理异常的基本手段是“错误码”。函数执行后,需要检查返回值或者全局的 errno,看是否正常,如果出错了,就执行另外一段代码处理错误:
int n = read_data(fd, ...); // 读取数据
if (n == 0) {
... // 返回值不太对,适当处理
}
if (errno == EAGAIN) {
... // 适当处理错误
}
这样写很直观,但也让正常的业务代码和错误处理代码混在一起,看起来混乱,导致编程思路断了。
另一个更大问题就是,错误码是可以被忽略的,也就是说,你完全可以不处理错误,“假装”程序运行正常,继续跑后面的代码,这就可能导致严重的安全隐患。
而异常,也就是针对错误码的缺陷而设计的,主要有三个特点:
异常的处理流程是完全独立的,throw 抛出异常后就可以不用管了,错误处理代码都集中在专门的 catch 块里。这样就彻底分离了业务逻辑与错误逻辑,看起来更清楚。
异常是绝对不能被忽略的,必须被处理。如果你有意或者无意不写 catch 捕获异常,那么它会一直向上传播出去,直至找到一个能够处理的 catch 块。如果实在没有,那就会导致程序立即停止运行,明白地提示你发生了错误,而不会“坚持带病工作”。
异常可以用在错误码无法使用的场合,这也算是 C++ 的“私人原因”。因为它比 C 语言多了构造 / 析构函数、操作符重载等新特性,有的函数根本就没有返回值,或者返回值无法表示错误,而全局的 errno 实在是“太不优雅”了,与 C++ 的理念不符,所以也必须使用异常来报告错误。
这三个关键点,是在C++中用好异常的基础,它们能够帮助你在本质上理解异常的各种用法。异常的
基本的try-catch估计大家都会,刚才的错误码例子可以写成:
try
{
int n = read_data(fd, ...); // 读取数据,可能抛出异常
... // do some right thing
}
catch(...)
{
... // 集中处理各种错误情况
}
但会这个,远远没有到用好异常的地步。
首先你要知道,C++ 里对异常的定义非常宽松,任何类型都可以用 throw 抛出,也就是说,你可以直接把错误码(int)、或者错误消息(char*、string)抛出,catch 也能接住,然后处理。
但我建议你最好不要“图省事”,因为 C++ 已经为处理异常设计了一个配套的异常类型体系,定义在标准库的 头文件里。
标准异常的继承体系有点复杂,最上面是基类 exception,下面是几个基本的异常类型,比如 bad_alloc、bad_cast、runtime_error、logic_error,再往下还有更细致的错误类型,像 runtime_error 就有 range_error、overflow_error,等等。
下面就是从runtime_error派生出自己异常类的例子:
class my_exception : public std::runtime_error
{
public:
using this_type = my_exception; // 给自己起个别名
using super_type = std::runtime_error; // 给父类也起个别名
public:
my_exception(const char* msg): // 构造函数
super_type(msg) // 别名也可以用于构造
{}
my_exception() = default; // 默认构造函数
~my_exception() = default; // 默认析构函数
private:
int code = 0; // 其他的内部私有数据
};
在抛出异常的时候,建议是最好不要直接用 throw 关键字,而是要封装成一个函数,这和不要直接用 new、delete 关键字是类似的道理——通过引入一个“中间层”来获得更多的可读性、安全性和灵活性。
[[noreturn]] // 属性标签
void raise(const char* msg) // 函数封装throw,没有返回值
{
throw my_exception(msg); // 抛出异常,也可以有更多的逻辑
}
使用 catch 捕获异常的时候也要注意,C++ 允许编写多个 catch 块,捕获不同的异常,再分别处理。但是,异常只能按照 catch 块在代码里的顺序依次匹配,而不会去找最佳匹配。
这个特性导致实际开发的时候有点麻烦,特别是当异常类型体系比较复杂的时候,有可能会因为写错了顺序,进入你本不想进的 catch 块。所以,我建议你最好只用一个 catch 块,绕过这个“坑”。
写 catch 块就像是写一个标准函数,所以入口参数也应当使用“const &”的形式,避免对象拷贝的代价:
try
{
raise("error occured"); // 函数封装throw,抛出异常
}
catch(const exception& e) // const &捕获异常,可以用基类
{
cout << e.what() << endl; // what()是exception的虚函数
}
具体来说,就是要仔细分析程序中可能发生的各种错误情况,按严重程度划分出等级,把握好“度”。
- 对于正常的返回值,或者不太严重、可以重试 / 恢复的错误,建议不使用异常,把它们归到正常的流程里。
- 比如说字符串未找到(不是错误)、数据格式不对(轻微错误)、数据库正忙(可重试错误),这样的错误比较轻微,而且在业务逻辑里会经常出现,如果用异常处理,就会“小题大做”,影响性能。
- 剩下的那些中级、高级错误也不是都必须用异常,你还要再做分析,尽量降低引入异常的成本。
学的几个使用异常的判断准则:
- 不允许被忽略的错误;
- 极少数情况下才会发生的错误;
- 严重影响正常流程,很难恢复到正常状态的错误;
- 无法本地处理,必须“穿透”调用栈,传递到上层才能被处理的错误。
下面有几个例子,助于理解。
异常有好有坏,难以取舍,如何权衡异常带来的好处和异常带来的成本呢?
C++毕竟是追求性能的语言,提出了一个新的编译阶段命令:noexcept,但它也有一点局限,不是“万能药”。
noexcept 专门用来修饰函数,告诉编译器:这个函数不会抛出异常。编译器看到 noexcept,就得到了一个“保证”,就可以对函数做优化,不去加那些栈展开的额外代码,消除异常处理的成本。
用法如下:
void func_noexcept() noexcept // 声明绝不会抛出异常
{
cout << "noexcept" << endl;
}
不过要注意的是,noexcept 只是做出了一个“不可靠的承诺”,不是“强保证”,编译器无法彻底检查它的行为,标记为 noexcept 的函数也有可能抛出异常:
void func_maybe_noexcept() noexcept // 声明绝不会抛出异常
{
throw "Oh My God"; // 但也可以抛出异常
}
noexcept 的真正意思是:“我对外承诺不抛出异常,我也不想处理异常,如果真的有异常发生,请让我死得干脆点,直接崩溃(crash、core dump)。”所以,你也不要一股脑地给所有函数都加上 noexcept 修饰,毕竟,你无法预测内部调用的那些函数是否会抛出异常。
主要知识点:
- 异常是针对错误码的缺陷而设计的,它不能被忽略,而且可以“穿透”调用栈,逐层传播到其他地方去处理;
- 使用 try-catch 机制处理异常,能够分离正常流程与错误处理流程,让代码更清晰;
- throw 可以抛出任何类型作为异常,但最好使用标准库里定义的 exception 类;
- 完全用或不用异常处理错误都不可取,而是应该合理分析,适度使用,降低异常的成本;
- 关键字 noexcept 标记函数不抛出异常,可以让编译器做更好的优化。
当然,想真的用好异常,还得在实际开发中多多积累经验,多多尝试::>_<::。