【C++】如何用好异常?

以前,刚开始学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的虚函数
}

三、合理使用异常

具体来说,就是要仔细分析程序中可能发生的各种错误情况,按严重程度划分出等级,把握好“度”。

  • 对于正常的返回值,或者不太严重、可以重试 / 恢复的错误,建议不使用异常,把它们归到正常的流程里。
  • 比如说字符串未找到(不是错误)、数据格式不对(轻微错误)、数据库正忙(可重试错误),这样的错误比较轻微,而且在业务逻辑里会经常出现,如果用异常处理,就会“小题大做”,影响性能。
  • 剩下的那些中级、高级错误也不是都必须用异常,你还要再做分析,尽量降低引入异常的成本。

学的几个使用异常的判断准则:

  • 不允许被忽略的错误;
  • 极少数情况下才会发生的错误;
  • 严重影响正常流程,很难恢复到正常状态的错误;
  • 无法本地处理,必须“穿透”调用栈,传递到上层才能被处理的错误。

下面有几个例子,助于理解。

  • 比如说构造函数,如果内部初始化失败,无法创建,那后面的逻辑也就进行不下去了,所以这里就可以用异常来处理。
  • 再比如,读写文件,通常文件系统很少会出错,总会成功,如果用错误码来处理不存在、权限错误等,就显得太啰嗦,这时也应该使用异常。
  • 相反的例子就是 socket 通信。因为网络链路的不稳定因素太多,收发数据失败简直是“家常便饭”。虽然出错的后果很严重,但它出现的频率太高了,使用异常会增加很多的处理成本,为了性能考虑,还是检查错误码重试比较好。

四、保证不抛出异常

异常有好有坏,难以取舍,如何权衡异常带来的好处和异常带来的成本呢?

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 标记函数不抛出异常,可以让编译器做更好的优化。

当然,想真的用好异常,还得在实际开发中多多积累经验,多多尝试::>_<::。

你可能感兴趣的:(【C++】,c++,开发语言)