错误处理是C++程序设计中最容易被低估、却又最影响代码健壮性的领域。一段看似正确的业务逻辑,往往因为对异常语义、资源生命周期、错误传播路径的理解不足,而在生产环境崩溃或泄漏。本文系统梳理C++从C++98到C++23的错误处理演进,覆盖异常安全保证、RAII、错误码与异常的权衡、std::expected与Result模式、std::variant模式匹配,以及在大型工程中如何落地一套可维护的错误处理策略。

一、错误处理的三种范式与权衡
C++社区长期存在两套并行的错误处理范式:返回码(error code)与异常(exception)。C++23引入的std::expected让”返回值携带错误”这一模式获得了标准库级别的支持,而std::variant与std::visit则为类型安全的错误分支提供了工具。理解每种范式的代价,是做出正确选型的前提。
| 范式 | 代表机制 | 优点 | 缺点 |
|---|---|---|---|
| 返回码 | int返回值、errno、std::error_code | 零开销、控制流显式、易于在禁用异常的环境使用 | 容易忽略、污染函数签名、无法跨调用栈自动传播 |
| 异常 | throw / try-catch | 自动传播、不污染正常路径签名、与RAII天然配合 | 运行时开销(表驱动 unwinding)、禁用场景受限、调试不直观 |
| Expected/Result | std::expected、std::variant、folly::Expected | 显式且类型安全、无 unwinding 开销、可组合(monadic) | 需要手动传播、API较新、与旧代码集成成本 |
选型建议:在允许异常的库和应用中,优先用异常处理”真正意外”的失败(资源耗尽、不变量破坏),用返回值或expected处理”预期内”的失败(解析失败、查找未命中)。在禁用异常的嵌入式或游戏引擎中,std::expected或自定义Result是首选。不要在同一层混用三种范式——选择一种主范式,辅以适配层转换。
二、异常安全保证:四个等级
异常安全不是”不抛异常”,而是”抛出异常后对象仍处于可预测状态”。Sutter和Guru定义了四级保证,从弱到强依次为:
- 无保证(No guarantee):抛异常后对象可能处于任何状态,包括资源泄漏、数据损坏。这是默认状态,必须主动避免。
- 基本保证(Basic guarantee):对象保持有效状态,不泄漏资源,但不保证具体值。这是所有代码应达到的最低线。
- 强保证(Strong guarantee):操作要么完全成功,要么回滚到调用前状态——事务语义。通常通过copy-and-swap实现。
- 不抛保证(Nothrow / noexcept):承诺不抛异常。析构函数、内存释放、移动构造在C++11后应尽量标记noexcept。
下面是一个经典的copy-and-swap示例,它天然提供强保证:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17 class StringList {
public:
StringList& operator=(const StringList& other) {
// 1. 拷贝构造:可能抛异常,但此时本对象未修改
StringList tmp(other);
// 2. 交换:noexcept,不会抛
swap(tmp);
// 3. tmp析构:自动清理旧数据
return *this;
}
void swap(StringList& rhs) noexcept {
data_.swap(rhs.data_);
}
private:
std::vector<std::string> data_;
};
关键在于:所有可能抛异常的操作(拷贝构造)发生在修改本对象之前。一旦拷贝成功,后续的swap和析构都是noexcept。这是”先准备,再提交”的事务思想的C++落地形式。
三、RAII与异常的协作
RAII(Resource Acquisition Is Initialization)是C++错误处理的基石。它的核心契约是:资源的获取与对象的构造绑定,释放与析构绑定。当异常展开栈时,已构造的局部对象析构函数被自动调用,从而保证资源不泄漏。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16 // 错误示范:手动管理,异常时泄漏
void bad_example() {
FILE* f = fopen("data.txt", "r");
if (!f) return;
// 若这里抛异常,f永远不会被关闭
process_file(f);
fclose(f);
}
// 正确做法:用RAII包装
void good_example() {
std::fstream f("data.txt");
if (!f) return;
// 即使 process_file 抛异常,f析构时自动 close
process_file(f);
}
标准库提供的RAII工具远不止fstream。智能指针std::unique_ptr、std::shared_ptr管理堆对象;std::lock_guard、std::unique_lock管理互斥量;std::jthread(C++20)管理线程;std::promise/std::future管理异步状态。在自定义资源类型时,应优先组合这些工具,而非手写new/delete或lock/unlock。
一个常见陷阱是析构函数中抛异常。若栈展开期间析构再次抛异常且未被捕获,std::terminate会被调用,程序直接终止。因此,C++11后析构函数默认隐式noexcept。如果析构需要调用可能抛异常的代码,必须显式捕获:
1
2
3
4
5
6
7
8
9
10
11
12
13 class Session {
public:
~Session() {
try {
close(); // 可能抛异常的网络操作
} catch (...) {
// 记录日志,吞掉异常,保证析构不抛
std::cerr << "close failed in dtor\n";
}
}
private:
void close(); // 可能抛
};
四、std::expected:C++23的Result类型
std::expected<T, E>是C++23引入的”成功值或错误值”容器,灵感来自Rust的Result和Haskell的Either。它显式地在类型签名中表达”这个函数可能失败”,迫使调用方处理错误分支,避免错误被忽略。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21 #include <expected>
#include <string>
#include <cmath>
// 返回成功值或错误描述
std::expected<double, std::string> safe_sqrt(double x) {
if (x < 0) {
return std::unexpected("sqrt of negative number");
}
return std::sqrt(x);
}
int main() {
auto r = safe_sqrt(-4.0);
if (r) {
std::cout << "result: " << *r << '\n';
} else {
std::cout << "error: " << r.error() << '\n';
}
return 0;
}
std::expected的真正威力在于monadic组合。C++23为它提供了and_then、or_else、transform三个组合子,让链式调用不必每步都手动拆包:
1
2
3
4
5
6
7
8
9
10
11
12
13
14 // 三个可能失败的步骤串联,任一失败则短路
std::expected<std::string, ParseError>
parse_and_normalize(const std::string& input) {
return parse(input) // expected<Json, ParseError>
.and_then(validate) // expected<Json, ParseError>
.transform(normalize) // expected<Json, ParseError>
.transform([](const Json& j) { return j.dump(); });
}
// or_else 提供错误恢复路径
auto r = load_config(path)
.or_else([](auto e) {
return std::expected<Config, Error>{default_config()};
});
与异常相比,std::expected的优势在于:错误路径是零开销的(无unwinding),调用者无法静默忽略错误(编译期类型强制)。劣势是:错误需要在每一层显式传播,深层调用栈中会出现大量”if (!result) return unexpected(…)”的样板。这正是monadic组合试图缓解的痛点。
五、std::variant与std::visit:类型安全的错误分支
当错误类型不止一种、且希望按错误类型分发处理逻辑时,std::variant配合std::visit是比异常更精确的工具。它通过”闭集类型”(sum type)在编译期穷举所有错误分支,编译器会强制你处理每一种情况。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33 #include <variant>
#include <string>
#include <iostream>
struct NetworkError { std::string msg; };
struct AuthError { int code; };
struct NotFound { std::string key; };
using FetchResult = std::variant<std::string, NetworkError, AuthError, NotFound>;
FetchResult fetch(const std::string& url);
// 用 visitor 穷举处理每种情况
struct Visitor {
void operator()(const std::string& body) const {
std::cout << "OK: " << body << '\n';
}
void operator()(const NetworkError& e) const {
std::cout << "network: " << e.msg << '\n';
}
void operator()(const AuthError& e) const {
std::cout << "auth code: " << e.code << '\n';
}
void operator()(const NotFound& e) const {
std::cout << "not found: " << e.key << '\n';
}
};
int main() {
auto r = fetch("https://api.example.com");
std::visit(Visitor{}, r);
return 0;
}
若使用generic lambda配合overloaded模式,代码可以更紧凑:
1
2
3
4
5
6
7
8
9
10
11
12 // C++20 简化版:用 lambda + 模板调用
auto result = fetch(url);
std::visit([](const auto& v) {
using T = std::decay_t<decltype(v)>;
if constexpr (std::is_same_v<T, std::string>) {
std::cout << "body: " << v << '\n';
} else if constexpr (std::is_same_v<T, NetworkError>) {
log_network(v.msg);
} else {
std::cout << "error\n";
}
}, result);
std::variant的优势在于:错误分支在类型层面”被穷举”,新增一种错误类型时编译器会立刻在所有visit点报错,迫使你补全处理逻辑。这是异常做不到的——异常的catch分支是可选的,新增异常类型不会触发任何编译警告。
六、noexcept与移动语义
C++11引入noexcept的目的之一是优化移动语义。标准容器在扩容时,若元素类型的移动构造是noexcept,容器会用移动;否则为强保证退回到拷贝。这意味着,如果你的移动构造没标noexcept但实际不抛,std::vector扩容时会默默走拷贝路径,性能白白损失。
1
2
3
4
5
6
7
8
9
10
11
12
13
14 class Buffer {
public:
// 正确:移动构造标记 noexcept,容器扩容时优先移动
Buffer(Buffer&& other) noexcept
: data_(other.data_), size_(other.size_) {
other.data_ = nullptr;
other.size_ = 0;
}
// 错误:若不标 noexcept,vector 扩容会退化为拷贝
// Buffer(Buffer&& other) { ... }
private:
char* data_;
size_t size_;
};
验证方式:static_assert(std::is_nothrow_move_constructible_v<Buffer>)。在大型代码库中,用static_assert批量断言关键类型的noexcept属性,能在编译期暴露因漏标导致的性能回归。
七、大型工程的错误处理策略
在几十万行的工程中,错误处理混乱的代价极高。一套可维护的策略需要明确分层与边界。
1. 错误类型分层
区分”编程错误”(bug,如越界、空指针解引用)与”运行时错误”(外部因素,如IO失败、网络超时)。前者用assert或std::terminate快速失败,不应通过异常传播;后者用异常或expected在调用栈中传递。
2. 边界转换
在异常代码与noexcept代码、C API与C++ API、第三方库与本系统之间,设立薄适配层做错误形式转换。例如把C API的errno统一映射为std::system_error抛出:
1
2
3
4
5
6
7
8
9 #include <system_error>
void posix_check(int ret) {
if (ret < 0) {
throw std::system_error(errno, std::system_category());
}
}
int fd = posix_check(::open(path, O_RDONLY));
3. 异常类型设计
建立一个有层次结构的异常基类,而非用std::runtime_error打天下。每层附带可机器解析的字段(错误码、上下文ID、原始cause),便于顶层统一日志和告警。
1
2
3
4
5
6
7
8
9
10
11
12
13 class AppException : public std::runtime_error {
public:
AppException(const std::string& msg, int code, std::string ctx)
: std::runtime_error(msg), code_(code), ctx_(std::move(ctx)) {}
int code() const noexcept { return code_; }
const std::string& context() const noexcept { return ctx_; }
private:
int code_;
std::string ctx_;
};
class NetworkException : public AppException { /* ... */ };
class ParseException : public AppException { /* ... */ };
4. 顶层捕获策略
每个执行入口(main、线程函数、异步任务)必须有顶层try-catch,将未捕获异常转为日志并标记任务失败,而非让进程崩溃。注意std::exception_ptr可以跨线程边界传递异常,适合在future/线程池中传递失败:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17 std::exception_ptr g_pending_error;
void worker() {
try {
do_work();
} catch (...) {
g_pending_error = std::current_exception();
}
}
// 主线程检查并重抛
if (g_pending_error) {
try { std::rethrow_exception(g_pending_error); }
catch (const std::exception& e) {
std::cerr << "async failed: " << e.what() << '\n';
}
}
八、常见反模式与排查
- 用异常做控制流:把异常当goto用,性能差且破坏可读性。异常应保留给”真正的异常”。
- catch(…)吞掉所有异常:调试时极难定位。至少记录what()再决定是否重抛。
- 构造函数中部分初始化后抛异常:对象处于半初始化状态,外部拿到的是无效对象。应让构造要么完全成功要么完全失败,或用两阶段构造(init()方法)。
- 移动后使用源对象:移动语义未标noexcept导致容器退化为拷贝;或移动后忘记置空源指针导致double-free。
- 析构函数抛异常:栈展开期间二次抛异常直接terminate。所有析构必须捕获并吞掉。
排查工具方面,GCC/Clang的-fno-exceptions可在编译期验证代码不依赖异常;AddressSanitizer能捕获移动后使用、double-free;valgrind的–tool=helgrind有助于多线程异常场景的资源泄漏定位。
九、性能与开销量化
异常的开销分两部分:抛出时和未抛出时。未抛出路径在现代编译器下几乎是零开销(Itanium ABI的zero-cost exception),但二进制体积会增加(unwind表)。抛出路径昂贵——一次throw的开销在微秒级,远高于一次函数返回。因此,不要在热循环中用异常处理”预期的失败”。
| 场景 | 建议机制 | 理由 |
|---|---|---|
| 热循环中解析失败 | std::expected | 避免每次迭代的throw开销 |
| 初始化阶段文件读取失败 | 异常 | 低频、自动传播、与RAII配合 |
| 网络请求超时 | 异常或expected | 低频,按工程统一选择 |
| 容器at越界 | 异常(std::out_of_range) | 属编程错误,应快速暴露 |
| 嵌入式实时系统 | error_code + -fno-exceptions | 硬实时要求禁止unwinding抖动 |
十、迁移路径建议
对于已有大量异常代码的工程,全面迁移到std::expected代价过高,可采取渐进策略:在新模块用expected,旧模块保留异常,边界处用薄适配层互转。注意expected不要滥用——若一个函数”几乎总是失败”,它不该用expected而应重新设计签名。
对于新项目,建议从第一天就确定主范式:网络/IO密集型且无实时约束,用异常;嵌入式/游戏引擎/高频交易,禁异常并用expected或自定义Result。无论哪种选择,配套的RAII、noexcept标注、异常安全分级、顶层捕获框架都应同步建立——这些是不依赖范式选择的工程基线。
错误处理是C++工程素养的试金石。掌握异常安全保证、RAII、std::expected与std::variant的组合运用,以及noexcept对移动语义的影响,你就能在性能与健壮性之间做出精确权衡,写出经得起生产环境考验的代码。
汤不热吧