35、异常处理
异常机制的价值,是把错误信息沿调用链交给有能力处理它的位置,同时让已经建立的资源得到清理。它不是把任意程序错误都自动变成一个能被 catch 的对象。
哪些错误会进入异常处理
本文使用 C++17。异常来自显式 throw,或者语言与库规定会抛出的操作,例如普通抛出式 new 的分配失败、vector::at 的范围检查等。
整数除零、数组越界或通过无效指针访问,不会被标准保证自动转成可捕获的 C++ 异常。把这些未定义行为放进 try,并不能获得安全保护。数学函数遇到域错误,也不能一概说会自动抛出 std::domain_error。
throw、catch 与栈展开
throw 建立异常对象并转移控制流,运行时沿动态调用链寻找匹配的处理器。在有匹配处理器的正常栈展开中,已经完整构造的自动对象按相应规则析构,随后进入 catch。
这正是 RAII 的基础:资源交给对象管理,错误路径同样通过析构清理。若异常逃逸 noexcept 函数或没有匹配处理器,会进入终止路径,不能保证像可正常处理的异常那样完成所有清理。
catch 按出现顺序匹配。派生异常应放在基类异常前面,通常按 const 引用捕获,避免切片与不必要复制。
常见标准异常的关系

std::out_of_range 属于 logic_error 分支,不属于 runtime_error 分支。异常名称描述库接口的错误约定,不是说 CPU 遇到某个现象就一定构造同名异常。
自定义异常可以继承标准异常类型,提供 what 信息;也可以抛其他类型,但不能因此认为 catch(std::exception&) 能抓到所有用户抛出的类型。
C++17 可运行示例
#include <cassert>
#include <iostream>
#include <stdexcept>
class InputError : public std::runtime_error {
public:
using std::runtime_error::runtime_error;
};
struct ReleaseMarker {
int& released;
~ReleaseMarker() noexcept { ++released; }
};
double checkedDivide(double numerator, double denominator) {
if (denominator == 0.0) throw InputError("denominator must not be zero");
return numerator / denominator;
}
int main() {
std::cout << checkedDivide(5.0, 2.0) << '\n';
int released = 0;
try {
ReleaseMarker marker{released};
checkedDivide(1.0, 0.0);
} catch (const InputError& error) {
assert(released == 1);
std::cout << error.what() << '\n';
} catch (const std::exception& error) {
std::cout << error.what() << '\n';
}
assert(released == 1);
}
先输出 2.5,再输出自定义错误信息。分母检查由我们的函数显式实现,不是执行非法除法后等待系统自动抛错。released 在 catch 之前已变为 1,验证了本例已构造局部对象的析构。
noexcept 与异常安全不是同一概念
noexcept 声明异常不得从函数逃逸;若违反,会调用 std::terminate,而不是让调用者继续 catch。它不证明函数内部绝不可能执行会抛出的操作。析构和清理路径通常不应让第二个异常继续逃逸。
异常安全常区分:不抛保证、失败时不改变可观察状态的强保证,以及维持有效状态、不泄漏资源的基本保证。一个函数标记 noexcept,不等于它替所有内部修改提供了强保证。
常见误区与练习
不要用 catch(...) 静默吞掉所有错误;如果当前层只能补充上下文而不能恢复,应选择明确的传播策略。重新抛出当前异常使用 throw;,不能在已经没有活动处理器时随意调用它。
尝试把 InputError 的 catch 放到 std::exception 后面,解释为什么前者不再有机会处理;再说明本例的显式检查与访问越界这类未定义行为有何区别。参考 C++17 异常处理与 标准异常类型。
