C++ 有两套并行的错误处理体系:异常(exception)处理"不该发生但发生了"的意外,错误即值std::optional / std::expected)处理"完全可能发生"的预期失败。很多人混用它们,结果要么用异常做流程控制、要么对所有失败都返回 expected 把调用方淹没在 if (!result) 里。这篇把两套机制的边界、异常的生命周期、异常安全等级、以及"何时该用哪一种"一次讲清楚。

std::optionalstd::expected 的接口细节见 C++ optional & expected,这里只讲策略层面的取舍。

异常的生命周期

抛出一个异常时,运行时会沿着调用栈向上查找匹配的 catch,找到前先逐帧栈展开(stack unwinding):每个被跳过的局部对象都会按构造的逆序析构。这正是 RAII 能保证资源释放的根本机制——异常一旦抛出,所有局部对象析构,资源自动回收。

1
2
3
4
5
6
7
void risky() {
std::vector<int> v(1'000'000, 42); // RAII: owns its buffer
File f = open("data.txt"); // RAII: owns a file handle
if (bad_condition()) {
throw std::runtime_error("boom"); // v and f destroyed during unwinding
}
} // no leak even on throw

栈展开期间的关键规则:

  • 局部对象析构是自动的,但你必须保证析构函数本身不会抛异常(见下文 noexcept 析构)。
  • 构造期间抛异常:已经构造好的成员和基类子对象会被析构,但对象本身从未"完成构造",所以它的析构函数不会被调用。这是"构造函数抛异常"时唯一的坑——必须确保每个成员自己能清理。
  • 数组/容器元素构造抛异常:已构造的元素按逆序析构,已分配的内存释放。标准容器都保证这一点。

throw 与 catch 的类型匹配

1
2
3
4
5
6
7
try {
throw std::runtime_error("oops");
} catch (const std::logic_error& e) { // does NOT match runtime_error
// ...
} catch (const std::exception& e) { // matches: runtime_error derives from exception
std::println("caught: {}", e.what());
}

匹配规则比函数重载严格得多——只允许隐式转换到基类引用/指针、const 调整、数组/函数退化,不允许算术转换或自定义转换。所以 catch (const std::exception&) 是兜底,catch 顺序应从派生到基类,先具体后宽泛。

重新抛出与嵌套异常

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
try {
process_file(path);
} catch (const std::filesystem::filesystem_error& e) {
// 保留原始异常作为 nested,附带当前上下文重新抛出
std::throw_with_nested(std::runtime_error(
std::format("processing '{}' failed", path)));
}

// 递归打印整条异常链
void print_exception(const std::exception& e, int level = 0) {
std::println("{}{}", std::string(level, ' '), e.what());
try {
std::rethrow_if_nested(e);
} catch (const std::exception& nested) {
print_exception(nested, level + 1);
}
}

throw;(无操作数)在 catch 块里重新抛出当前异常,保留原始类型;throw e; 则会拷贝切片,丢失派生类型,几乎总是错的。

noexcept:声明"我不会抛"

noexcept 是函数的契约声明:承诺不抛异常。它有两个用途——优化和约束。

1
2
3
void swap(int& a, int& b) noexcept {     // promise: won't throw
int tmp = a; a = b; b = tmp;
}

为什么 noexcept 能提速

标准库的很多操作会先问类型会不会抛,再决定走哪条路径。最典型的就是 std::vector 扩容:扩容要逐个移动/拷贝旧元素,如果元素的移动构造是 noexcept 的,vector 就用移动(快);否则为强异常安全起见退化为拷贝(慢但安全)。

1
2
3
4
5
6
7
8
9
10
11
struct Good {
std::vector<int> data;
Good(Good&&) noexcept = default; // noexcept move → vector uses move on realloc
};

struct Bad {
std::vector<int> data;
Bad(Bad&&) = default; // implicit noexcept, fine here
Bad(const Bad&) { /* might throw */ }
// if move could theoretically throw (not defaulted), vector falls back to copy
};

这也是为什么"移动构造尽量加 noexcept"是条硬规矩——不只是文档,它直接改变标准库的行为。

noexcept(false) 与条件式 noexcept

1
2
3
4
5
6
7
// 仅当 T 的移动不抛时,本函数也不抛
template<typename T>
void relocate(T& from, T& to) noexcept(std::is_nothrow_move_constructible_v<T>) {
to = std::move(from);
}

// noexcept(expr) 里的 expr 是常量表达式 bool

析构函数默认 noexcept

从 C++11 起,析构函数隐式 noexcept(除非显式 noexcept(false) 或基类/成员析构会抛)。这是有原因的:栈展开期间若析构再抛异常,且当前已有异常在传播,std::terminate 立即被调用。永远不要让析构函数抛异常——这是 C++ 最硬的几条铁律之一。

违反 noexcept 的后果

noexcept 函数里真抛了异常不会"偷偷吞掉",而是直接 std::terminate

1
2
3
void f() noexcept {
throw std::runtime_error("x"); // calls std::terminate(), no unwinding
}

所以 noexcept 是一个承诺:你向编译器和库保证不抛,违约代价是程序直接死。别轻易加,加了就确保守住。

异常安全等级

Sutter 提出的四个等级,从弱到强:

等级保证典型手段
No-throw绝不抛析构、swap、移动基本类型
Strong(强)失败时状态回滚到调用前,像没发生过copy-and-swap、先准备再提交
Basic(基本)失败时对象仍处于有效状态、不泄漏,但具体值不确定大部分标准库操作
No guarantee失败时啥都可能发生写得糙的代码

强异常安全的经典模式:copy-and-swap

1
2
3
4
5
6
7
8
9
10
11
12
class Buffer {
std::vector<int> data_;
public:
Buffer& operator=(const Buffer& other) {
Buffer tmp(other); // 1. 先在临时副本上做所有可能抛的操作
swap(tmp); // 2. 用 noexcept 的 swap 提交,绝不抛
return *this; // 3. tmp 析构旧数据
}
void swap(Buffer& other) noexcept {
data_.swap(other.data_);// vector::swap is noexcept
}
};

关键思想:把可能抛异常的操作和"提交"分开。能抛的全做完且成功后,用一个 noexcept 的操作原子地切换状态。若步骤 1 抛了,this 完全没被动过,满足强保证。

先准备再提交

std::vector::push_back 扩容就是这个套路:先分配新内存、在新内存里构造所有元素(这步可能抛),全部成功后再释放旧内存、切换指针。任意一步失败,旧数据原封不动。

异常 vs 错误即值:何时用哪个

这是现代 C++ 最大的设计取舍之一。判断标准是这个失败是"预期之内"还是"意外"

信号用异常用 optional/expected
频率罕见(异常路径)常见甚至主流
性能抛时有开销,不抛时近零每次都要 check,但可预测
强制性调用方可忽略( terminates 或 propagate)调用方必须显式处理返回值
传播自动沿栈展开手动 return 逐层传递(或 ? 等糖)
信息量携带类型化对象 + 调用栈携带错误码 / 错误对象

经验法则:

  • 配置加载失败、文件不存在、网络超时、JSON 解析错 → 预期失败,用 std::expected。调用方大概率要据此分支,错误是正常逻辑的一部分。
  • 内存耗尽、不变式被破坏、不可能的状态 → 意外,用异常(或 assert / 直接 terminate)。这些不该在正常流程里处理。
  • "找不到"算正常结果(如 map::find)→ std::optional,连错误信息都不需要。

不要用异常做流程控制

1
2
3
4
5
6
7
8
9
10
11
12
13
// 反模式:用异常表示"未找到"这种完全正常的分支
size_t find(const std::vector<int>& v, int x) {
for (size_t i = 0; i < v.size(); ++i)
if (v[i] == x) return i;
throw std::runtime_error("not found"); // wrong: not-found is expected
}

// 正确:optional 表达"可能有也可能没有"
std::optional<size_t> find(const std::vector<int>& v, int x) {
for (size_t i = 0; i < v.size(); ++i)
if (v[i] == x) return i;
return std::nullopt;
}

抛异常在未抛路径上几乎免费,但一旦进入抛出路径开销大且语义上误导——它声明"出问题了",而不是"这是结果之一"。

expected 串联:monadic 风格

C++23 给 std::expected(和 optional)加了 monadic 接口,让逐层传播不再被 if (!r) return r.error() 淹没:

1
2
3
4
5
6
7
8
std::expected<User, Error> load(std::string_view id) {
return read_config(id)
.and_then([](Config c) { return open_db(c.path); })
.and_then([](DbHandle db) { return db.query_user(id); })
.or_else([](Error e) -> std::expected<User, Error> {
return recover(e); // 错误恢复
});
}

每一步失败会短路到最终返回值,成功则链式继续。这比层层 try/catch 在"预期失败"场景里干净得多。

异常的开销模型

很多人怕异常是因为听说"异常很慢"。其实要分两种:

  • 无异常抛出(快路径):现代编译器(Itanium ABI / zero-cost exception)下,正常路径只生成少量"异常表"元数据,运行期几乎零开销——没有分支、没有设置/检查标志位。代价是二进制体积略增。
  • 异常抛出(慢路径):抛出时要分配异常对象、走栈展开、查表,比一次函数调用慢一两个数量级。

所以"异常用于异常路径"这个原则还有性能层面的合理性:只要异常真的罕见,整体更快;如果把它当返回值用、频繁抛出,反而比 expected 慢。

-fno-exceptions 可以完全关闭异常(嵌入式、游戏引擎常见),代价是 throwterminate、标准库容器的强异常安全退化。关了异常的项目通常全面改用 expected / abort。

自定义异常类型

std::exception 派生,覆盖 what(),便于在 catch (const std::exception&) 兜底时仍能拿到信息:

1
2
3
4
5
6
7
8
9
10
11
class ParseError : public std::runtime_error {
std::string source_;
int line_;
public:
ParseError(std::string msg, std::string src, int line)
: std::runtime_error(std::format("{} at {}:{}", msg, src, line))
, source_(std::move(src)), line_(line) {}

[[nodiscard]] std::string_view source() const noexcept { return source_; }
[[nodiscard]] int line() const noexcept { return line_; }
};
  • 优先用标准库已有的 std::runtime_error / std::logic_error / std::system_error(带 std::error_code)。
  • 按值抛、按引用捕(catch (const ParseError&)),别按指针抛——指针的 ownership 谁管?
  • 别抛内置类型(throw 42;),失去类型信息和 what()

全局兜底

未被任何 catch 捕获的异常会触发 std::terminate,默认打印消息后 std::abort。可以装个自定义 handler 记录崩溃栈:

1
2
3
4
5
6
7
8
9
10
11
12
int main() {
std::set_terminate([] {
try { std::rethrow_exception(std::current_exception()); }
catch (const std::exception& e) {
std::println(stderr, "terminate: {}", e.what());
} catch (...) {
std::println(stderr, "terminate: unknown exception");
}
std::abort();
});
// ...
}

catch (...) 能捕获任意类型但拿不到对象,只适合最后兜底做清理。

一句话总结

异常和 expected 不是二选一的敌人,而是分工:异常管"意外",expected 管"预期失败",optional 管"可能有"。判定标准永远是——这个失败是程序正常运转的一部分,还是出了岔子?想清楚这一条,两套机制就能各司其职,而不是互相打架。