C++异常的万字深度解析

文章标题:

C++异常的全面深度剖析

文章内容:

万字解读C++异常

  • C++异常
  • github地址
  • 0. 前言
  • 1. 传统 C 风格的错误处理:为何需要异常?
  • 2. C++异常的概念
  • 3. 异常的使用
    • 3.1 异常的抛出和捕获
    • 匹配原则
    • 异常调用链上的“栈展开”
    • 3.2 异常的重新抛出
    • 3.3 异常安全与 RAII 思想
    • 3.4 异常规范说明
  • 4. 自定义异常体系——统一管理
    • 1. 为什么要自定义统一体系
    • 2. 常用自定义体系
    • 异常类继承层次
    • 处理函数
    • 捕获模块
  • 5. C++标准库中的异常体系
  • 6. 异常的优缺点
  • 7. 结语

C++异常

github地址

怀揣梦想的电信人

0. 前言

在C++的学习过程中,异常是一个既重要又容易被忽视的内容。不少初学者更习惯采用错误码或者断言来处理问题,却很少系统地去理解和运用异常机制。实际上,异常的设计目的,就是为了解决C语言时代“错误处理困难、代码可读性差”的难题。

在现代软件开发里,系统往往更为复杂,函数调用链很长,模块之间紧密协作。要是依赖传统的错误返回值,就需要“层层上传”,一旦遗漏检查就可能引发严重的bug;而直接使用assert终止程序,也会让用户难以接受。C++提供的异常机制正好为我们提供了一个优雅的解决办法:

  • 错误信息能够在调用链上自动传递;
  • 外层能够通过catch块集中处理错误;
  • 结合RAII思想,还能有效避免资源泄漏问题。

本文将带你全面学习C++异常:从传统C风格错误处理的缺陷说起,再到异常的语法规则、抛出与捕获的过程、异常安全与RAII、统一异常体系的设计,以及标准库提供的异常层次。通过循序渐进的讲解和丰富的代码示例,你将能够从根本上理解C++异常机制的工作原理,并在工程实践中做到“用得明白,用得规范”。


1. 传统 C 风格的错误处理:为何需要异常?

C语言中常见的错误处理方式有两种:

  1. 终止程序(如assert为false时直接终止程序):缺陷是无法明确知道具体是什么错误,用户也难以接收错误信息。比如内存错误(数组越界、未初始化/空指针/无效地址的访问、野指针、内存泄露、同一块空间释放多次等)、除0错误,都会直接终止程序。
  2. 返回错误码(需配合errno),缺陷是需要调用者层层检查和传递对应的错误,既繁琐又容易出错。例如系统的很多库的接口函数都是通过把错误码放到errno中表示错误。实际工程中,多数会选择返回错误码的方式处理错误,极少数“致命错误”会直接终止。

小结:当函数调用链很深时,错误码方案要求“层层返回”,既污染业务逻辑,又容易遗漏。而异常机制正是为解决此类痛点而生,异常不会终止程序,并且能详细介绍错误信息。

先感受一下异常:

double Division(int a, int b) {
    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() {
    //Func();
    try {
        Func();
    }
    catch (const char* errmsg) {
        cout << "异常已捕获: " << errmsg << endl;
    }
    catch (...) {
        cout << "unkown exception" << endl;
    }
    return 0;
}

使用异常捕获除0错误,会显示出异常的信息;不使用异常,传统C语言会直接终止程序。

2. C++异常的概念

异常是面向对象语言处理错误的一种方式,当一个函数发现自己无法处理的错误时,可以抛出异常,让函数的直接或间接调用者处理这个错误。异常的抛出和捕获由以下三个关键字配合完成:

  • throw:当问题出现时,程序会抛出一个异常(本质是抛出一个对象)。抛异常使用throw关键字完成。
  • try:try块中包含可能出现异常的代码或者函数,try块中的代码被称为保护代码,放置可能抛出异常的代码。
  • catch(异常的类型):跟在try块之后,用于捕捉异常。catch关键字用于捕获异常,可以设置多个catch捕获throw抛出的不同类型的异常(对象)。catch(...)可以捕获任意类型的异常,用来捕获没有显示捕获类型的异常,相当于条件判断最后的else。只有try{}块中抛出了异常,才会执行catch中的代码。

在想要处理问题的地方,通过异常处理程序捕获异常。

使用方法如下:

注意:不论try和catch块中有多少行代码,都必须加上{}

try {
    // 保护代码,可能出现错误,出现错误后抛异常
}
// catch 的括号中填异常的类型
catch (ExceptionName e1) {
    // 分支1
} catch (ExceptionName e2) {
    // 分支2
} catch (...) {
    // 兜底分支:捕获任意类型
}

关键点:

  • throw抛出的是对象,对象的类型决定匹配到哪个catch;
  • 可以有多分支;
  • catch(...)可兜底但无法区分具体错误。

3. 异常的使用

3.1 异常的抛出和捕获

匹配原则

异常的抛出和匹配原则:

  1. 异常是通过抛出对象而引发的,该对象的静态类型决定了应该激活哪个catch的处理代码。
  2. 异常抛出后,匹配的catch处理代码是调用链中与该异常对象类型匹配且离抛出异常位置最近的那一个。
  3. 抛出异常对象后,会生成一个异常对象的拷贝。因为抛出的异常对象可能是一个临时对象(匿名对象),所以会生成一个拷贝对象抛出。catch结束后该拷贝对象销毁(类比函数的按值返回)。
  4. catch(...)可以捕获任意类型的异常,但无法得知异常的具体信息。抛出了异常但没有被捕获时,程序会被终止。因此需要catch(...)兜底。
  5. 实际中抛出和捕获的类型并不是完全匹配,可以抛出派生类对象,使用基类捕获,便于统一处理与多态扩展(工程中非常实用)。可用基类捕获派生类原因是:派生类可以赋值给基类,基类的指针或引用可以指向派生类。

异常调用链上的“栈展开”

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

  • 首先检查抛出点throw本身是否在某个try块内,如果是,尝试就近查找匹配catch。如果有匹配的,则跳转到catch的地方进行处理。
  • 没有匹配的catch,退出当前函数栈帧,到调用者的函数栈帧中查找匹配的catch语句。
  • 一直查找至main函数的栈帧,如果依旧没有catch块匹配,则终止程序;找到catch匹配并处理后,从当前栈帧的catch子句之后继续往下执行。
  • 上述这个沿着调用链查找匹配的catch子句的过程称为栈展开。
  • 实践建议:顶层栈帧(通常是main函数的栈帧)加上一个catch(...)兜底,用于捕获任意类型的异常,避免异常漏出导致进程崩溃(程序直接终止)。

结论:按照函数调用链,一层一层往外找,直到找到匹配的catch块,直接跳到匹配的catch块执行,执行完catch,会继续往catch块后面的语句执行。相当于没有找到匹配的函数栈帧被释放了。

3.2 异常的重新抛出

有时catch到一个异常对象后,需要对错误进行分类,其中的某种异常错误需要进行特殊的处理,其他错误则重新抛出异常给外层调用链处理。捕获异常后需要重新抛出时,直接throw,就可以直接抛出。

场景一:

下面程序模拟展示了聊天时发送消息,发送失败捕获异常,但是可能在电梯地下室等场景手机信号不好,则需要多次尝试。如果多次尝试都发送不出去,则就需要捕获异常再重新抛出,其次如果不是网络差导致的错误,捕获后也要重新抛出。

void _SeedMsg(const string& s) {
    if (rand() % 2 == 0)
        throw HttpException("网络不稳定,发送失败", 102, "put");
    else if (rand() % 7 == 0)
        throw HttpException("你已经不是对象的好友,发送失败", 103, "put");
    else
        cout << "发送成功" << endl;
}

void SendMsg(const string& s) {
    // 发送消息失败,则再重试3次
    for (size_t i = 0; i < 4; i++) {
        try {
            _SeedMsg(s);
            break;
        }
        catch (const Exception& e) {
            // 捕获异常,if中是102号错误,网络不稳定,则重新发送
            // 捕获异常,else中不是102号错误,则将异常重新抛出
            if (e.getid() == 102)
            {
                // 重试三次以后否失败了,则说明网络太差了,重新抛出异常
                if (i == 3)
                    throw;
                // 重试的逻辑
                cout << "开始第" << i + 1 << "重试" << endl;
            }
            else
                throw;//捕获到什么就抛什么
        }
    }
}

int main() {
    srand(time(0));
    string str;
    while (cin >> str) {
        try {
            SendMsg(str);
        }
        catch (const Exception& e) {
            cout << e.what() << endl << endl;
        }
        catch (...) {
            cout << "Unkown Exception" << endl;
        }
    }
    return 0;
}

场景二:

在下面的代码场景里面,当b==0时,抛出异常,被main函数里面的catch捕获,代码直接跳到main函数里面执行,但是Func函数里面的ptr1没有被释放,怎么办?

double Divide(int a, int b) {
    try{
        // 当b == 0时抛出异常
        if (b == 0) {
            string s("Divide by zero condition!");
            throw s;
        }
        else
            return ((double)a / (double)b);
    }
    catch (int errid)
        cout << errid << endl;
    return 0;
}

void Func() {
    int* ptr1 = new int[10];
    int len, time;
    cin >> len >> time;
    // 可能抛异常
    cout << Divide(len, time) << endl;

    delete[] ptr1;
    cout << "delete:" << ptr1 << endl;
}

int main() {
    while (1){
        try{
            Func();
        }
        catch (const string& errmsg) {
            cout << errmsg << endl;
        }   
        catch (...) {
            cout << "未知异常" << endl;
        }
    }
    return 0;
}

在Func函数中添加一个try{}...catch{}语句,并对异常进行重新抛出来解决

void Func() {
    int* ptr1 = new int[10];
    int len, time;
    cin >> len >> time;
    try  {
        cout << Divide(len, time) << endl;
    }
    catch (...) {
        delete[] ptr1;
        cout << "delete:" << ptr1 << endl;
        // 重新抛出,捕获到什么抛什么。这里的try,catch的目的就是释放ptr1,再重新抛出,对捕获到的异常该处理处理
        // 所以可见,重新抛出,是为了进行内存的释放或者场景一中业务的处理等
        throw;
    }
    delete[] ptr1;
    cout << "delete:" << ptr1 << endl;
}

更新后的Func函数的执行流:

  • 无异常时:catch块中的代码不执行,下面的代码delete[] ptr1正确释放了ptr1指针
  • 出现异常时:执行catch块中的代码,释放ptr1,单条throw语句将异常重新抛出,让外层栈帧对该异常进行处理

总结:单个catch不能完全处理时,可在完成必要清理后“重新抛出”,将异常交给更外层处理。在C++中用于“原样抛出”的语法是throw;。

3.3 异常安全与 RAII 思想

  • 构造函数:构造函数完成对象的构造和初始化,尽量不要抛异常,避免出现异常后执行流跳转,使对象处于不完整状态。
  • 析构函数:尽量不要抛异常,执行流跳转后资源未被清理,可能导致资源泄漏甚至terminate。
  • 典型风险:new/delete之间抛异常,delete未被执行导致内存泄漏;lock/unlock之间抛异常,锁没有被释放导致死锁。
  • 解决思路:RAII(资源获取即初始化)——把资源放入对象,靠对象生命周期自动管理。

演示出现异常时先清理再throw的范式:

void Func() {
    int* array = new int[10];
    try {
        int len, time;
        std::cin >> len >> time;
        std::cout << Division(len, time) << std::endl;
    }
    // 抛异常后,捕获异常进行处理,再将异常重新抛出
    // 抛异常后,资源也能被正确释放
    catch (...) {
        std::cout << "出现异常时:  delete []" << array << std::endl;
        delete[] array;       // 清理资源
        throw;                // 重新抛出,等待后面的代码或上层函数栈帧处理
    }
    // 未抛异常时,资源被正确释放
    std::cout << "正常情况: delete []" << array << std::endl;
    delete[] array;           // 正常路径清理
}

“异常经常导致资源泄漏,RAII是通用解法(如智能指针管理内存,互斥量用守卫对象管理加解锁)”。


3.4 异常规范说明

  • 为了让函数使用者明确该函数“可能抛什么”异常,可在函数尾部给出异常规格:

    • 函数的后面加throw(),表示函数不抛异常。 void fun() throw(A, B, C, D);函数仅可能抛出列出的异常类型;void f() throw();表示不抛异常;若无异常接口声明,表示此函数可能抛出任意类型的异常;
    • C++98中的std::exception类的成员函数,后面跟throw()的函数,表示该函数不会抛出异常
  • 标准库中曾使用过:operator new可能抛std::bad_alloc;operator delete声明不抛异常;

  • C++11引入noexcept表示不抛异常,如thread() noexcept;,thread(thread&&) noexcept;。

// 这里表示这个函数会抛出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 new (std::size_t size, void* ptr) throw();
void* operator new (std::size_t size, void* ptr) noexcept;  // C++11

现代C++已弃用旧式throw(TypeList)规格而统一到noexcept,但理解其设计意图有助于阅读旧代码。


4. 自定义异常体系——统一管理

1. 为什么要自定义统一体系

为什么要自定义?如果多个不同模块的团队“随心所欲”地抛各种类型(int、const char*、自定义结构体…),外层就很难一网打尽。最佳实践是统一继承自共同基类,外层只需捕获基类对象,通过派生类虚函数的重写,实现异常的多态处理。

2. 常用自定义体系

服务端常用的异常处理层次如下:

异常类继承层次

```cpp
class Exception {
public:
Exception(const std::string& errmsg, int id)
: _errmsg(errmsg), _id(id)
{}
virtual std::string what() const {
return _errmsg;
}
protected:
std::string _errmsg;
int _id;
};

class SqlException : public Exception {
public:
SqlException(const std::string& errmsg, int id, const std::string& sql)
: Exception(errmsg, id), _sql(sql) {}
std::string what() const override {
return "SqlException:" + _errmsg + "->" + _sql;
}
private:
const std::string _sql;
};

class CacheException : public Exception {
public:
using Exception::Exception;
std::string what() const override {
return "CacheException:" + _

版权声明:程序员胖胖胖虎阿 发表于 2025年9月18日 上午1:10。
转载请注明:C++异常的万字深度解析 | 胖虎的工具箱-编程导航

相关文章

暂无评论

暂无评论...