23、异常处理:try-catch-throw、标准异常类、自定义异常

写代码这么多年,我越来越觉得异常处理就像给程序买保险。平时觉得没用,真出事了才知道它的好。你想想看,一个程序跑着跑着突然崩溃了,用户看到的是个黑框或者闪退,多尴尬。异常处理就是让程序在出错时还能优雅地收场。

为什么需要异常处理?

传统的错误处理方式,无非是函数返回错误码,或者设置全局错误标志。我在早期项目里也这么干过,结果呢?调用方经常忘记检查返回值,错误就这样悄悄溜过去了。更麻烦的是,错误码层层传递,代码里全是 if 判断,可读性差得要命。

异常处理就不一样了。它能把错误检测和错误处理分离开来。出错的代码只管抛出异常,处理异常的代码集中在一个地方搞定。说白了,就是让正常逻辑和错误逻辑各走各的路。

核心思想:异常处理让程序在遇到错误时,能够跳转到预先准备好的处理代码,而不是直接崩溃或返回错误码。

try-catch-throw 基本用法

这三个关键字是异常处理的铁三角。我习惯这么理解:try 是监控区,throw 是报警器,catch 是消防队。

#include <iostream>
#include <stdexcept>

double divide(double a, double b) {
    if (b == 0.0) {
        throw std::runtime_error("除数不能为零!");
    }
    return a / b;
}

int main() {
    try {
        double result = divide(10.0, 0.0);
        std::cout << "结果: " << result << std::endl;
    } catch (const std::exception& e) {
        std::cerr << "捕获到异常: " << e.what() << std::endl;
    }
    return 0;
}

你看,divide 函数发现除数为零时,直接抛出异常。调用方用 try-catch 包起来,异常就被捕获了。程序不会崩溃,而是打印出友好的错误信息。

我的习惯:catch 时尽量用引用捕获(const std::exception&),避免对象切片,也省去一次拷贝。

异常传播与栈展开

异常抛出后,会发生栈展开(stack unwinding)。什么意思呢?就是程序沿着调用链往回走,沿途的局部对象会被自动析构,直到找到匹配的 catch 块。我曾经在调试一个内存泄漏问题时,发现就是栈展开时某个指针没被释放。嗯,这里要注意:栈展开只保证局部对象的析构函数被调用,不保证堆内存被释放。

class Resource {
public:
    Resource() { std::cout << "资源获取\n"; }
    ~Resource() { std::cout << "资源释放\n"; }
};

void func() {
    Resource res;  // 栈展开时会自动析构
    throw std::runtime_error("出错了");
}

int main() {
    try {
        func();
    } catch (...) {
        std::cout << "捕获所有异常\n";
    }
    return 0;
}

输出结果:

资源获取
资源释放
捕获所有异常

看到没?Resource 对象在栈展开时被正确析构了。这就是 RAII 的力量。

避坑指南:我曾经在析构函数里抛出异常,结果程序直接 terminate 了。记住,析构函数永远不要抛出异常!如果析构函数里可能出错,请内部消化掉。

标准异常类

C++ 标准库提供了一套异常类体系,都继承自 std::exception。我平时用得最多的是这几个:

异常类 说明 使用场景
std::runtime_error 运行时错误 除零、文件打开失败等
std::logic_error 逻辑错误 参数无效、越界访问等
std::out_of_range 越界错误 vector::at() 越界
std::bad_alloc 内存分配失败 new 分配内存失败
std::invalid_argument 无效参数 函数参数不合法

这些异常类都提供了 what() 方法,返回一个描述错误的 C 风格字符串。我个人建议优先使用标准异常类,而不是自己造轮子。除非标准库确实满足不了你的需求。

#include <iostream>
#include <vector>
#include <stdexcept>

int main() {
    try {
        std::vector<int> vec = {1, 2, 3};
        int val = vec.at(10);  // 抛出 out_of_range
    } catch (const std::out_of_range& e) {
        std::cerr << "越界了: " << e.what() << std::endl;
    }
    return 0;
}

自定义异常类

有时候标准异常类不够用。比如你在写一个网络库,需要表达「连接超时」、「DNS 解析失败」这类具体错误。这时候就该自定义异常了。

自定义异常很简单,继承 std::exception 或者它的子类,重写 what() 方法就行。

#include <iostream>
#include <exception>
#include <string>

class NetworkException : public std::runtime_error {
public:
    explicit NetworkException(const std::string& message, int errorCode)
        : std::runtime_error(message), m_errorCode(errorCode) {}

    int getErrorCode() const { return m_errorCode; }

private:
    int m_errorCode;
};

void connectToServer() {
    // 模拟连接失败
    throw NetworkException("连接服务器失败", 1001);
}

int main() {
    try {
        connectToServer();
    } catch (const NetworkException& e) {
        std::cerr << "网络错误: " << e.what()
                  << " (错误码: " << e.getErrorCode() << ")" << std::endl;
    }
    return 0;
}

你看,自定义异常可以携带更多信息,比如错误码。这样 catch 块就能根据错误码做不同的处理。

我的建议:自定义异常类最好继承 std::runtime_errorstd::logic_error,而不是直接继承 std::exception。因为前者已经帮你实现了 what() 的字符串存储,省事不少。

异常安全与最佳实践

写异常安全的代码,说白了就是要保证:即使发生异常,程序也不会泄漏资源或处于不一致状态。我总结了几个原则:

  • 使用 RAII:让资源管理自动进行。智能指针、锁守卫都是好例子。
  • 不要吞异常:catch 了异常却什么都不做,比不 catch 更糟糕。
  • 按引用捕获:避免对象切片和额外拷贝。
  • catch 顺序:子类在前,父类在后。否则子类异常永远捕获不到。
  • 不要抛出异常:在析构函数、移动构造函数、swap 函数中。
// 不好的做法
try {
    // ...
} catch (std::exception) {  // 按值捕获,切片!
    // 什么都不做,异常被吞了
}

// 好的做法
try {
    // ...
} catch (const std::invalid_argument& e) {  // 子类在前
    // 处理无效参数
} catch (const std::exception& e) {  // 父类在后
    std::cerr << "未知异常: " << e.what() << std::endl;
    throw;  // 重新抛出,让上层处理
}

我曾经踩过的坑:在 catch 块里写 throw e; 而不是 throw;。前者会拷贝一份异常对象,丢失了多态信息。记住,重新抛出请用 throw; 不带参数。

异常处理知识体系

下面这张图帮你理清异常处理的整体脉络:

C++ 异常处理知识体系 异常处理 try-catch-throw 标准异常类 自定义异常 try 块:监控可能出错的代码 throw:抛出异常对象 catch:捕获并处理异常 runtime_error:运行时错误 logic_error:逻辑错误 bad_alloc:内存分配失败 继承 std::exception 重写 what() 方法 携带额外错误信息 最佳实践:RAII + 按引用捕获 + 不吞异常

异常处理这东西,刚开始可能觉得麻烦。但用顺手了,你会发现它让代码干净很多。错误处理不再散落在各个角落,而是集中管理。程序出问题时,用户看到的是友好的提示,而不是莫名其妙的崩溃。

记住一点:异常不是为了处理「所有可能的错误」,而是为了处理「你预料到但无法就地解决的错误」。把握好这个度,你的代码就会既健壮又清晰。

公众号:蓝海资料掘金营,微信deep3321