异常(C++)

1. 概念

1.1 C语言处理错误机制

C语言处理错误的机制有以下几种:

  • 通过返回值检查错误。C语言中,大多数的函数或者系统调用都会返回一个特定的值来表示是否发生了错误,比如-1或者NULL。同时,还会设置一个全局变量errno来存储具体的错误代码。我们可以通过检查这些返回值和errno来判断是否出现了错误,并进行相应的处理。
  • 通过assert宏断言错误。assert宏是一种调试工具,用于在程序运行时检查某个条件是否为真。如果为假,就会终止程序并打印出错信息。这种机制适用于那些不应该发生的错误,比如指针为空、数组越界等。
  • (不常见)通过setjmp和longjmp跳转错误。setjmp和longjmp是两个函数,用于在程序中设置一个跳转点,并在发生错误时跳转回该点。这种机制可以实现类似于异常处理的功能,但是需要注意保存和恢复程序的状态。
  • 通过自定义函数或者结构体封装错误。我们也可以根据自己的需求,定义一些专门用于处理错误的函数或者结构体,并在程序中调用它们。这种机制可以提高代码的可读性和可维护性。

1.2 C++异常机制

C++的异常是一种在程序运行过程中发生的意外情况,比如除零、内存不足、无效参数、数组越界等等。C++提供了一种机制来处理这些异常,这种机制分为两部分:异常检测和异常处理。当程序出现问题时,程序会抛出一个异常。这是通过使用 throw 关键字来完成的。然后,使用 try 块来捕获异常,并使用 catch 块来处理异常。C++标准库还提供了一个基类std::exception,用于声明作为异常抛出的对象。这个类有一个虚成员函数what,用于返回一个描述异常的字符串。

C语言处理错误的方式简单粗暴,直接终止进程难免“伤及无辜”,就拿音乐播放器来说,难道因为某个歌词显示错误就直接中断程序吗?这显然是不合理的,C++的异常机制就好像把错误交给一个中间者,如果在某个模块中出现了错误,那么就把这个错误暂时塞给它,它会执行我们事先规定好的处理流程,同时它也会给上层一些事先规定要提示的内容,此时进程是不会被系统终止的,其他模块也能正常运作。这就保证了程序的稳定性。

C++ 的异常机制主要由三个关键字完成:

  • throw表达式(throw expression),异常检测部分使用throw表达式来表示它遇到了无法处理的问题。我们说throw引发(raise)了异常。
  • try语句块(try block),异常处理部分使用try语句块处理异常。try语句块以关键字try开始,并以一个或多个catch子句(catch clause)结束。try语句块中代码抛出的异常通常会被某个catch子句处理。因为catch子句“处理”异常,所以它们也被称作异常处理代码(exception handler)。
  • 一套异常类(exception class),用于在throw表达式和相关的catch子句之间传递异常的具体

throw(抛出)就是某个模块(一般是函数)遇到错误后,把问题塞给那个中间者,然后它会处理(catch)异常。前提是那个中间者有在“监视”(try)这个函数。

throw表达式

程序的异常检测部分使用throw表达式引发一个异常。throw表达式包含关键字throw和紧随其后的一个表达式,其中表达式的类型就是抛出的异常类型。throw表达式后面通常紧跟一个分号,从而构成一条表达式语句。

实际上,有时不会传递错误信息,而是将错误信息写入错误日志。因为错误信息对开发者而言更有意义。

try…catch语句

跟在try块之后的是一个或多个catch子句。catch子句包括三部分:关键字catch、括号内一个(可能未命名的)对象的声明(称作异常声明,exception declaration)以及一个块。当选中了某个catch子句处理异常之后,执行与之对应的块。catch一旦完成,程序跳转到try语句块最后一个catch子句之后的那条语句继续执行。

语法:

try 
{
  	// 可能抛出异常的代码
}
catch(异常类型1) 
{
  	// 处理该类型异常的代码
}
catch(异常类型2) 
{
  	// 处理该类型异常的代码
}
// 可以有多个catch块,用于处理不同类型的异常

注意,每个catch都是相互独立的,各自变量的作用域都在大括号内部。

例子

为了更好地理解C++的异常处理,一个简单的例子以供参考:

#include <iostream>
using namespace std;

double fun(int a, int b)
{
    if(b == 0)
    {
        throw "除零异常";
    }
    return a / b;
}
int main()
{
    int a = 1, b = 0;

    try
    {
        fun(a, b);
    }
    catch (const char* msg)
    {
        cout << msg << endl;
    }
    
    cout << fun(4, 2) << endl;
    
    return 0;
}

输出结果为:

除零异常
0

其中,被除数b为0,程序发生除零错误。事先在函数中写好如果发生错误应该抛出的错误信息,这个信息在上面是一个字符串。在main函数中,try就相当于监视,如果发现fun抛出了异常,那么这个字符串就像一个参数一样被传递到catch中,在catch中也会执行实现规定好的动作。

从第二个结果来看,如果没有发生事先预想的错误,try不到函数抛出的异常,也就不会执行catch语句。其他正常的功能不受上一次错误的影响,整个进程也不会被中断。

事实上,根据抛出的异常的类型,可以有多个catch块。在这里,throw抛出的对象的类型是一个C语言类型的字符串,即const char*

2. 抛出异常

我们通过抛出(throwing)一条表达式来引发(raised)一个异常。被抛出的表达式的类型以及当前的调用链共同决定了哪段处理代码(handler)将被用来处理该异常(即匹配的catch块)。被选中的处理代码是在调用链中与抛出对象类型匹配的最近的处理代码。其中,根据抛出对象的类型和内容,程序的异常抛出部分将会告知异常处理部分到底发生了什么错误。

当执行一个throw时,跟在throw后面的语句将不再被执行。相反,程序的控制权从throw转移到与之匹配的catch模块。该catch可能是同一个函数中的局部catch,也可能位于直接或间接调用了发生异常的函数的另一个函数中。控制权从一处转移到另一处,这有两个重要的含义(稍后会解释):

  • 沿着调用链的函数可能会提早退出。
  • 一旦程序开始执行异常处理代码,则沿着调用链创建的对象将被销毁。

因为跟在throw后面的语句将不再被执行,所以throw语句的用法有点类似于return语句:它通常作为条件语句的一部分或者作为某个函数的最后(或者唯一)一条语句。

2.1 栈展开

**当抛出一个异常后,程序暂停当前函数的执行过程并立即开始寻找与异常匹配的catch子句。**函数中出现错误时,可能是直接被调用,也可能是间接被调用。

直接调用:当throw出现在一个try语句块内时,检查与该try块关联的catch子句。如果找到了匹配的catch,就使用该catch处理异常。

如果这一步没找到匹配的catch且该try语句嵌套在其他try块中,则继续检查与外层try匹配的catch子句。如果还是找不到匹配的catch,则退出当前的函数,在调用当前函数的外层函数中继续寻找。(这是一个由内向外查找匹配catch块的过程,了解即可)

间接调用:如果对抛出异常的函数的调用语句位于一个try语句块内,则检查与该try块关联的catch子句。如果找到了匹配的catch,就使用该catch处理异常。

否则,如果该try语句嵌套在其他try块中,则继续检查与外层try匹配的catch子句。如果仍然没有找到匹配的catch,则退出当前这个主调函数,继续在调用了刚刚退出的这个函数的其他函数中寻找,以此类推。

上述过程被称为栈展开(stack unwinding)过程。栈展开过程沿着嵌套函数的调用链不断查找,直到找到了与异常匹配的catch子句为止;或者也可能一直没找到匹配的catch,则退出主函数后查找过程终止。

假设找到了一个匹配的catch子句,则程序进入该子句并执行其中的代码。当执行完这个catch子句后,找到与try块关联的最后一个catch子句之后的点,并从这里继续执行,即只执行一个catch语句

如果没找到匹配的catch子句,程序将退出。因为异常通常被认为是妨碍程序正常执行的事件,所以一旦引发了某个异常,就不能对它置之不理。当找不到匹配的catch时,程序将调用标准库函数terminate,顾名思义,terminate负责终止程序的执行过程。

注意:一个异常如果没有被捕获,那么它将会终止当前进程。

catch(...)的作用是能够捕获任何数据类型的异常对象,而不需要指定具体的异常类型。这样可以避免漏掉一些未知或者不常见的异常,提高程序的健壮性和稳定性。

例如,以下代码可以捕获任何类型的异常:

try 
{
    // 保护代码
}
catch(...) 
{
    // 处理任何异常
    cout << "未知异常" << endl;
}
栈展开的例子

在函数调用链中异常栈展开的匹配规则在上面已经介绍过了,总结起来就是如果在throw之处找不到匹配的catch语句,它就会到调用这个函数的地方找,以此类推,不论直接调用或间接,最后都会回到main函数的栈帧。

例如:

void func1()
{
    throw string("出现异常");
}
void func2()
{
    func1();
}
void func3()
{
    func2();
}
int main()
{
    try
    {
        func3();
    }
    catch (const string& msg)
    {
        cout << msg << endl;
    }
    catch (...)
    {
        cout << "未知异常" << endl;
    }

    return 0;
}

输出:

出现异常

image-20230304160944336

在main函数中的try块,func3调用func2,func2调用func1,因此它们的栈帧如图所示。try到抛出的异常以后,并未在func3中找到和catch类型匹配的throw,所以不断往上走,直到匹配func1的throw。这个查找匹配的catch块的过程就是栈展开。

2.2 栈展开过程中对象被自动销毁

在栈展开过程中,位于调用链上的语句块可能会提前退出。通常情况下,程序在这些块中创建了一些局部对象。我们已经知道,块退出后它的局部对象也将随之销毁,这条规则对于栈展开过程同样适用。如果在栈展开过程中退出了某个块,编译器将负责确保在这个块中创建的对象能被正确地销毁。如果某个局部对象的类型是类类型,则该对象的析构函数将被自动调用。与往常一样,编译器在销毁内置类型的对象时不需要做任何事情。

如果异常发生在构造函数中,则当前的对象可能只构造了一部分。有的成员已经初始化了,而另外一些成员在异常发生前也许还没有初始化。即使某个对象只构造了一部分,我们也要确保已构造的成员能被正确地销毁。

类似的,异常也可能发生在数组或标准库容器的元素初始化过程中。与之前类似,如果在异常发生前已经构造了一部分元素,则我们应该确保这部分元素被正确地销毁。

2.3 析构函数与异常

析构函数总是会被执行的,但是函数中负责释放资源的代码却可能被跳过,这一特点对于我们如何组织程序结构有重要影响。如果一个块分配了资源,并且在负责释放这些资源的代码前面发生了异常,则释放资源的代码将不会被执行。另一方面,类对象分配的资源将由类的析构函数负责释放。因此,如果我们使用类来控制资源的分配,就能确保无论函数正常结束还是遭遇异常,资源都能被正确地释放。即将资源的管理绑定在类的生命周期上。

析构函数在栈展开的过程中执行,这一事实影响着我们编写析构函数的方式。在栈展开的过程中,已经引发了异常但是我们还没有处理它。如果异常抛出后没有被正确捕获,则系统将调用terminate函数(终止进程)。因此,出于栈展开可能使用析构函数的考虑,析构函数不应该抛出不能被它自身处理的异常。换句话说,如果析构函数需要执行某个可能抛出异常的操作,则该操作应该被放置在一个try语句块当中,并且在析构函数内部得到处理。

在实际的编程过程中,因为析构函数仅仅是释放资源,所以它不太可能抛出异常。所有标准库类型都能确保它们的析构函数不会引发异常。

注意:

在栈展开的过程中,运行类类型的局部对象的析构函数。因为这些析构函数是自动执行的,所以它们不应该抛出异常。一旦在栈展开的过程中析构函数抛出了异常,并且析构函数自身没能捕获到该异常,则程序将被终止。

内存泄漏

例如,下面代码的目的是用函数调用链的最外层函数捕获异常:

void func1()
{
    throw string("出现异常");
}
void func2()
{
    int* arr = new int[10];
    func1(); // 调用func1()

    // 一系列操作

    delete[] arr;
}
int main()
{
    try
    {
        func2();
    }
    catch (const string& s)
    {
        cout << s << endl;
    }
    catch (...)
    {
        cout << "未知异常" << endl;
    }
    
    return 0;
}

输出:

出现异常

func2()通过new申请了内存,在最后delete释放空间。但是在delete之前调用的func1()中抛出了异常,因此此时会跳转到main函数栈帧的catch块处执行异常处理语句。处理完毕后会直接跳转到最后一个catch子句之后继续执行,对于func2()来说,它只执行了调用func1()之前的语句,跳过了delete(即无法调用析构函数),造成了内存泄漏。

解决方法:在func2()中捕获func1()的抛出的异常,释放内存以后再将异常重新抛出,详见3.2。

2.4 异常对象

虽然上面的例子中我们throw的是一个字符串,但实际上标准库中已经有一个专门用来处理异常的类型,叫做异常对象。

异常对象(exception object)是一种特殊的对象,编译器使用异常抛出表达式来对异常对象进行拷贝初始化。因此,throw语句中的表达式必须拥有完全类型。而且如果该表达式是类类型的话,则相应的类必须含有一个可访问的析构函数和一个可访问的拷贝或移动构造函数。如果该表达式是数组类型或函数类型,则表达式将被转换成与之对应的指针类型。

C++完全类型是指编译器能够计算出类型的大小和布局的类型。也就是说,必须有类型的完整定义,而不仅仅是前向声明。

例如,以下代码中,A是一个不完全类型,因为只有前向声明,而没有定义:

class A; // 前向声明
class B {
    A* p; // 可以使用不完全类型的指针
    A a; // 错误,不能使用不完全类型的对象
};

要使A成为一个完全类型,需要提供类的定义:

class A {
    int x;
}; // 完整定义
class B {
    A* p; // 可以使用完全类型的指针
    A a; // 可以使用完全类型的对象
};

异常对象位于由编译器管理的空间中,编译器确保无论最终调用的是哪个catch子句都能访问该空间。当异常处理完毕后,异常对象被销毁。

如我们所知,当一个异常被抛出时,沿着调用链的块将依次退出直至找到与异常匹配的处理代码。如果退出了某个块,则同时释放块中局部对象使用的内存。因此,抛出一个指向局部对象的指针几乎肯定是一种错误的行为。出于同样的原因,从函数中返回指向局部对象的指针也是错误的。如果指针所指的对象位于某个块中,而该块在catch语句之前就已经退出了,则意味着在执行catch语句之前局部对象已经被销毁了。

当我们抛出一条表达式时,该表达式的静态编译时类型决定了异常对象的类型。因为很多情况下程序抛出的表达式类型来自于某个继承体系。如果一条throw表达式解引用一个基类指针,而该指针实际指向的是派生类对象,则抛出的对象将被切掉一部分(切片),只有基类部分被抛出。

实际上,捕获和抛出的异常类型并不一定要完全匹配(代码上的一致),可以抛出派生类对象,用父类进行捕获,而这也是常用的做法,在下一节会介绍。

注意:

抛出指针要求在任何对应的处理代码存在的地方,指针所指的对象都必须存在。

3. 捕获异常

catch子句(catch clause)中的异常声明(exception declaration)看起来像是只包含一个形参的函数形参列表。像在形参列表中一样,如果catch无须访问抛出的表达式的话,则我们可以忽略捕获形参的名字。

声明的类型决定了处理代码所能捕获的异常类型。这个类型必须是完全类型,它可以是左值引用,但不能是右值引用。

当进入一个catch语句后,通过异常对象初始化异常声明中的参数。和函数的参数类似,如果catch的参数类型是非引用类型,则该参数是异常对象的一个副本,在catch语句内改变该参数实际上改变的是局部副本而非异常对象本身;相反,如果参数是引用类型,则和其他引用参数一样,该参数是异常对象的一个别名,此时改变参数也就是改变异常对象。

catch的参数还有一个特性也与函数的参数非常类似:如果catch的参数是基类类型,则我们可以使用其派生类类型的异常对象对其进行初始化。此时,如果catch的参数是非引用类型,则异常对象将被切掉一部分(切片),这与将派生类对象以值传递的方式传给一个普通函数差不多。另一方面,如果catch的参数是基类的引用,则该参数将以常规方式绑定到异常对象上(别名)。

3.1 捕获子类异常

在之前的例子中介绍了非继承体系的异常捕获,下面介绍继承体系的异常捕获。

想象一个场景:一个项目里有不同的组,而不同的组又有它自己的异常,那么最后在捕获异常的时候是非常麻烦的,因为不同组的异常的类型可能是不一样的(本质是一个类)。

如果其他组的异常都是子类抛出的异常,catch用父类做形参,那么就能匹配父类或子类的异常,而且还能利用继承体系的多态特性调用各自子类重写的虚函数,如what()等。

在尝试捕获涉及父类和子类的异常类型时要注意catch子句的排序方式,因为父类的catch子句也会匹配它的任何子类。例如,由于所有异常的父类都是Exception,因此带有catch的Exception会捕获所有可能的异常。所以,应该将父类放在最后,以避免屏蔽子类。例如:

#include <iostream>
using namespace std;

class Base 
        {};
class Derived: public Base 
        {};

int main ()
{
    try
    {
        throw Derived();
    }
    catch(const Derived& d)
    {
        cout << "Derived exception caught" << endl;
    }
    catch(const Base& b)
    {
        cout << "Base exception caught" << endl;
    }
    
    return 0;
}

输出:

Derived exception caught

如果交换两个catch子句的顺序,则会输出:

Base exception caught

当然,实际情况下子类类型的异常可能不止一个,catch的数量只要针对性地增加即可。

性能消耗:throw异常对象类似函数传值传参,会产生临时拷贝,C++11后可将其识别为右值,通常情况下,如果catch接受的异常与某个继承体系有关,则最好将该catch的参数定义成(const)引用类型。

  • 使用引用类型可以避免异常对象的拷贝,提高效率,并且可以正确处理异常类的继承关系。如果使用值类型,可能会发生切片现象,导致捕获到的异常对象不是完整的。
  • 使用const可以防止修改异常对象,保持其不变性,并且可以捕获到const异常。如果不使用const,可能会导致编译错误或者无法捕获到某些异常。

然而,异常并非一定是不好的,例如在你被删除好友时给对方发消息,会转好一会的圈,其实这时候就已经抛出异常了,程序可能以为网络等环境问题,正在重试。

如果某个地方抛出的异常非常重要,例如堆栈信息,方式是在子类中增加protected的成员。

注意,异常声明的静态类型将决定catch语句所能执行的操作。如果catch的参数是基类类型,则catch无法使用派生类特有的任何成员。

3.2 异常的重新抛出

有时,一个单独的catch语句不能完整地处理某个异常。在执行了某些操作之后,当前的catch可能会决定由调用链更上一层的函数接着处理异常,例如最外层函数需要得到某些日志信息。

一条catch语句通过重新抛出(rethrowing)的操作将异常传递给另外一个catch语句。这里的重新抛出仍然是一条throw语句,只不过不包含任何表达式:

throw;

==空的throw语句只能出现在catch语句或catch语句直接或间接调用的函数之内。==如果在处理代码之外的区域遇到了空throw语句,编译器将调用terminate(终止进程)。一个重新抛出语句并不指定新的表达式,而是将当前的异常对象沿着调用链向上传递(类似函数传参)。

很多时候,catch语句会改变其参数(即异常对象)的内容。如果在改变了参数的内容后catch语句重新抛出异常,则只有当catch异常声明是引用类型时我们对参数所做的改变才会被保留并继续传递。

例如2.3中的例子:

void func1()
{
    throw string("出现异常");
}
void func2()
{
    int* arr = new int[10];
    func1(); // 调用func1()

    // 一系列操作

    delete[] arr;
}
int main()
{
    try
    {
        func2();
    }
    catch (const string& s)
    {
        cout << s << endl;
    }
    catch (...)
    {
        cout << "未知异常" << endl;
    }
    
    return 0;
}

其中func2中申请的内存因为异常的抛出没有得到释放,造成了内存泄漏。

解决办法:在func2()中捕获func1()的抛出的异常,释放内存以后再将异常重新抛出:

void func2()
{
    int* arr = new int[10];
    try
    {
        func1();
    }
    catch (...)
    {
        delete[] arr;
        throw; // 重新抛出异常
    }

    delete[] arr;
}

一旦在fun2()内部捕获func1()抛出的异常,就会跳转到catch块中释放申请的资源,然后重新抛出异常,像无事发生一样。

其中,catch(...)处理任何异常,以避免因匹配不到catch块而导致进程终止,当然根据具体情况也能详尽地列出可能的catch块。

4. 异常安全

异常安全是指在函数报告错误条件后,对程序状态的额外保证。有以下四种级别的异常保证,它们是严格的超集关系:

  • 不抛出异常保证(no-fail guarantee):这是最强的保证,它意味着一个函数不会抛出任何异常,也不会改变程序的状态。比如析构函数或者移动构造函数。
  • 强异常保证(strong guarantee):这是次强的保证,它意味着一个函数如果抛出了异常,那么程序的状态会回滚到调用该函数之前的状态。比如std::vector::push_back或者std::swap。
  • 基本异常保证(basic guarantee):这是最弱的保证,它意味着一个函数如果抛出了异常,那么程序的状态仍然是有效和一致的,但可能不是预期的。比如std::vector::reserve或者std::list::splice。

4.2 例子

不抛出异常保证

假设我们有一个类Foo,它有一个成员变量m_data,它是一个指向int的指针。Foo的构造函数会分配一块内存给m_data,并初始化为0。Foo的析构函数会释放m_data指向的内存。Foo还有一个移动构造函数,它会接受另一个Foo对象作为参数,并交换它们的m_data指针。

class Foo 
        {
public:
    Foo() 
        : m_data(new int(0)) // 构造函数
    {} 
    ~Foo() noexcept // 析构函数不抛出异常
    { 
        delete m_data; 
    } 
    Foo(Foo&& other) noexcept 
        : m_data(nullptr) // 移动构造函数不抛出异常
    { 
        swap(m_data, other.m_data); 
    } 
private:
    int* m_data; // 指针成员变量
};

这个类的析构函数和移动构造函数都提供了不抛出异常保证,因为它们都使用了noexcept关键字,并且不会执行任何可能抛出异常的操作。这样一来,即使在异常发生时调用这些函数,也不会导致程序终止或者未定义行为。

强异常保证

假设我们有一个类Bar,它有一个成员变量m_vec,它是一个std::vector<int>类型的容器。Bar还有一个成员函数add,它会接受一个int类型的参数,并将其添加到m_vec中。

class Bar
{
public:
    Bar() 
        : m_vec() // 构造函数
    {} 
    void add(int x) // 添加元素到容器中
    { 
        m_vec.push_back(x); 
    } 
private:
    std::vector<int> m_vec; // 容器成员变量
};

这个类的add函数提供了强异常保证,因为它调用了std::vector::push_back函数,而这个函数也提供了强异常保证 。如果在添加元素时发生了异常(比如内存分配失败),那么m_vec容器就不会被修改,也就是说程序状态回滚到调用add之前的状态。

基本异常保证

假设我们有一个类Baz,它有两个成员变量m_a和m_b,它们都是int类型。Baz还有一个成员函数swap,它会交换两个成员变量的值。

class Baz 
{
public:
    Baz(int a, int b) // 构造函数
        : m_a(a), m_b(b) 
        {} 
    void swap() // 交换两个成员变量的值
    { 
        std::swap(m_a, m_b); 
    } 
private:
    int m_a; // 整数成员变量
    int m_b; // 整数成员变量
};

这个类的swap函数提供了基本异常保证,因为它调用了std::swap模板函数 。如果在交换两个整数时发生了异常(比如整数溢出),那么m_a和m_b可能只有其中之一被修改,也就是说程序状态仍然是有效和一致的(没有内存泄漏或者无效指针),但可能不是预期的(两个整数没有完全交换)。

4.1 异常规范

C++异常可能会导致以下安全问题:

  • 资源泄漏:如果一个函数在分配资源后抛出异常,而没有正确地释放资源,那么就会造成内存泄漏或者文件句柄泄漏等问题。为了避免这种情况,可以使用RAII的技术,比如智能指针或者标准库容器。
  • 数据破坏:如果一个函数在修改数据结构后抛出异常,而没有正确地恢复数据结构的状态,那么就会造成数据不一致或者不变量被破坏等问题。为了避免这种情况,可以使用拷贝和交换(copy and swap)的技术,比如std::swap或者std::vector::swap。
  • 异常嵌套:如果一个函数在处理异常时又抛出新的异常,而没有正确地捕获和处理新的异常,那么就会造成程序终止或者未定义行为等问题。为了避免这种情况,可以使用noexcept关键字来指定不会抛出异常的函数,比如析构函数或者移动构造函数。

RAII,Resource Acquisition Is Initialization,资源获取即初始化。一种将资源的生命周期绑定到对象的生命周期的 C++ 编程技术。它有助于避免资源泄漏并简化错误处理。这是设计智能指针核心。

为了能知道某个函数可能抛出异常的情况,标准规定:

  • 函数声明后使用throw(type1, type2...),表示这个函数可能抛出的所有类型的异常。
  • 在函数声明后使用throw()noexcept,表示这个函数不抛出任何异常。
  • 未声明异常接口,表示这个函数可能抛出任何异常。

C++有两种类型的异常规范:

  • 动态异常规范使用throw关键字来列出函数可以抛出的特定异常。
  • 静态异常规范使用noexcept关键字来指示函数是否可以抛出任何异常。

例如throw:

void f() throw(int); // f can throw an int exception
void g() throw(); // g cannot throw any exception

动态异常规范在C++11中被弃用,在C++17中被移除,除了throw(),它相当于noexcept(true)。这种异常规范的设计目的是提供关于函数可以抛出什么异常的概要信息,但实践中发现它有很多问题。例如:

  • 动态异常规范不是函数类型的一部分,所以不能用于函数指针或引用。
  • 动态异常规范只能检查直接抛出的异常,而不能检查间接抛出的异常。
  • 动态异常规范会增加运行时开销和编译时间开销。
  • 动态异常规范会限制代码的可移植性和可维护性。

因此,建议不要使用动态异常规范,而使用静态异常规范或者不使用任何异常规范。

C++98规定抛出指定的异常,但是到底是否抛出异常还是取决于程序员,因为这个规范不是强制的,所以出不出问题主要看人。C++11简化了这一规则,只问抛出不抛出(TRUE or FALSE),即noexcept表示不抛异常。

但是这还不能完全解决问题,因为它们都不是强制的。所以还要看公司内部的规则。

noexcept

在C++11新标准中,我们可以通过提供noexcept说明(noexcept specification)指定某个函数不会抛出异常。其形式是关键字noexcept紧跟在函数的参数列表后面,用以标识该函数不会抛出异常。

违反异常声明

注意,noexcept的声明,并不代表函数一定不会抛出异常。

编译器并不会在编译时检查noexcept说明。实际上,如果一个函数在说明了noexcept的同时又含有throw语句或者调用了可能抛出异常的其他函数,编译器将顺利编译通过,并不会因为这种违反异常说明的情况而报错(不排除个别编译器会对这种用法提出警告):

image-20230305103349387

因此可能出现这样一种情况:尽管函数声明了它不会抛出异常,但实际上还是抛出了。一旦一个noexcept函数抛出了异常,程序就会调用terminate以确保遵守不在运行时抛出异常的承诺。上述过程对是否执行栈展开未作约定,因此noexcept可以用在两种情况下:一是我们确认函数不会抛出异常,二是我们根本不知道该如何处理异常。

指明某个函数不会抛出异常可以令该函数的调用者不必再考虑如何处理异常。无论是函数确实不抛出异常,还是程序被终止,调用者都无须为此负责。

异常说明的实参

noexcept说明符接受一个可选的实参,该实参必须能转换为bool类型:如果实参是true,则函数不会抛出异常;如果实参是false,则函数可能抛出异常:

image-20230305103743186

5. 标准exception类体系

C++标准库中大多使用继承关系的异常机制,其中exception就是各种异常类的父类,它定义在<exception>头文件中。各个异常类和父类之间的继承关系:

2CA58BE7-F2C5-4BA0-B43D-6173D600966A

类型exception仅仅定义了拷贝构造函数、拷贝赋值运算符、一个虚析构函数和一个名为what的虚成员函数。其中what函数返回一个const char*类型的空字符,该指针,并且确保不会抛出任何异常。

类exception、bad_cast和bad_alloc定义了默认构造函数。类runtime_error和logic_error没有默认构造函数,但是有一个可以接受C风格字符串或者标准库string类型实参的构造函数,这些实参负责提供关于错误的更多信息。在这些类中,what负责返回用于初始化异常对象的信息。因为what是虚函数,所以当我们捕获基类的引用时,对what函数的调用将执行与异常对象动态类型对应的重写版本。

std::exception该异常是所有标准C++异常的父类。
std::bad_alloc该异常可以通过new抛出。
std::bad_cast该异常可以通过dynamic_cast抛出。
std::bad_exception这在处理C++程序中无法预期的异常时非常有用。
std::bad_typeid该异常可以通过typeid抛出。
std::logic_error理论上可以通过读取代码来检测到的异常。
std::domain_error当使用了一个无效的数学域时,会抛出该异常。
std::invalid_argument当使用了无效的参数时,会抛出该异常。
std::length_error当创建了太长的std::string时,会抛出该异常。
std::out_of_range该异常可以通过方法抛出,例如std::vector和std::bitset<…>::operator。
std::runtime_error理论上不可以通过读取代码来检测到的异常。
std::overflow_error当发生数学上溢时,会抛出该异常。
std::range_error当尝试存储超出范围的值时,会抛出该异常。
std::underflow_error当发生数学下溢时,会抛出该异常。

具有继承体系的异常类,能方便地从上往下管理各种异常类,所有由标准库组件抛出的对象都派生自此类。因此,所有标准异常都可以通过引用捕获此类型来捕获。

当然,根据需求也能建立自定义exception类体系。

6. 自定义exception类体系

我们使用自定义异常类的方式与使用标准异常类的方式完全一样。程序在某处抛出异常类型的对象,在另外的地方捕获并处理这些出现的问题。

可以根据小组分为不同的子类,这些子类都继承自同一个父类。

最基础的异常类至少需要包含错误编号和错误描述两个成员变量,甚至还可以包含当前函数栈帧的调用链等信息。该异常类中一般还会提供两个成员函数,分别用来获取错误编号和错误描述。例如:

class Exception
{
public:
    Exception(int errid, const char* errmsg)
            :_errid(errid)
            , _errmsg(errmsg)
    {}
    int GetErrid() const
    {
        return _errid;
    }
    virtual string what() const
    {
        return _errmsg;
    }
protected:
    int _errid;     // 错误编号
    string _errmsg; // 错误信息
    //...
};

异常类中使用protected是为了保护成员变量和函数,使得它们只能被自己或者子类访问,而不能被其他类访问。这样可以避免数据被破坏或者误用。

和标准库一样,其中what()必须是虚函数,这样子类才能按需重写自己的what()函数。当然,子类也能按需增加自己的成员。

7. 异常的优缺点

7.1 优点

  • 相比错误码的方式可以清晰准确的展示出错误的各种信息,甚至可以包含堆栈调用的信息,这样可以帮助更好的定位程序的bug。
  • 解决运行时错误。异常机制可以让我们能够捕获运行时错误,给程序一次“起死回生”的机会,或者至少告诉用户发生了什么再终止程序,增加程序的健壮性。
  • 异常处理可以在调用跳级,把错误和处理分开来,由库函数抛出异常,由调用者捕获这个异常,并去处理。

7.2 缺点

  • 异常会导致程序执行流乱跳的问题,而且非常混乱,并且是运行时出错抛异常就会乱跳,这导致我们跟踪调试时以及分析程序时,造成很大困难。
  • 异常需要保证代码异常安全,这对开发者的挑战很大。引入异常,也就引入了一个隐含的执行路径。很多时候,你根本不知道一个函数会不会抛出异常,可能抛出哪些异常。
  • C++标准库的异常定义得很不好,导致都定义自己的异常体系,非常混乱。
  • C++异常不带调用栈信息,当在外层捕获到下层自动传播来的异常时,现场已经没了。这时候反而可能是手动向上层层传播的错误码+错误日志更有利于定位到真正的问题。
  • 异常会有一些性能的开销。当然在现代硬件速度很快的情况下,这个影响基本忽略不计。但是如果bad-path的频率较高,性能开销则不可能忽略不计。

混乱的执行流:实际上,使用goto这个早已被抛弃的关键字也能实现异常处理机制,而异常机制混乱的执行流就像goto语句一样有很多不确定性。例如在一个函数调用链中某个函数捕获了异常,并跳转到对应catch语句,这虽然符合异常的处理机制,但是一旦我们想在跳转的语句块中调试,就显得异常困难。

通过对代码在某个地方打断点调试,如果这个地方恰好发生了执行跳转,断点就失效了。实际上,异常机制其他缺点都可以弥补,唯有执行流混乱难以解决。

只要程序走到断点位置,那么程序一定能停下来。跳来跳去停不下来,说明没有执行到断点位置,如果逻辑复杂,调试非常麻烦。
注:本文参考了 《C++ Primer, 5th Edition》,部分图片来源于此书

  • 0
    点赞
  • 1
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值