10. 异常处理:让你的程序学会“优雅地摔倒”

写代码这么多年,我见过最惨烈的崩溃,不是逻辑错误,而是程序直接“挂掉”——没有任何提示,没有任何挽回余地。你想想看,一个银行转账系统,执行到一半突然崩溃,钱扣了但没到账,这谁受得了?

异常处理,说白了就是给程序装上一套“安全气囊”。当意外发生时,不是直接爆炸,而是有控制地停下来,甚至还能尝试修复。今天我们就来聊聊C++里的异常机制。

核心思想:异常处理不是用来处理“正常情况”的,而是用来应对“意料之外但可能发生”的错误。比如文件打不开、内存分配失败、网络连接断开等。

10.1 异常的基本语法:try、catch、throw

这三个关键字,是C++异常处理的铁三角。我习惯把它们想象成“扔球”和“接球”的游戏:

  • throw:把异常“扔”出去
  • try:标记一段可能“被扔”的代码区域
  • catch:在try后面“接住”被扔出来的异常

来看一个最简单的例子:

#include <iostream>
#include <stdexcept>

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

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

运行结果:

捕获到异常:除数不能为零!

嗯,这里要注意:throw 一旦执行,函数会立即返回,后面的代码都不会执行。异常会沿着调用栈向上“冒泡”,直到遇到合适的 catch 块。如果一直没被捕获,程序就会调用 std::terminate() 直接终止——这就是我们常说的“崩溃”。

个人经验:我在项目中见过有人把 throw 当成“goto”来用,到处扔异常。千万别这么干!异常的开销比普通返回大得多,只应该在真正的异常情况下使用。

10.2 异常类型与匹配:不是所有的球都能接

C++允许你抛出任何类型的对象——整数、字符串、自定义类,都可以。但我不建议你乱扔。为什么呢?

想象一下,你写了一个库,别人用你的代码。你扔了一个 int 类型的异常,对方怎么知道这个 int 代表什么?是错误码?还是数据?

我个人习惯的做法是:总是抛出标准异常类的派生类对象。这样别人一看就知道是异常,而且能通过 what() 方法获取错误描述。

异常匹配的规则很简单:

  • catch 块按顺序匹配,从上到下
  • 匹配规则和函数重载类似,但更宽松——基类引用可以捕获派生类异常
  • 如果多个 catch 都能匹配,第一个匹配的会被执行

看这个例子:

try {
    // 可能抛出多种异常
    someFunction();
} catch (const std::invalid_argument& e) {
    // 先匹配更具体的类型
    std::cout << "参数错误:" << e.what() << std::endl;
} catch (const std::runtime_error& e) {
    // 再匹配一般性的运行时错误
    std::cout << "运行时错误:" << e.what() << std::endl;
} catch (const std::exception& e) {
    // 最后匹配所有标准异常
    std::cout << "其他异常:" << e.what() << std::endl;
} catch (...) {
    // 捕获所有非标准异常(兜底)
    std::cout << "未知异常" << std::endl;
}

我曾经踩过的坑:有一次我把 catch (const std::exception& e) 写在了最前面,结果后面更具体的 catch 块永远执行不到。编译器居然没报错!从那以后,我每次写异常捕获都会刻意检查顺序——从最具体到最通用。

10.3 异常安全保证:你的代码能扛住吗?

异常安全,是衡量代码质量的一个重要指标。说白了就是:当异常发生时,你的程序状态是否还能保持一致?

C++社区把异常安全分为三个等级:

等级 含义 通俗理解
基本保证 异常发生后,程序处于有效状态,但具体值可能变了 “人没死,但可能受了伤”
强保证 异常发生后,程序状态和调用前完全一样 “时光倒流,好像什么都没发生过”
不抛异常 函数保证永远不会抛出异常 “绝对安全,永远不会摔倒”

我举个例子你就明白了。假设你在写一个银行转账函数:

class BankAccount {
    int balance_;
public:
    void withdraw(int amount) {
        if (amount > balance_) {
            throw std::runtime_error("余额不足");
        }
        balance_ -= amount;
    }
    
    void deposit(int amount) {
        balance_ += amount;
    }
};

void transfer(BankAccount& from, BankAccount& to, int amount) {
    from.withdraw(amount);  // 如果这里抛异常,to 的状态不变
    to.deposit(amount);     // 如果这里抛异常,from 的钱已经扣了!
}

看到了吗?transfer 函数只提供了基本保证——如果 deposit 失败,from 账户的钱已经扣了,但 to 没收到。这在银行系统里是绝对不允许的!

要提供强保证,可以这样改:

void transfer(BankAccount& from, BankAccount& to, int amount) {
    // 先检查所有条件
    if (from.getBalance() < amount) {
        throw std::runtime_error("余额不足");
    }
    
    // 执行操作,但先不修改状态
    int newFromBalance = from.getBalance() - amount;
    int newToBalance = to.getBalance() + amount;
    
    // 最后一次性修改状态(假设这两个操作都不会抛异常)
    from.setBalance(newFromBalance);
    to.setBalance(newToBalance);
}

我的建议:写代码时,至少保证“基本保证”。如果做不到,那你的代码就是“异常不安全”的——这种代码在线上环境就是定时炸弹。我见过一个项目,因为异常安全没做好,导致数据库里出现了“幽灵数据”,排查了整整三天。

10.4 标准异常类:C++给你准备好的工具箱

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

std::exception
├── std::logic_error          // 逻辑错误(可预见的)
│   ├── std::invalid_argument  // 无效参数
│   ├── std::domain_error      // 域错误
│   ├── std::length_error      // 长度错误
│   └── std::out_of_range      // 越界访问
├── std::runtime_error        // 运行时错误(不可预见的)
│   ├── std::range_error       // 范围错误
│   ├── std::overflow_error    // 上溢
│   └── std::underflow_error   // 下溢
└── std::bad_alloc             // 内存分配失败(在 <new> 中定义)

我个人习惯这样使用:

  • 逻辑错误:函数调用前就能检查出来的问题,比如参数不合法。用 std::invalid_argument
  • 运行时错误:运行时才能发现的问题,比如文件不存在。用 std::runtime_error
  • 内存不足new 分配失败时,标准库会自动抛出 std::bad_alloc,你不需要手动处理。

看一个实际例子:

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

int getElement(const std::vector<int>& vec, size_t index) {
    if (index >= vec.size()) {
        throw std::out_of_range("索引 " + std::to_string(index) + 
                                " 超出范围,容器大小:" + std::to_string(vec.size()));
    }
    return vec[index];
}

int main() {
    std::vector<int> data = {1, 2, 3};
    try {
        std::cout << getElement(data, 5) << std::endl;
    } catch (const std::out_of_range& e) {
        std::cout << "越界错误:" << e.what() << std::endl;
    }
    return 0;
}

重要提醒:永远不要抛出 std::exception 本身!它只是一个基类,没有具体的语义。抛出它的派生类,别人才能根据类型做出不同的处理。

10.5 noexcept关键字:给编译器一个承诺

noexcept 是C++11引入的关键字,用来告诉编译器:这个函数保证不会抛出异常。

为什么需要这个?

你想想看,如果编译器知道一个函数不会抛异常,它可以做很多优化——比如省掉异常处理相关的栈展开代码。而且,在移动构造函数、析构函数等场景中,noexcept 几乎是必须的。

用法很简单:

void safeFunction() noexcept {
    // 这个函数保证不会抛异常
    int a = 10;
    int b = 20;
    int c = a + b;
}

// 也可以带条件
void conditionalFunction() noexcept(noexcept(someOperation())) {
    // 当 someOperation() 也不抛异常时,这个函数才不抛异常
    someOperation();
}

嗯,这里有个坑:如果你在 noexcept 函数里抛了异常,程序会直接调用 std::terminate() 终止,不会走正常的异常处理流程。所以,不要轻易给函数加上 noexcept,除非你百分之百确定它不会抛异常。

我曾经犯过的错:有一次我给一个函数加了 noexcept,但函数内部调用了 std::vector::push_back——这个操作在内存不足时会抛 std::bad_alloc。结果程序在线上莫名其妙地崩溃了,连错误日志都没留下。从那以后,我给自己定了个规矩:只有纯计算、不涉及资源分配的函数,才敢加 noexcept

哪些函数应该加 noexcept

  • 析构函数:C++标准规定析构函数默认不抛异常,加了 noexcept 可以优化
  • 移动构造函数和移动赋值运算符:标准库容器在重新分配内存时,会优先使用 noexcept 的移动操作
  • swap 函数:通常只交换指针,不会抛异常
  • 简单的 getter/setter:只返回成员变量,不涉及复杂操作

知识体系总览

下面这张图,帮你把今天的内容串起来:

C++ 异常处理知识体系 异常处理 基本语法 try / catch / throw 类型与匹配 从具体到通用 异常安全保证 基本 / 强 / 不抛 标准异常类 exception 体系 noexcept 编译器承诺 核心原则 只在异常情况下使用异常 · 保持异常安全 · 优先使用标准异常

好了,异常处理的基础知识就讲到这里。记住一句话:异常是“最后的手段”,不是“常规流程”。能用条件判断解决的问题,就别扔异常。但一旦遇到真正的异常情况,也别怕——用好今天学的这些工具,让你的程序学会“优雅地摔倒”。


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