异常

简介: 实际中C语言基本都是使用返回错误码的方式处理错误,部分情况下使用终止程序处理非常严重的错误。C++觉得这种处理错误的方式并不好,包括其它的面向对象语言也是这样认为的,因此C++就推出了异常这种对错误的新的处理方式。不过C++也兼容C传统的处理错误的方式。

异常

一、C语言传统的处理错误的方式

传统的错误处理机制:

  1. 终止程序,如assert,缺陷:用户难以接受。如发生内存错误,除0错误时就会终止程序.
  2. 返回错误码,缺陷:需要程序员自己去查找对应的错误。如系统的很多库的接口函数都是通过把错误码放到errno中,表示错误.
实际中C语言基本都是使用返回错误码的方式处理错误,部分情况下使用终止程序处理非常严重的错误。C++觉得这种处理错误的方式并不好,包括其它的面向对象语言也是这样认为的,因此C++就推出了异常这种对错误的新的处理方式。不过C++也兼容C传统的处理错误的方式。

二、C++异常概念

异常是一种处理错误的方式,当一个函数发现自己无法处理的错误时就可以抛出异常,让函数的直接或间接的调用者处理这个错误。

  • throw抛出异常): 当问题出现时,程序会抛出一个异常。这是通过使用 throw 关键字来完成的。
  • catch标识异常): 在您想要处理问题的地方,通过异常处理程序捕获异常。catch 关键字用于捕获异常,可以有多个catch进行捕获。
  • try捕获异常): try 块中的代码标识将被激活的特定异常,它后面通常跟着一个或多个 catch 块。

如果有一个块抛出一个异常,捕获异常的方法会使用 try 和 catch 关键字。try 块中放置可能抛出异常的代码,try 块中的代码被称为保护代码。使用 try/catch 语句的语法如下所示:

try
{
    
    
    // 保护的标识代码
}
catch (ExceptionName e1)
{
    
    
    // catch 块
}
catch (ExceptionName e2)
{
    
    
    // catch 块
}
catch (ExceptionName eN)
{
    
    
    // catch 块
}

三、异常的使用

1.异常的抛出和捕获

异常的抛出和匹配原则:

  1. 异常是通过抛出对象而引发的,该对象的类型决定了应该激活哪个catch的处理代码。
  2. 被选中的处理代码是调用链中与该对象类型匹配且离抛出异常位置最近的那一个,但是同一个位置不允许有两个相同的捕获。
  3. 抛出异常对象后,会生成一个异常对象的拷贝,因为抛出的异常对象可能是一个临时对象,所以会生成一个拷贝对象,这个拷贝的临时对象会在被catch以后销毁。(这里的处理类似于函数的传值返回)
  4. catch(...)可以捕获任意类型的异常,问题是不知道异常错误是什么
  5. 实际中抛出和捕获的匹配原则有个例外,并不都是类型完全匹配,可以抛出的派生类对象,使用基类捕获,这个在实际中非常实用,我们后面会详细讲解这个。

在函数调用链中异常栈展开匹配原则:

  1. 首先检查throw本身是否在try块内部,如果是再查找匹配的catch语句。如果有匹配的,则调到catch的地方进行处理。
  2. 没有匹配的catch则退出当前函数栈,继续在调用函数的栈中进行查找匹配的catch。
  3. 如果到达main函数的栈,依旧没有匹配的,则终止程序。上述这个沿着调用链查找匹配的catch子句的过程称为栈展开。所以实际中我们最后都要加一个catch(...)捕获任意类型的异常,否则当有异常没捕获,程序就会直接终止。
  4. 找到匹配的catch子句并处理以后,会继续沿着catch子句后面继续执行。

如下有三个函数func1、func2、func3,在main函数中调用func3,在func3中调用func2,在func2中调用func1,在func1中抛出一个string类型的异常,并在main函数中用catch捕获:

void func1()
{
    
    
    throw string("这是一个异常");
}
void func2()
{
    
    
    func1();
}
void func3()
{
    
    
    func2();
}
int main()
{
    
    
    try
    {
    
    
        func3();
    }
    catch (const string& s)
    {
    
    
        cout << "错误描述:" << s << endl;
    }
    catch (...)
    {
    
    
        cout << "未知异常" << endl;
    }
    return 0;
}

在func1中的异常被抛出后,会沿着栈帧去展开,一层一层在函数栈里找到匹配的catch,如果到达main函数的栈,依旧没有匹配的,就终止程序,具体过程如下:

  1. 首先会检查throw本身是否在try内部,这里由于throw不在try内部,因此会退出func1所在的函数栈,继续在上一个调用的函数栈(func2)中进行查找
  2. 由于func2所在的函数栈也没有匹配的catch,继续退到上一个函数栈(func3)中进行查找
  3. func3所在的函数栈也没有匹配的catch,此时退回到main所在的函数栈进行查找,最终在main函数栈中找到了匹配的catch
  4. 这时就会跳到main函数中对应的catch块中执行对应的代码块,执行后继续执行该代码块后续的代码

整个过程如下图示:

image-20230420000826911

再看如下的程序中,Func函数里输入俩数值作为实参并调用Division函数进行除法运算,如果发生除0错误,就throw抛出异常,并且我在main函数中去捕获此异常:

double Division(int a, int b)
{
    
    
    // 当b == 0时抛出异常
    if (b == 0)
        throw "Division by zero condition!";
    else
        return ((double)a / (double)b);
}
void Func()
{
    
    
    int len, time;
    cin >> len >> time;
    cout << Division(len, time) << endl;
}
int main()
{
    
    
    try 
    {
    
    
        Func();
    }
    catch (const char* errmsg)
    {
    
    
        cout << errmsg << endl;
    }
    return 0;
}

image-20230424191133083

上述情况是捕获了异常,假设我抛出异常,但是不捕获呢?此时就会直接报错并终止程序:

image-20230424191419149

综上,异常必须被我们捕获,这里允许多个catch的捕获,异常是通过抛出对象而引发的,该对象的类型决定了应该激活哪个catch的处理代码。上述抛出的异常是字符串类型,假设我们这里有int类型和字符串类型的捕获,当抛出异常时,它会自动跳到类型最匹配的捕获:

image-20230424191909818

假设我有捕获,但是类型不匹配呢?即我抛出的异常是字符串类型的,捕获却是int类型的,最终只会导致类型不匹配,依旧报错,如下:

image-20230424192211684

假设我func函数和main函数均有捕获,那么抛异常时,会跳到哪个捕获呢?

double Division(int a, int b)
{
    
    
    // 当b == 0时抛出异常
    if (b == 0)
        throw "Division by zero condition!";
    else
        return ((double)a / (double)b);
}
void Func()
{
    
    
    try
    {
    
    
        int len, time;
        cin >> len >> time;
        cout << Division(len, time) << endl;
    }
    catch (const char* errmsg)
    {
    
    
        cout << errmsg << endl;
    }
}
int main()
{
    
    
    try 
    {
    
    
        Func();
    }
    catch (const char* errmsg)
    {
    
    
        cout << errmsg << endl;
    }
    return 0;
}

根据被选中的处理代码是调用链中与该对象类型匹配且离抛出异常位置最近的那一个,但是同一个位置不允许有两个相同的捕获。我们得知这里跳到里抛出异常最近的Func函数的捕获里。这里我们通过调试进行演示:

image-20230424203621972

注意:实际抛出和捕获的匹配原则有个例外,并不都是类型完全匹配,可以抛出派生类对象,之后使用基类的引用捕获,也就是形成多态调用,这个在异常使用中非常常见。


2.异常的重新抛出

有可能单个的catch不能完全处理一个异常,在进行一些校正处理以后,希望再交给更外层的调用链函数来处理,catch则可以通过重新抛出将异常传递给更上层的函数进行处理。

  • 看如下的程序(依旧是除0就抛异常):
double Division(int a, int b)
{
    
    
    // 当b == 0时抛出异常
    if (b == 0)
    {
    
    
        throw "Division by zero condition!";
    }
    return (double)a / (double)b;
}
void Func()
{
    
    
    int* p = new int[10];
    int len, time;
    cin >> len >> time;
    cout << Division(len, time) << endl;
    cout << "delete[]->" << p << endl;
    delete[] p;
}
int main()
{
    
    
    try
    {
    
    
        Func();
    }
    catch (const char* errmsg)
    {
    
    
        cout << errmsg << endl;
    }
    return 0;
}
  • 如上的程序中,我new了一块数组,我们都清楚new出来的对象往往都要手动去释放,否则会造成内存泄漏,为了检测此程序是否会在结束后手动delete释放了数组,我们使用使用打日志的方式直接cout显示出来我们delete了,如若程序显示delete[ ]说明释放了,否则没有(内存泄漏),如下展开讨论:

当我time ≠ 0时,不会发生除0错误,程序new出数组后会先正常的进行除法运算,随后delete [ ]数组,示例如下:

image-20230424204509526

如若time == 0时,会在Division函数抛异常,随后走到main函数进行捕获,此时会发现程序并没有delete [ ]数组(发生内存泄漏)

image-20230424204435520

  • 此时就发生了一个严重的错误,当除数time为0时,会在Division函数抛异常,随后走到main函数进行捕获,捕获完就结束了,一整个跳过了delete释放数组的操作,这不就发生了严重的内存泄漏错误嘛。此问题也是典型的异常安全,后续会讲。

解决办法:(异常的重新抛出)

先看修正后的代码:

double Division(int a, int b) {
    
    
    // 当b == 0时抛出异常
    if (b == 0)
        throw "Division by zero condition!";
    else {
    
    
        return ((double)a / (double)b);
    }
}

void Func() {
    
    
    // 这里如果发生除0错误会抛出异常,但下面的资源没有得到释放
    // 所以这里捕获异常后并不处理异常,而只释放资源,异常还是交给外面处理,这里捕获了再重新抛出去。
    int* p = new int[10];
    try {
    
    
        int len, time;
        cin >> len >> time;
        cout << Division(len, time) << endl;
    }
    catch (const char* errmsg) {
    
    
        cout << "delete[] ->" << p << endl;
        delete[] p;
        throw errmsg;
    }

    //如果上面没有抛异常,这里再正常释放
    delete[] p;
}

int main() 
{
    
    
    try {
    
    
        Func();
    }
    catch (const char* errmsg) {
    
    
        cout << errmsg << endl;
    }

    return 0;
}

我们直接在可能发生异常的地方(Division函数)进行捕获:

  • 当发生异常的时候,进入catch捕获,此时delete释放new出来的数组(走的是1::delete),随后再throw抛出异常,此处抛出的是前面catch捕获到的异常(除0错误),此时就算是发生了异常也不会发生内存泄漏了,运行效果如下:

image-20230424204945152

  • 当没有发生异常的时候,程序不会进入catch捕获,继而执行delete[ ]释放new出来的数组,同样也不会发生内存泄漏。

注意:

  • Func中的new和delete之间可能还会抛出其他类型的异常,因此在Func中最好以catch(...)的方式进行捕获,将申请到的内存delete后再通过throw重新抛出。
  • 重新抛出异常对象时,throw后面可以不用指明要抛出的异常对象(正好也不知道以catch(...)的方式捕获到的具体是什么异常对象)。

上述操作就是典型的异常的重新抛出,不过这样的处理方式有点挫,后续会使用智能指针的方法来解决此问题。

在上面异常重新抛出的场景中,由于 func() 函数中还可能会调用其他函数,而其他函数也可能会抛出异常,并且它们抛出的对象的类型可能与 division 并不相同,那么此时如果我们要实现捕获异常释放资源重新抛出就需要写多个不同参数类型的 catch 块,这显然很麻烦,所以 C++ 还支持捕获与抛出任意类型的异常:

try {
    
    
    //...
}
catch(...) {
    
      //三个点表示捕获任意类型异常
    //throw 表示抛出任意类型的异常--捕获到什么就抛出什么
    throw;
}

同时,之前我们提到,如果到达 main 函数的栈帧后依旧没有匹配的 catch 块或依然没有对异常进行捕获,那么程序会直接终止,这显然是不好的;所以实际中通常我们都会在最后加一个 catch(…) 来捕获任意类型的异常,以此来处理未知异常,放在程序被直接终止;如下:

double Division(int a, int b) {
    
    
    // 当b == 0时抛出异常
    if (b == 0)
        throw "Division by zero condition!";
    else {
    
    
        return ((double)a / (double)b);
    }
}

void Func() {
    
    
    // 这里如果发生除0错误会抛出异常,但下面的资源没有得到释放
    // 所以这里捕获异常后并不处理异常,而只释放资源,异常还是交给外面处理,这里捕获了再重新抛出去。
    int* p = new int[10];
    try {
    
    
        int len, time;
        cin >> len >> time;
        cout << Division(len, time) << endl;
    }
    catch (...) {
    
      //捕获任意类型异常
        cout << "delete[] ->" << p << endl;
        delete[] p;
        throw;  //捕获到什么,就抛出什么
    }

    //如果上面没有抛异常,这里再正常释放
    delete[] p;
}

int main() {
    
    
    try {
    
    
        Func();
    }
    catch (const char* errmsg) {
    
    
        cout << errmsg << endl;
    }
    catch (int errid) {
    
    
        cout << errid << endl;
    }
    catch (...) {
    
    
        cout << "未知异常" << endl;
    }

    return 0;
}

image-20230424211104192


3.异常安全

抛异常导致的安全问题称为异常安全,有以下几点要注意:

  • 构造函数完成对象的构造和初始化,最好不要在构造函数中抛出异常,否则可能导致对象不完整或没有完全初始化
  • 析构函数主要完成资源的清理,最好不要在析构函数内抛出异常,否则可能导致资源泄漏(内存泄漏、句柄未关闭等)
  • C++中异常经常会导致资源泄漏的问题,比如在new和delete中抛出了异常,导致内存泄漏,在lock和unlock之间抛出了异常导致死锁,C++经常使用RAII来解决以上问题,关于RAII我们到下一篇博文智能指针再来讲解。

4.异常规范

异常规格说明的目的是为了让函数使用者知道该函数可能抛出的异常有哪些,C++标准规定如下:

  1. 可以在函数的后面接throw(type1, type2, ...),列出这个函数可能抛掷的所有异常类型。
  2. 函数的后面接throw()或noexcept(C++11),表示函数不抛异常。
  3. 若无异常接口声明,则此函数可以抛掷任何类型的异常。

示例:

// 这里表示这个函数会抛出A/B/C/D中的某种类型的异常
void fun() throw(A,B,C,D);
// 这里表示这个函数只会抛出bad_alloc的异常
void* operator new (std::size_t size) throw (std::bad_alloc);
// 这里表示这个函数不会抛出异常
void* operator delete (std::size_t size, void* ptr) throw();

// C++11 中新增的noexcept,表示不会抛异常
thread() noexcept;
thread(thread&& x) noexcept;

补充说明:

  1. 因为C++98函数异常接口知识建议做法,语法并未强制要求,尽管C++98期望每个人写一个函数,就声明清楚是否抛异常,抛什么异常,可能会抛出所有的异常,这种方式比较麻烦,所以C++98的异常规范在实际开发中几乎没人遵守。
  2. 于是C++11推出后,C++98的异常规范继续支持,只是简化了这块的规则,如果你不抛异常,就给一个noexcept说明一下即可。
  • 1、函数后面不加关键字 noexcept,表示该函数可能会抛出任意类型异常。
  • 2、函数后面加关键字 noexcept,表示该函数不会抛出异常。

并且 C++11 还对使用 noexcept 修饰的函数进行了检查,如果该函数被 noexcept 修饰,但是可能会抛出异常,则编译器会报一个警告,但并不影响程序的正确性:

image-20230424212515196


四、C++标准库的异常体系

对于C++而言,其库里也搞了一套异常体系,其定义了一个基类(exception),源码如下:

class exception {
    
    
public:
exception () throw();
exception (const exception&) throw();
exception& operator= (const exception&) throw();
virtual ~exception() throw();
virtual const char* what() const throw();
//what的作用是返回抛出异常的原因的相关信息
}

解释说明:

  • exception类中的what成员函数和析构函数都定义成了虚函数,方便了子类对其进行重写,从而达到多态的效果
  • 我们可以捕获父类exception抛子类(利用多态的性质),也可以直接抛派生类

C++提供的一些标准异常就继承了此exception基类中,我们可以在程序中使用这些异常,它们是以父子类层次结构组织起来的,如图所示:

image-20230420002539326

下表是对上面继承体系中出现的每个异常的说明:

异常 描述
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 当发生数学下溢时,会抛出该异常。

虽然我们可以直接使用 C++ 标准提供的这些异常,也可以去继承 exception 类来实现自己的异常类,但在实际开发中很多企业都会像上面一样自己定义一套单独的异常继承体系,因为C++标准库设计的不够好用。再加上我们平时自己写代码基本不会使用异常,所以对于 C++ 标准异常我们作为了解内容即可。

使用 C++ 标准库中的异常类来捕获异常:

int main() {
    
    
    try {
    
    
        vector<int> v(10, 5);
        // 这里如果系统内存不够也会抛异常
        v.reserve(1000000000);

        // 这里越界会抛异常
        v.at(10) = 100;
    }
    catch (const exception& e) // 这里捕获父类对象就可以--多态
    {
    
    
        cout << e.what() << endl;
    }
    catch (...) {
    
    
        cout << "Unkown Exception" << endl;
    }
    return 0;
}

image-20230424213729960

  • 正常情况下,C++的程序直接去捕获基类(exception)就可以了,但实际上很多人非常“嫌弃”C++的exception,因为库里的那一套并不能满足不同公司间的需求,所以实际中很多公司像上面一样自己定义一套异常继承体系。因为C++标准库设计的不够好用。看下文的示例:

五、自定义异常体系

  • 实际使用中很多公司都会自定义自己的异常体系进行规范的异常管理,因为一个项目中如果大家随意抛异常,那么外层的调用者基本就没办法玩了。
  • 所以实际中都会定义一套继承的规范体系,先定义一个最基础的异常类,所有人抛出的异常对象都必须是继承于该异常类的派生类对象,而异常语法规定可以用基类捕获抛出的派生类对象。这样大家抛出的都是继承的派生类对象,捕获一个基类就可以了。

image-20230420001031334

看如下我模拟实现的服务器开发中通常使用的异常继承体系:定义了一个Exception基类,并实现了数据库组的类(SqlException)、缓存组的类(CacheException)、协议组的类(HttpServerException)均继承了Exception基类,下面分别介绍其内部作用:

  • Exception基类:内部成员变量有_ errmsg用来描述错误信息,_id用来描述错误编码,并定义what虚函数来返回错误信息,getid成员函数返回错误编码。
  • 数据库组的类(SqlException):增加了sql成员变量,并对what函数进行了重写(在返回错误信的同时又补充了是哪一条sql语句出错的)。
  • 缓存组的类(CacheException):重写了what函数,除了返回错误信息并标记此错误是缓存组的错误。
  • 协议组的类(HttpServerException):重写了what函数,返回错误信息并标记此错误是协议组的错误,此外还返回了是哪个类型的错误。
// 服务器开发中通常使用的异常继承体系
    //基类
class Exception
{
    
    
public:
    Exception(const string& errmsg, int id)
        :_errmsg(errmsg)
        , _id(id)
    {
    
    }
    virtual string what() const
    {
    
    
        return _errmsg;
    }
    int getid() const
    {
    
    
        return _id;
    }
protected:
    string _errmsg;//描述错误信息
    int _id;//错误编码
};
//数据库组
class SqlException : public Exception
{
    
    
public:
    SqlException(const string& errmsg, int id, const string& sql)
        :Exception(errmsg, id)
        , _sql(sql)
    {
    
    }
    virtual string what() const
    {
    
    
        string str = "SqlException:";
        str += _errmsg;
        str += "->";
        str += _sql;
        return str;
    }
private:
    const string _sql;
};
//缓存组
class CacheException : public Exception
{
    
    
public:
    CacheException(const string& errmsg, int id)
        :Exception(errmsg, id)
    {
    
    }
    virtual string what() const
    {
    
    
        string str = "CacheException:";
        str += _errmsg;
        return str;
    }
};
//协议组
class HttpServerException : public Exception
{
    
    
public:
    HttpServerException(const string& errmsg, int id, const string& type)
        :Exception(errmsg, id)
        , _type(type)
    {
    
    }
    virtual string what() const
    {
    
    
        string str = "HttpServerException:";
        str += _type;
        str += ":";
        str += _errmsg;
        return str;
    }
private:
    const string _type;
};

实现了如上的基本框架,接下来就要模拟实现抛异常的过程了,这里我们仅仅是用rand概率控制模拟抛出的不同的异常。下面展开来讨论数据库组、缓存组、协议族分别抛异常的过程:

  • 数据库组(SQLMgr):控制%10的概率抛权限不足的异常,其它为成功。
  • 缓存组(CacheMgr):控制%10的概率抛权限不足的异常,%25的概率抛数据不存在的异常,其它为成功,成功就调用数据库组的函数。
  • 协议族(HttpServer):控制%10的概率抛请求资源不存在的异常、%10的概率抛权限不足的异常、其它为成功,成功就调用缓存组函数。
//抛数据库组的异常
void SQLMgr()
{
    
    
    if (rand() < RAND_MAX / 10)//10%的概率抛权限不足的异常
    {
    
    
        throw SqlException("权限不足", 100, "select * from name = '张三'");
    }
    else
    {
    
    
        cout << "Sql success" << endl;
    }
}
//抛缓存组的异常
void CacheMgr()
{
    
    
    if (rand() < RAND_MAX / 10)//%10的概率抛权限不足的异常
    {
    
    
        throw CacheException("权限不足", 100);
    }
    else if (rand() < RAND_MAX / 25)//%25的概率抛数据不存在的异常
    {
    
    
        throw CacheException("数据不存在", 101);
    }
    else
    {
    
    
        cout << "Cache success" << endl;
    }
    SQLMgr();
}
//抛协议组的异常
void HttpServer()
{
    
    
    if (rand() < RAND_MAX / 10)//%10的概率抛请求资源不存在的异常
    {
    
    
        throw HttpServerException("请求资源不存在", 404, "get");
    }
    else if (rand() < RAND_MAX / 10)//%10的概率抛权限不足的异常
    {
    
    
        throw HttpServerException("权限不足", 501, "post");
    }
    else
    {
    
    
        cout << "Http success" << endl;
    }
    CacheMgr();
}

实现好了上述三个模拟抛异常的函数,接下来进入main函数开始具体的测试,如下就会把多态的作用体现的淋漓尽致:

  • 定义死循环,确保程序一直运行
  • try(调用协议组(HttpServer)的抛异常的函数),catch(捕获父类对象,传的是父类的引用),catch内部调用e.what()输出不同派生类对象抛出的异常所对应的错误信息,
  • 上面就体现了捕获父类抛子类的过程,因为我catch的是父类的引用,根据多态的发生条件:1、父类的指针或引用,2、调用的必须是重写的虚函数。指向谁调用谁。那么此时我抛的是协议组的异常,就调协议组的,抛的是缓存组的异常,就调缓存组的……
int main()
{
    
    
    srand(time(0));
    while (1)
    {
    
    
        Sleep(1000);
        try 
        {
    
    
            HttpServer();
        }
        catch (const Exception& e) // 这里捕获父类对象就可以
        {
    
    
            // 多态
            cout << e.what() << endl;
        }
        catch (const std::exception& e) // 这里捕获库里的基类对象,捕获类似出现new失败等库里抛的异常
        {
    
    
            // 多态
            cout << e.what() << endl;
        }
        catch (...)
        {
    
    
            cout << "Unkown Exception" << endl;
        }
    }
    return 0;
}

image-20230424214503615

上述整体代码链接:模拟实现服务器开发中通常使用的异常继承体系)

  • 上述代码的实现就不会因为异常崩掉了,要么抛的是公司实现的Exception子类的异常,要么抛的是C++库里的异常,最差的情况就是抛了一个未知异常,不过被catch(...)捕获了,同样程序不会崩掉。

  • 存在一个基类 Exception,该类中有两个成员变量,分别用来保存错误编号和错误的描述信息,还有一个 what 虚函数;

  • 其他子类 SqlException、CacheException、HttpServerException 都继承自父类 Exception,并且子类会根据自己的需要增加成员变量,比如 SqlException 中增加了一个 _sql,用来保存失败的 SQL 查询语句;并且子类都重写了父类的 what 方法,通过 what 方法,返回自己的错误编号、错误描述信息以及该类特有的一些信息,比如属于哪一类异常,比如 SQL 查询语句和网络请求类型;
  • 存在三个函数 SQLMgr、CacheMgr 和 HttpServer,分别对应 SQL 查询、缓存访问和网络请求,这些函数都可能会抛出异常,例如权限不足、数据不存在等。在主函数中使用了 try-catch 语句来捕获这些异常,如果捕获到了异常,则调用 e.what() 方法输出具体的异常信息。函数的调用逻辑是 main -> HttpServe -> CacheMgr -> SQLMgr。

这里有两个地方需要注意:

  1. 为什么在 main 函数中调用父类对象的 what 方法就可以捕获其他三个子类的异常对象,并且输出的还是对应子类的异常信息?这是因为父类中 what 是虚函数,而所有的子类都对 what 进行了重写;同时,main 函数中的 catch 的形参是父类类型的引用;当捕获到子类的对象时这里就会触发多态,去调用子类对象中的 what 方法。
  2. 为什么要用一个变量来表示错误编号?这是为了方便对不同异常进行分类,从而对某些异常进行特殊处理;比如,当我们坐火车发送消息时,由于火车信号不好,经常会网卡,所以就很可能导致本次 http 请求失败抛出异常;但是对于这种异常我们需要间隔一定时间再次发起 http 网络请求,因为此刻信号说不定又能够支持我们发送消息了。这就是为什么当网络不好时使用qq/微信发送消息会有一个圆圈一直在转。

通过像上面这样来设计异常处理程序,我们可以在程序出错时可以快速定位问题,特别是在复杂的系统中,异常往往是难以避免的。通过准确地捕获异常,我们可以及时发现错误并进行修复,提高程序的稳定性和可靠性。同时,将不同类型的异常分别封装为不同的子类,也可以更加清晰地表达异常的类型和具体信息,为后续的维护和优化带来方便

现在升级我们的需求:假设我现在有个SeedMsg发送消息的函数,如若里面出现网络错误的时候,我们现在要求重试发消息10次。

void SeedMsg(const string& str)
{
    
    
    if (rand() < RAND_MAX - 10000)//大概率抛网络错误的异常
    {
    
    
        throw HttpServerException("SeedMsg::网络错误", 2, "put");
    }
    else if (rand() < RAND_MAX / 10)//%10的概率抛权限不足的异常
    {
    
    
        throw HttpServerException("SeedMsg::权限不足,你已经不是对方好友", 1, "post");
    }
    else
    {
    
    
        cout << "消息发送成功!->" << str << endl;
    }
}

这里我们只需要在抛出异常时跳回的位置进行调整即可,先直接给出正确代码:

void test_Msg()
{
    
    
    srand(time(0));
    while (1)
    {
    
    
        Sleep(1000);
        try
        {
    
    
            //HttpServer();
            //发送消息出现网络错误,要求重试10次
            //权限错误就直接报错,
            for (size_t i = 0; i < 10; i++)
            {
    
    
                try
                {
    
    
                    cpp::SeedMsg("地球不爆炸,我们不放假;宇宙不重启,我们不休息!!!");
                    break;//如果发送成功了,直接break退出,防止发送成功还进入循环
                }
                catch (const Exception& e)
                {
    
    
                    if (e.getid() == 2)//异常编码的价值,可以针对某个错误进行特殊处理
                    {
    
    
                        cout << "网络错误,重试发消息第" << i << "次" << endl;
                        continue;
                    }
                    else//其它错误
                    {
    
    
                        //break;不能break,否则当出现权限错误时,根本不会走到这一步
                        throw e;//重新抛出异常
                    }
                }
            }

        }
        //……
    }
    return 0;
}

解释上述代码逻辑:

  • 这里重试10次的前提是只有网络错误才重试,否则就退出循环
  • 当我发送消息成功时,直接break,防止发送成功还进入循环
  • 当发送失败捕获的getid错误编码为2时,说明是网络错误,此时continue执行重发消息的代码逻辑
  • 当发送失败捕获的getid错误编码不是2时,说明不是网络错误,直接throw e,重新抛出异常,不能直接break,否则当出现权限错误时,根本不会走到这一步。

整体代码链接:

运行如下:

image-20230424225634382


六、异常的优缺点

C++异常的优点:

  • 1、异常对象定义好了,相比错误码的方式可以清晰准确的展示出错误的各种信息,甚至可以包含堆栈调用的信息,这样可以帮助更好的定位程序的bug。
  • 2、返回错误码的传统方式有个很大的问题就是,在函数调用链中,深层的函数返回了错误,那么我们得层层返回错误,最外层才能拿到错误,而异常可以通过抛出捕获直接拿到错误。具体看图示:

image-20230424222905459

  • 3、很多的第三方库都包含异常,比如boost、gtest、gmock等等常用的库,那么我们使用它们也需要使用异常。
  • 4、部分函数使用异常更好处理,比如构造函数没有返回值,不方便使用错误码方式处理。比如T& operator[] (size_t pos) 这样的函数,如果pos越界了只能使用异常或者终止程序处理,没办法通过返回值表示错误。

C++异常的缺点:

  1. 异常会导致程序的执行流乱跳,并且非常的混乱,并且是运行时出错抛异常就会乱跳。这会导致我们跟踪调试时以及分析程序时,比较困难。
  2. 异常会有一些性能的开销。当然在现代硬件速度很快的情况下,这个影响基本忽略不计。
  3. C++没有垃圾回收机制,资源需要自己管理。有了异常非常容易导致内存泄漏、死锁等异常安全问题。这个需要使用RAII来处理资源的管理问题。学习成本较高。
  4. C++标准库的异常体系定义得不好,导致大家各自定义各自的异常体系,非常的混乱。
  5. 异常尽量规范使用,否则后果不堪设想,随意抛异常,外层捕获的用户苦不堪言。所以异常规范有两点:
  • 1、抛出异常类型都继承自一个基类。
  • 2、函数是否抛异常、抛什么异常,都使用 func() throw();的方式规范化。
总结:异常总体而言,利大于弊,所以工程中我们还是鼓励使用异常的。另外面向对象的语言基本都是用异常处理错误,这也可以看出这是大势所趋。
相关文章
|
1月前
|
安全 Java 编译器
异常的讲解
异常的讲解
20 1
|
7月前
|
安全
异常
异常
53 0
|
3月前
|
SQL 安全 Java
C++之异常
C++之异常
9 0
|
3月前
|
C++
C++中的异常
C++中的异常
|
4月前
|
程序员 编译器 Shell
C++『异常』
C++『异常』
38 0
|
9月前
|
安全 Java 程序员
c++异常
c++异常
62 0
|
6月前
|
安全 Java 程序员
|
10月前
|
安全 Java C语言
【C++】异常,你了解了吗?(二)
在之前的C语言处理错误时,会通过assert和错误码的方式来解决,这导致了发生错误就会直接把程序关闭,或者当调用链较长时,就会一层一层的去确定错误码,降低效率,所以c++针对处理错误,出现了异常,一起来学习!
53 0
|
10月前
|
算法
缺页中断与缺页异常
缺页中断与缺页异常
|
11月前
|
Java 程序员 编译器
15 异常
15 异常
51 0