You cannot select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.

263 lines
15 KiB
Markdown

This file contains ambiguous Unicode characters!

This file contains ambiguous Unicode characters that may be confused with others in your current locale. If your use case is intentional and legitimate, you can safely ignore this warning. Use the Escape button to highlight these characters.

# 09 | exception怎样才能用好异常
你好我是Chrono。
上节课我建议尽量不用裸指针、new和delete因为它们很危险容易导致严重错误。这就引出了一个问题如何正确且优雅地处理运行时的错误。
实际上,想要达成这个目标,还真不是件简单的事情。
程序在运行的时候不可能“一帆风顺”,总会遇到这样那样的内外部故障,而我们写程序的人就要尽量考虑周全,准备各种“预案”,让程序即使遇到问题也能够妥善处理,保证“健壮性”。
C++处理错误的标准方案是“异常”exception。虽然它已经在Java、C#、Python等语言中得到了广泛的认可和应用但在C++里却存在诸多争议。
你也可能在其他地方听到过一种说法:“**现代C++里应该使用异常**。”但这之后呢?应该怎么去用异常呢?
所以,今天我就和你好好聊聊“异常那些事”,说一说为什么要有异常,该怎么用好异常,有哪些要注意的地方。
## 为什么要有异常?
很多人认为C++里的“异常”非常可怕,一旦发生异常就是“了不得的大事”,这其实是因为没有理解异常的真正含义。
实际上,你可以按照它的字面意思,把它理解成“**异于正常**”,就是正常流程之外发生的一些特殊情况、严重错误。一旦遇到这样的错误,程序就会跳出正常流程,甚至很难继续执行下去。
归根到底,**异常只是C++为了处理错误而提出的一种解决方案,当然也不会是唯一的一种**。
在C++之前处理异常的基本手段是“错误码”。函数执行后需要检查返回值或者全局的errno看是否正常如果出错了就执行另外一段代码处理错误
```
int n = read_data(fd, ...); // 读取数据
if (n == 0) {
... // 返回值不太对,适当处理
}
if (errno == EAGAIN) {
... // 适当处理错误
}
```
这种做法很直观,但也有一个问题,那就是**正常的业务逻辑代码与错误处理代码混在了一起**看起来很乱你的思维要在两个本来不相关的流程里来回跳转。而且有的时候错误处理的逻辑要比正常业务逻辑复杂、麻烦得多看了半天你可能都会忘了它当初到底要干什么了容易引起新的错误。你可以对比一下预处理代码与C++代码混在一起的情景。)
错误码还有另一个更大的问题:**它是可以被忽略的**。也就是说,你完全可以不处理错误,“假装”程序运行正常,继续跑后面的代码,这就可能导致严重的安全隐患。(可能是无意的,因为你确实不知道发生了什么错误。)
“没有对比就没有伤害”,现在你就应该明白了,作为一种新的错误处理方式,异常就是针对错误码的缺陷而设计的,它有三个特点。
1. **异常的处理流程是完全独立的**throw抛出异常后就可以不用管了错误处理代码都集中在专门的catch块里。这样就彻底分离了业务逻辑与错误逻辑看起来更清楚。
2. **异常是绝对不能被忽略的,必须被处理**。如果你有意或者无意不写catch捕获异常那么它会一直向上传播出去直至找到一个能够处理的catch块。如果实在没有那就会导致程序立即停止运行明白地提示你发生了错误而不会“坚持带病工作”。
3. **异常可以用在错误码无法使用的场合**这也算是C++的“私人原因”。因为它比C语言多了构造/析构函数、操作符重载等新特性有的函数根本就没有返回值或者返回值无法表示错误而全局的errno实在是“太不优雅”了与C++的理念不符,所以也必须使用异常来报告错误。
记住这三个关键点是在C++里用好异常的基础,它们能够帮助你在本质上理解异常的各种用法。
## 异常的用法和使用方式
C++里异常的用法想必你已经知道了:**用try把可能发生异常的代码“包”起来然后编写catch块捕获异常并处理**。
刚才的错误码例子改用异常,就会变得非常干净清晰:
```
try
{
int n = read_data(fd, ...); // 读取数据,可能抛出异常
... // do some right thing
}
catch(...)
{
... // 集中处理各种错误情况
}
```
基本的try-catch谁都会写那么怎样才能用好异常呢
首先你要知道C++里对异常的定义非常宽松任何类型都可以用throw抛出也就是说你可以直接把错误码int、或者错误消息char\*、string抛出catch也能接住然后处理。
但我建议你最好不要“图省事”因为C++已经为处理异常设计了一个配套的异常类型体系,定义在标准库的<stdexcept>头文件里。
下面我画了个简单的示意图,你可以看一下。
![](https://static001.geekbang.org/resource/image/8b/67/8ba78367ce53d54a393a8963bb62e867.jpg)
标准异常的继承体系有点复杂最上面是基类exception下面是几个基本的异常类型比如bad\_alloc、bad\_cast、runtime\_error、logic\_error再往下还有更细致的错误类型像runtime\_error就有range\_error、overflow\_error等等。
我在[第5节课](https://time.geekbang.org/column/article/235301)讲过,如果继承深度超过三层,就说明有点“过度设计”,很明显现在就有这种趋势了。所以,我建议你最好选择上面的第一层或者第二层的某个类型作为基类,不要再加深层次。
比如说你可以从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关键字是类似的道理——**通过引入一个“中间层”来获得更多的可读性、安全性和灵活性**。
抛异常的函数不会有返回值,所以应该用[第4节课](https://time.geekbang.org/column/article/235295)里的“属性”做编译阶段优化:
```
[[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的虚函数
}
```
关于try-catch还有一个很有用的形式**function-try。**我一直都觉得非常奇怪的是这个形式如此得简单清晰早在C++98的时候就已经出现了但知道的人却非常少。
所谓function-try就是把整个函数体视为一个大try块而catch块放在后面与函数体同级并列给你看个示例
```
void some_function()
try // 函数名之后直接写try块
{
...
}
catch(...) // catch块与函数体同级并列
{
...
}
```
这样做的好处很明显,不仅能够捕获函数执行过程中所有可能产生的异常,而且少了一级缩进层次,处理逻辑更清晰,我也建议你多用。
## 谨慎使用异常
掌握了异常和它的处理方式,下面我结合我自己的经验,和你讨论一下应该在什么时候使用异常来处理错误。
目前的C++世界里有三种使用异常的方式(或者说是观点)。
第一种是绝不使用异常就像是C语言那样只用传统的错误码来检查错误。
选择禁止异常的原因当然有很多,有的也很合理,但我觉得这就等于浪费了异常机制,对于改善代码质量没有帮助,属于“**因噎废食**”。
第二种则与第一种相反,主张全面采用异常,所有的错误都用异常的形式来处理。
但你要知道,异常也是有成本的。
异常的抛出和处理需要特别的栈展开stack unwind操作如果异常出现的位置很深但又没有被及时处理或者频繁地抛出异常就会对运行性能产生很大的影响。这个时候程序全忙着去处理异常了正常逻辑反而被搁置。
这种观点我认为是“**暴饮暴食**”,也不可取。
所以,第三种方式就是两者的折中:区分“非”错误、“轻微”错误和“严重”错误,谨慎使用异常。我认为这应该算是“**均衡饮食**”。
具体来说,就是要仔细分析程序中可能发生的各种错误情况,按严重程度划分出等级,把握好“度”。
对于正常的返回值,或者不太严重、可以重试/恢复的错误,我建议你不使用异常,把它们归到正常的流程里。
比如说字符串未找到(不是错误)、数据格式不对(轻微错误)、数据库正忙(可重试错误),这样的错误比较轻微,而且在业务逻辑里会经常出现,如果你用异常处理,就会“小题大做”,影响性能。
剩下的那些中级、高级错误也不是都必须用异常,你还要再做分析,尽量降低引入异常的成本。
我自己总结了几个应当使用异常的判断准则:
1. 不允许被忽略的错误;
2. 极少数情况下才会发生的错误;
3. 严重影响正常流程,很难恢复到正常状态的错误;
4. 无法本地处理,必须“穿透”调用栈,传递到上层才能被处理的错误。
规则听起来可能有点不好理解,我给你举几个例子。
比如说构造函数,如果内部初始化失败,无法创建,那后面的逻辑也就进行不下去了,所以这里就可以用异常来处理。
再比如,读写文件,通常文件系统很少会出错,总会成功,如果用错误码来处理不存在、权限错误等,就显得太啰嗦,这时也应该使用异常。
相反的例子就是socket通信。因为网络链路的不稳定因素太多收发数据失败简直是“家常便饭”。虽然出错的后果很严重但它出现的频率太高了使用异常会增加很多的处理成本为了性能考虑还是检查错误码重试比较好。
## 保证不抛出异常
看到这里,你是不是觉得异常是把“双刃剑”呢?优点缺点都有,难以取舍。
有没有什么办法既能享受异常的好处,又不用承担异常的成本呢?
还真有这样的“好事”毕竟写C++程序追求的就是性能所以C++标准就又提出了一个新的编译阶段指令:**noexcept**,但它也有一点局限,不是“万能药”。
noexcept专门用来修饰函数告诉编译器这个函数不会抛出异常。编译器看到noexcept就得到了一个“保证”就可以对函数做优化不去加那些栈展开的额外代码消除异常处理的成本。
和const一样noexcept要放在函数后面
```
void func_noexcept() noexcept // 声明绝不会抛出异常
{
cout << "noexcept" << endl;
}
```
不过你要注意noexcept只是做出了一个“不可靠的承诺”不是“强保证”编译器无法彻底检查它的行为标记为noexcept的函数也有可能抛出异常
```
void func_maybe_noexcept() noexcept // 声明绝不会抛出异常
{
throw "Oh My God"; // 但也可以抛出异常
}
```
noexcept的真正意思是“我对外承诺不抛出异常我也不想处理异常如果真的有异常发生请让我死得干脆点直接崩溃crash、core dump。”
所以你也不要一股脑地给所有函数都加上noexcept修饰毕竟你无法预测内部调用的那些函数是否会抛出异常。
## 小结
今天的话题是错误处理和异常,因为它实在太大了,想要快速说清、说透实在是“不可能的任务”,我们可以在课后继续讨论。
异常也与上一讲的智能指针密切相关如果你决定使用异常为了确保出现异常的时候资源会正确释放就必须禁用裸指针改成智能指针用RAII来管理内存。
由于异常出现和处理的时机都不好确定当前的C++也没有在语言层面提出更好的机制,所以,你还要在编码阶段写好文档和注释,说清楚哪些函数、什么情况下会抛出什么样的异常,应如何处理,加上一些“软约束”。
再简单小结一下今天的内容:
1. 异常是针对错误码的缺陷而设计的,它不能被忽略,而且可以“穿透”调用栈,逐层传播到其他地方去处理;
2. 使用try-catch机制处理异常能够分离正常流程与错误处理流程让代码更清晰
3. throw可以抛出任何类型作为异常但最好使用标准库里定义的exception类
4. 完全用或不用异常处理错误都不可取,而是应该合理分析,适度使用,降低异常的成本;
5. 关键字noexcept标记函数不抛出异常可以让编译器做更好的优化。
## 课下作业
最后是课下作业时间,给你留两个思考题:
1. 结合自己的实际情况,谈一下使用异常有什么好处和坏处。
2. 你觉得用好异常还有哪些要注意的地方?
欢迎你在留言区写下你的思考和答案,如果觉得今天的内容对你有所帮助,也欢迎分享给你的朋友,我们下节课见。
![](https://static001.geekbang.org/resource/image/96/6e/96a9e2f3c794a3b24df1a49e1ce8c16e.jpg)