C++ 异常处理
异常(exception)是 C++ 处理运行时错误的机制。当函数遇到无法处理的错误(文件打不开、网络断、除零),它可以"抛出"异常,由调用链上某处"捕获"处理。这一章我们看 C++ 异常的全部要点。
一、为什么需要异常?
不用异常,处理错误是这种风格:
- 函数返回错误码,调用者必须每次检查
if (err)。 - 错误层层传播,代码充斥
if (err) return err;。 - 构造函数没有返回值,无法用错误码报告失败。
异常的优点:
- 分离正常逻辑和错误处理:try 块里只写"正常代码",catch 集中处理错误。
- 自动跨函数传播:抛出的异常会沿调用链向上,直到被捕获。
- 构造函数也能失败:无法用返回值时,异常是唯一选择。
异常的缺点:
- 性能开销:抛出和捕获都有开销(虽然现代 C++ 在"不抛"时几乎零开销)。
- 控制流隐式:阅读代码时不知道哪里会抛,容易写出难调试的 bug。
- 不适用于所有场景:游戏引擎、嵌入式、内核通常禁用异常。
二、基本用法:throw / try / catch
#include <iostream>
#include <stdexcept>
using namespace std;
double divide(int a, int b) {
if (b == 0) {
// throw 抛出一个异常对象
throw runtime_error("除零错误!");
}
return static_cast<double>(a) / b;
}
int main() {
try {
// 可能抛异常的代码
cout << divide(10, 2) << endl; // 5
cout << divide(10, 0) << endl; // 抛异常
cout << "这里不会执行" << endl;
}
catch (const runtime_error& e) {
// 捕获并处理
cout << "捕获异常: " << e.what() << endl;
}
cout << "程序继续..." << endl;
return 0;
}
// 输出:
// 5
// 捕获异常: 除零错误!
// 程序继续...关键点:
- throw:抛出异常对象。可以是任意类型,但强烈推荐抛 std::exception 的子类。
- try:标记可能抛异常的代码块。
- catch:捕获并处理。按类型匹配。
- 未捕获的异常:调用
std::terminate,程序崩溃。 - 异常对象用 const 引用捕获:避免拷贝、避免对象切片。
三、多重 catch 与通配
#include <iostream>
#include <stdexcept>
using namespace std;
void risky(int x) {
if (x == 0) throw runtime_error("zero");
if (x < 0) throw invalid_argument("negative");
if (x > 100) throw out_of_range("too big");
cout << "OK: " << x << endl;
}
int main() {
int arr[] = {1, 0, -5, 200};
for (int x : arr) {
try {
risky(x);
}
catch (const runtime_error& e) {
cout << "runtime_error: " << e.what() << endl;
}
catch (const invalid_argument& e) {
cout << "invalid_argument: " << e.what() << endl;
}
catch (const out_of_range& e) {
cout << "out_of_range: " << e.what() << endl;
}
catch (...) { // 捕获所有其他类型(通配)
cout << "未知异常" << endl;
}
}
return 0;
}- catch 按顺序匹配:从上到下,匹配第一个合适的。
- 子类必须在前:父类在前会"吃掉"子类异常。
catch(...):捕获任意类型异常(通配),但拿不到异常对象。- 用 const 引用:避免拷贝和对象切片(子类被当父类拷贝会丢失信息)。
四、标准异常体系
C++ 标准库提供了一组异常类,全在 <stdexcept> 头文件:
#include <iostream>
#include <stdexcept>
using namespace std;
// C++ 标准异常体系(<stdexcept>):
// std::exception 所有标准异常的基类
// ├── logic_error 程序逻辑错误(可避免)
// │ ├── invalid_argument 参数非法
// │ ├── domain_error 域错误
// │ ├── length_error 长度超限
// │ └── out_of_range 下标越界
// ├── runtime_error 运行时错误(难以避免)
// │ ├── overflow_error 算术上溢
// │ ├── underflow_error 算术下溢
// │ └── range_error 范围错误
// └── bad_alloc new 分配失败
// bad_cast dynamic_cast 失败
// bad_typeid typeid 失败
int main() {
try {
// 各种抛标准异常的操作
throw out_of_range("越界");
}
// 子类异常必须在前,父类在后(否则永远捕到父类)
catch (const out_of_range& e) {
cout << "out_of_range: " << e.what() << endl;
}
catch (const logic_error& e) { // 父类
cout << "logic_error: " << e.what() << endl;
}
catch (const exception& e) { // 顶层基类
cout << "exception: " << e.what() << endl;
}
return 0;
}常用标准异常:
- logic_error 系列:程序逻辑错误(本可避免)。如 invalid_argument(参数非法)、out_of_range(下标越界)。
- runtime_error 系列:运行时错误(难以避免)。如 overflow_error(算术溢出)。
- bad_alloc:new 失败。你不一定要 catch——通常让程序崩溃更合理。
推荐用 runtime_error 作为自定义异常的基类——它有 what() 方法返回错误信息,够用。
五、自定义异常
继承 std::runtime_error 或 std::exception,加自己的字段:
#include <iostream>
#include <stdexcept>
#include <string>
using namespace std;
// 自定义异常:继承 std::runtime_error 或 std::exception
class DatabaseError : public runtime_error {
int errorCode;
public:
DatabaseError(const string& msg, int code)
: runtime_error(msg), errorCode(code) {}
int code() const { return errorCode; }
};
class FileNotFoundError : public runtime_error {
string path;
public:
FileNotFoundError(const string& p)
: runtime_error("File not found: " + p), path(p) {}
const string& getPath() const { return path; }
};
int main() {
try {
throw DatabaseError("连接超时", 500);
}
catch (const DatabaseError& e) {
cout << "DB 错误 [" << e.code() << "]: " << e.what() << endl;
}
try {
throw FileNotFoundError("/etc/config");
}
catch (const FileNotFoundError& e) {
cout << e.what() << " (path: " << e.getPath() << ")" << endl;
}
return 0;
}自定义异常的关键点:
- 继承 runtime_error 或 exception:有现成的 what() 方法。
- 构造函数调用基类构造:把 message 传给
runtime_error(msg)。 - 加自定义字段:错误码、路径、上下文等。
- 不要抛裸字符串或整数:虽然语法上可以
throw "error",但失去类型信息,难捕获。
六、noexcept:承诺不抛异常(C++11)
#include <iostream>
using namespace std;
// noexcept(C++11):承诺函数不会抛异常
void safeFunction() noexcept {
cout << "我不会抛异常" << endl;
// throw runtime_error("oops"); // 调 terminate,程序直接挂
}
// noexcept(false):可能抛(默认)
void riskyFunction() {
throw runtime_error("oops");
}
// noexcept(condition):根据条件决定
template <typename T>
void swapValues(T& a, T& b) noexcept(noexcept(a = b)) {
T tmp = a;
a = b;
b = tmp;
}
// 为什么用 noexcept?
// 1. 编译器优化:知道不抛异常,可以做更多优化
// 2. 标准库要求:vector 在重新分配时,如果元素类型是 noexcept 移动,
// 就用移动(快),否则用拷贝(慢但安全)
int main() {
static_assert(noexcept(safeFunction()), "should be noexcept");
cout << "noexcept check passed" << endl;
return 0;
}noexcept 的意义:
- 编译器优化:不抛异常的函数可以做更多优化(不需要保留异常处理代码)。
- 标准库要求:vector 在重新分配时,如果元素是 noexcept 移动,会用移动(快);否则用拷贝(慢)。移动构造/赋值建议都标 noexcept。
- noexcept 函数里抛异常:调用
std::terminate,程序直接挂(不会沿调用链传播)。 - 析构函数默认 noexcept:在析构里抛异常非常危险,几乎肯定崩溃。
七、重新抛出与异常包装
有时你想"先处理一下,再让上层继续处理":
#include <iostream>
#include <stdexcept>
using namespace std;
void inner() {
throw runtime_error("原始异常");
}
void middle() {
try {
inner();
}
catch (const runtime_error& e) {
cout << "middle 捕获: " << e.what() << endl;
// 处理后可以重新抛出,让外层继续处理
throw; // 不带参数:重新抛出当前异常
// throw runtime_error("包装: "s + e.what()); // 也可以包装后抛新异常
}
}
int main() {
try {
middle();
}
catch (const exception& e) {
cout << "outer 捕获: " << e.what() << endl;
}
return 0;
}
// 输出:
// middle 捕获: 原始异常
// outer 捕获: 原始异常- throw;(无参):重新抛出当前异常,保留原始类型和栈信息。
- throw 新异常:包装成更有语义的异常。常见于分层架构(底层异常包装成业务异常)。
八、异常安全(Exception Safety)
"异常安全"指代码在抛异常时的资源/数据完整性。有四个等级:
#include <iostream>
#include <memory>
using namespace std;
// 异常安全(Exception Safety)的四个等级:
// 1. 无异常保证(No guarantee):出异常可能资源泄漏、数据破坏
// 2. 基本保证(Basic):出异常不会泄漏、不会破坏,但程序状态可能改变
// 3. 强保证(Strong):出异常时程序状态回滚到调用前(事务性)
// 4. 不抛保证(No-throw):绝不抛异常(noexcept)
// 反面教材:不安全
class Bad {
int* a;
int* b;
public:
Bad() : a(new int(1)), b(new int(2)) {}
~Bad() { delete a; delete b; }
void reset() {
delete a;
a = new int(0); // 若 new 抛异常,a 已 delete 但未置 nullptr
delete b; // 析构时再 delete a 是 double free!
b = new int(0);
}
};
// 正面教材:强异常安全(copy-and-swap)
class Good {
unique_ptr<int> a;
unique_ptr<int> b;
public:
Good() : a(make_unique<int>(1)), b(make_unique<int>(2)) {}
void reset() {
auto newA = make_unique<int>(0); // 先在临时变量上构造
auto newB = make_unique<int>(0); // 若这里抛异常,原 a/b 未受影响
a = move(newA); // 不抛(移动)
b = move(newB); // 不抛
}
};四个异常安全等级:
- 无保证:出异常可能泄漏、可能破坏。最差。
- 基本保证(Basic):不泄漏、不破坏,但状态可能改变。STL 容器的最低标准。
- 强保证(Strong):出异常时回滚到调用前。事务性、最常用。
- 不抛保证(No-throw):绝不抛异常(noexcept)。析构、swap 必须做到。
实现强保证的常用手法:
- copy-and-swap:先在临时变量上完成所有可能抛异常的操作,最后用 noexcept 的 swap 一次性提交。
- RAII:用智能指针/容器自动管理资源,异常发生时析构自动释放。
- 避免在析构里抛异常:析构抛异常会导致 std::terminate。
九、何时用异常,何时用错误码?
这是 C++ 社区长期争论的话题。经验法则:
// 异常 vs 错误码:何时用异常?
// 用异常的情况:
// 1. 真正"异常"的情况(失败的预期很低):文件打不开、网络断、内存不足
// 2. 错误会传播多层:用异常可以避免每层都 if (err) return;
// 3. 构造函数失败:构造函数没有返回值,只能用异常
// 4. 不能继续执行的错误:解析 JSON 失败、配置格式错误
// 用错误码/返回值的情况:
// 1. 预期会发生的情况:用户输入错误、文件已存在
// 2. 性能关键路径:异常有性能开销(尽管现代 C++ 已经优化很多)
// 3. 嵌入式/游戏引擎:很多团队禁用异常(关 -fno-exceptions 提速)
// 4. 跨语言边界:extern "C" API 不能抛异常
// 经验法则:
// - 业务应用、脚本、CLI:用异常
// - 高频交易、游戏引擎、内核:用错误码
// - 库的对外 API:看目标用户(谷歌 C++ 风格禁用异常)
// C++23 引入了 std::expected<T, E>(类似 Rust 的 Result)
// 提供了"错误码 + 类型安全"的折中方案,无需用异常。
#include <expected>
std::expected<int, std::string> parse(const std::string& s);- 用异常:真正的"异常"情况(失败率低)、跨多层传播、构造函数失败。
- 用错误码:预期会发生的情况、性能关键路径、跨语言 API、嵌入式/游戏。
- C++23 的 std::expected:类似 Rust 的 Result,提供"错误码 + 类型安全"的折中方案。
- 团队规范优先:谷歌 C++ 风格指南禁用异常,有些团队相反。看你团队规范。
十、常见陷阱
- 析构函数抛异常:几乎必然崩溃。永远不要在析构里抛。
- catch by value(按值捕获):对象切片,丢失子类信息。用 const 引用。
- 异常+裸指针 = 内存泄漏:new 后抛异常,delete 永远不执行。用 unique_ptr 解决。
- catch(...) 拿不到信息:无法知道是什么异常。优先 catch 具体类型。
- 用异常做控制流:别拿异常当 goto 用,可读性极差。
小结
这一章你掌握了 C++ 异常处理:throw/try/catch 基本用法、多重 catch、标准异常体系、自定义异常、noexcept、异常安全四等级、何时用异常何时用错误码。下一篇我们看 C++ 的文件 IO——怎么读写文件。
← 上一篇 C++ 内存管理
下一篇 C++ 文件 IO →