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

异常处理这个话题,说实话,很多初学者觉得它可有可无。我刚开始写C++那会儿也这么想——程序跑得好好的,搞什么异常?直到有一次,我写的一个服务器程序在线上突然崩溃,查了半天发现是一个除零操作没处理。嗯,从那以后,我再也不敢轻视异常处理了。

说白了,异常处理就是给你的程序上一份「保险」。你永远不知道用户会输入什么数据,网络什么时候断开,文件会不会打不开。与其让程序直接崩溃,不如优雅地处理这些意外情况。

1. 异常处理的基本结构:try-catch-throw

C++的异常处理机制,核心就三个关键字:trycatchthrow。它们配合起来,就像一套「报警-接警-出警」的流程。

  • 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::cout << "捕获到异常: " << e.what() << std::endl;
    }
    return 0;
}

这段代码里,divide函数检查到除数为零,直接throw一个异常。调用方用try-catch包住,就能捕获并处理这个异常。程序不会崩溃,而是打印出友好的错误信息。

我的习惯:我一般会在函数开头就做参数检查,发现非法参数直接throw。这样代码逻辑清晰,错误处理集中,不会出现「错误码满天飞」的情况。

2. 标准异常类家族

C++标准库提供了一套完整的异常类体系,都定义在<stdexcept>头文件中。它们都继承自std::exception基类。

异常类 说明 常见场景
std::runtime_error 运行时错误 除零、文件打开失败
std::logic_error 逻辑错误 参数越界、状态错误
std::invalid_argument 无效参数 传入空指针、非法值
std::out_of_range 越界访问 vector的at()越界
std::bad_alloc 内存分配失败 new操作失败

为什么推荐用标准异常类?因为它们的what()方法会返回有意义的错误描述,而且catch的时候可以按基类统一处理。我个人习惯是:能用标准异常就用标准异常,只有标准异常不够用时才自定义。

避坑指南:我曾经犯过一个错误——在catch块里直接写catch(...)捕获所有异常,然后什么都不做。结果程序出了bug,异常被吞掉了,排查了整整两天。记住:要么明确处理,要么让异常继续往上抛,千万别静默吞掉异常!

3. 自定义异常类

有时候标准异常类不够用。比如你要表达「用户余额不足」这种业务异常,标准库里没有对应的类。这时候就需要自定义异常。

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

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

class InsufficientBalanceError : public std::runtime_error {
public:
    explicit InsufficientBalanceError(double balance, double required)
        : std::runtime_error(buildMessage(balance, required).c_str()),
          balance_(balance), required_(required) {}

    double getBalance() const { return balance_; }
    double getRequired() const { return required_; }

private:
    static std::string buildMessage(double balance, double required) {
        return "余额不足!当前余额: " + std::to_string(balance) 
               + ",需要: " + std::to_string(required);
    }

    double balance_;
    double required_;
};

// 使用示例
void withdraw(double amount) {
    double balance = 100.0;
    if (amount > balance) {
        throw InsufficientBalanceError(balance, amount);
    }
    // 正常扣款逻辑...
}

int main() {
    try {
        withdraw(200.0);
    } catch (const InsufficientBalanceError& e) {
        std::cout << e.what() << std::endl;
        std::cout << "还差: " << (e.getRequired() - e.getBalance()) << "元" << std::endl;
    }
    return 0;
}

你看,自定义异常的好处是:除了错误描述,还能携带额外的上下文信息。比如这里我传入了余额和需要的金额,catch的时候可以计算出还差多少钱,给用户更精确的提示。

注意事项:自定义异常类一定要继承自std::exception体系,否则别人用catch(const std::exception& e)就捕获不到你的异常。我见过有人直接throw一个int或者字符串,这种做法非常不推荐——你想想看,如果团队里每个人都按自己的方式抛异常,代码维护起来得多痛苦?

4. 异常处理的核心流程图

下面这张图展示了异常处理的完整流程,从抛出到捕获的整个生命周期:

C++ 异常处理流程图 程序开始执行 try 块:监控可能出问题的代码 执行正常逻辑 是否抛出异常? 正常执行后续代码 程序继续运行 throw 抛出异常 异常对象被创建 catch 捕获异常 匹配异常类型 异常处理逻辑 记录日志、恢复状态、提示用户 程序继续或退出 try块 catch块 throw抛出 正常流程

5. 异常处理的几个关键原则

写了这么多年C++,我总结了几条异常处理的原则,分享给你:

  1. 按引用捕获异常catch(const std::exception& e),避免对象切片和拷贝开销。
  2. 先捕获具体异常,再捕获基类异常:catch块的顺序很重要,编译器会按顺序匹配。
  3. 不要在析构函数中抛出异常:如果析构函数抛出异常,而栈展开过程中又有另一个异常,程序会直接terminate。
  4. 使用RAII管理资源:智能指针、锁守卫等RAII对象能确保异常发生时资源被正确释放。
  5. 异常规格说明已废弃:C++11之后不再推荐使用throw()异常规格,改用noexcept
我的经验:在大型项目中,我习惯定义一个项目级别的异常基类,所有自定义异常都继承自它。这样catch的时候可以统一处理项目异常,同时还能区分是业务异常还是系统异常。比如:class ProjectException : public std::runtime_error { ... }

6. 实际项目中的异常处理策略

你可能会问:到底哪些地方该用异常?哪些地方不该用?我个人的经验是这样的:

  • 该用异常的场景:构造函数失败、资源获取失败、业务逻辑违反约束(比如余额不足)、不可恢复的错误。
  • 不该用异常的场景:普通流程控制(比如用异常跳出循环)、频繁发生的预期错误(比如用户输入格式错误,用返回值判断更高效)。

举个例子,文件打开失败这种「预期可能发生」的情况,用返回值判断就比用异常更合适。但如果是「数据库连接池耗尽」这种异常情况,用异常处理就非常合适。

异常处理不是银弹,它是一把双刃剑。用好了,代码健壮又优雅;用不好,反而会让代码变得混乱。记住一个原则:异常应该用于处理「异常情况」,而不是替代正常的流程控制

最后提醒一句:我曾经接手过一个项目,里面到处都是try-catch,连一个简单的字符串拼接都用异常处理。结果代码又慢又难读。异常处理要「用在刀刃上」,别滥用。

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