1. 项目概述:为什么C++程序员必须掌握异常安全与错误处理?
在C++的世界里摸爬滚打了十几年,我见过太多因为错误处理不当而导致的“惨案”:内存泄漏、数据损坏、程序在用户面前莫名其妙地崩溃,甚至更糟——悄无声息地给出错误结果。很多从其他语言转过来的开发者,尤其是习惯了Java或C#那种“异常无处不在”环境的程序员,初入C++时往往会低估这门语言在错误处理上的复杂性和重要性。他们可能会想:“不就是try和catch吗?有什么难的?” 但C++的异常机制,远不止语法糖那么简单,它背后牵涉到资源管理、对象生命周期、性能开销以及最重要的——异常安全。
“异常安全”这个词,听起来有点学术,但它本质上是一个承诺:当你的代码因为异常而“飞”出当前执行路径时,它不会把系统搞成一团糟。不会留下半开半闭的文件,不会泄露一块分配的内存,也不会让某个核心数据结构处于不一致的中间状态。这是构建健壮、可靠、可维护的C++系统的基石。无论是开发一个高并发的服务器,还是一个对稳定性要求极高的桌面应用,甚至是嵌入式系统,异常安全都是你必须跨越的一道坎。这篇文章,我们就来彻底拆解C++的异常安全与错误处理,不讲虚的,只聊那些我踩过坑、流过汗才总结出来的实战经验和设计原则。
2. 异常安全的核心:三个级别的保证
在深入代码之前,我们必须先统一思想:什么是好的异常安全?C++社区通常用三个级别的保证来衡量一段代码的异常安全性,理解它们是设计任何可能抛出异常的代码的前提。
2.1 基本保证:不破坏不变量,不泄露资源
这是最低要求,但也是最基本、必须做到的底线。基本保证承诺:如果操作因异常而中断,程序的所有对象仍处于有效(尽管不一定是可预测的)状态,并且没有资源(如内存、文件句柄、锁)被泄露。
听起来简单,做起来却处处是坑。举个例子,一个看似简单的“交换两个对象”的操作:
template <typename T> void swap_bad(T& a, T& b) { T temp = a; // 可能抛出异常(如拷贝构造函数) a = b; // 可能抛出异常(如拷贝赋值运算符) b = temp; // 可能抛出异常(如拷贝赋值运算符) }如果a = b这一步抛出了异常,那么a的内容可能已经被部分覆盖,处于损坏状态,而temp和b的原始值也都丢失了。这违反了基本保证。一个提供基本保证的swap通常利用std::swap,它针对许多类型有特化实现,或者使用“拷贝并交换” idiom。
实操心得:确保基本保证的关键是RAII(资源获取即初始化)。任何资源(动态内存、文件、网络连接、锁)的获取都应该封装在一个对象中,该对象的析构函数负责释放资源。这样,无论控制流如何因异常离开作用域,栈回滚都会自动调用析构函数,完成清理。std::unique_ptr,std::shared_ptr,std::lock_guard,std::fstream等都是RAII的典范。
2.2 强保证:操作要么完全成功,要么完全回滚
强保证,也叫“提交或回滚”语义。它承诺:如果操作因异常而失败,程序的状态将完全回滚到操作开始之前,就像这个操作从未执行过一样。这对于需要保持数据一致性的操作至关重要,比如数据库事务或修改复杂数据结构。
实现强保证通常比基本保证更困难。一个经典例子是向一个std::vector中插入多个元素:
std::vector<MyClass> vec; // ... 假设vec已有一些元素 try { vec.push_back(obj1); // (1) vec.push_back(obj2); // (2) 可能抛出异常(如内存分配失败) } catch (...) { // 如果(2)失败,vec中已经多了一个obj1!状态被改变了。 }上面的代码只提供了基本保证。为了实现强保证,我们可能需要使用“拷贝-交换”技术,或者利用标准库提供的“强异常安全”操作(如std::vector::insert的单元素版本,在C++11后通常提供强保证)。
注意事项:提供强保证往往意味着性能开销,因为你需要先在一个“临时副本”上完成所有可能失败的操作,确认全部成功后,再通过一个不会失败的操作(如swap)来提交更改。在设计接口时,需要权衡强保证的必要性和性能成本。
2.3 不抛掷保证:承诺绝不抛出异常
不抛掷保证是最高级别的异常安全,它承诺一个操作永远不会抛出异常。这对于析构函数、内存释放函数(operator delete)和交换操作等关键函数尤其重要。在C++11及以后,我们可以用noexcept关键字来显式声明和检查这一点。
为什么析构函数最好不抛出异常?想象一下,在栈回滚处理一个异常的过程中,另一个析构函数又抛出了异常,程序会立刻调用std::terminate终止,这通常是灾难性的。因此,C++标准库中所有类型的析构函数都是noexcept的。
class MyResourceHolder { public: ~MyResourceHolder() noexcept { // 强烈建议加上noexcept // 释放资源,这里绝不能抛出异常! // 如果清理操作可能失败,必须在内部处理掉异常。 try { cleanup(); } catch (...) { // 记录日志,但绝不能再次抛出 std::cerr << "Cleanup failed, but suppressing exception.\n"; } } private: void cleanup() { /* ... */ } };核心原则:如果你不能提供强保证,至少提供基本保证。如果你能提供不抛掷保证,就用noexcept明确声明它,这不仅是一种承诺,也能帮助编译器进行更好的优化(例如,std::vector在移动元素时会使用noexcept移动构造函数,否则会回退到拷贝)。
3. 实现异常安全的关键技术与设计模式
知道了目标,我们来看看实现这些保证的具体武器。这些技术和模式是我在多年项目实践中反复验证过的“法宝”。
3.1 RAII:异常安全的基石
RAII我已经提过多次,但它的重要性怎么强调都不为过。它的核心思想是:将资源的生命周期与对象的生命周期绑定。资源在构造函数中获取,在析构函数中释放。由于C++保证了析构函数的调用(无论是正常离开作用域还是因为异常栈回滚),资源泄漏就被从根本上杜绝了。
看一个反面教材——手动管理资源:
void processFile(const char* filename) { FILE* f = fopen(filename, "r"); if (!f) { /* 处理错误 */ return; } // ... 一些可能抛出异常的操作 fclose(f); // 如果上面抛异常,这行不会执行,文件句柄泄漏! }使用RAII包装器(这里用C++风格的文件流):
void processFile(const std::string& filename) { std::ifstream file(filename); if (!file.is_open()) { /* 处理错误 */ return; } // ... 一些可能抛出异常的操作 // 无论是否异常,file对象离开作用域时,析构函数会自动关闭文件。 }经验之谈:对于任何非栈上的资源,你的第一反应就应该是“找一个RAII包装类”。标准库提供了绝大多数常用资源的包装(智能指针、容器、文件流、线程、锁)。如果没有,你应该自己编写一个简单的RAII包装类。这是一个投入极小但回报巨大的好习惯。
3.2 拷贝并交换(Copy-and-Swap)惯用法
这是实现强保证和异常安全赋值运算符的“瑞士军刀”。其核心是:先在一个局部副本上完成所有可能失败的工作,然后用一个不会失败的操作(交换)来提交更改。
假设我们有一个管理动态数组的类MyArray:
class MyArray { public: // ... 构造函数、析构函数等 MyArray& operator=(const MyArray& other) { if (this != &other) { // 传统写法:风险高,不提供强保证 // delete[] data_; // 如果这里抛异常?实际上delete不会,但... // data_ = new int[other.size_]; // 可能抛出bad_alloc // std::copy(...); // 可能抛出(如果元素类型的拷贝构造抛异常) // 拷贝并交换写法:提供强保证 MyArray temp(other); // 1. 用other构造一个临时副本(可能抛异常) swap(temp); // 2. 与*this交换(不会抛异常,我们保证swap是noexcept的) } // 3. temp离开作用域,清理旧资源 return *this; } void swap(MyArray& other) noexcept { using std::swap; swap(data_, other.data_); swap(size_, other.size_); } private: int* data_; std::size_t size_; }; // 非成员函数swap,用于支持ADL和通用算法 void swap(MyArray& a, MyArray& b) noexcept { a.swap(b); }为什么它能提供强保证?
- 构造
temp:如果失败,异常会直接抛出,*this的状态完全没有被触动。 - 交换
temp和*this:我们确保swap是noexcept的。这一步总是成功,瞬间将新数据提交给*this,旧数据转移到temp。 temp析构:清理旧的资源。即使析构失败(我们已确保它noexcept),也与*this的新状态无关了。
注意事项:拷贝并交换 idiom 可能会因为额外的拷贝构造和析构带来性能开销。在C++11以后,通常还会同时实现移动赋值运算符,对于可移动的类型,移动赋值通常更高效。
3.3 资源所有权转移与“空状态”
有时,我们无法在失败时回滚所有操作。例如,一个函数需要获取多个资源(如打开两个文件、连接两个网络服务)。如果第二个资源获取失败,第一个资源已经获取成功且无法“取消获取”。
一种策略是定义对象的“空状态”或“默认构造状态”。在构造函数中,按顺序获取资源。如果中途失败,则在抛出异常前,清理所有已获取的资源,并将对象置于一个有效的“空状态”。这至少提供了基本保证。
class DualConnection { public: DualConnection(const std::string& host1, const std::string& host2) : conn1_(nullptr), conn2_(nullptr) { // 初始化为“空” conn1_ = connectTo(host1); // 可能失败 try { conn2_ = connectTo(host2); // 可能失败 } catch (...) { disconnect(conn1_); // 获取conn2失败,清理conn1 throw; // 重新抛出异常 } } ~DualConnection() { disconnect(conn2_); disconnect(conn1_); } bool isValid() const { return conn1_ && conn2_; } private: Connection* conn1_; Connection* conn2_; };关键点:在构造函数中,如果发生异常,已经完全构造好的子对象(成员变量、基类部分)的析构函数会被调用。但对于原始指针这类“非对象”资源,你需要手动在catch块中清理。更好的做法仍然是使用RAII对象作为成员(如std::unique_ptr<Connection>),让它们的析构函数自动处理。
4. 异常中立的代码编写实践
“异常中立”是指你的代码本身不直接处理异常,但能正确地传播调用者或下层代码抛出的异常,同时保证自身的异常安全。编写异常中立的代码是构建大型、模块化系统的基础。
4.1 标准库容器的异常安全
了解你使用的工具的异常安全保证至关重要。C++标准库容器对其大部分操作都提供了明确的异常安全保证。例如:
std::vector::push_back(在C++11后):提供强保证(如果元素类型的拷贝/移动构造函数不抛出异常,或者移动构造函数是noexcept的)。std::vector::insert(单元素):通常提供强保证。std::map::insert:提供强保证(如果比较操作不抛出异常)。- 所有容器的析构函数:提供不抛掷保证。
- 所有容器的
swap成员函数:提供不抛掷保证。
重要提示:像std::vector这样的容器,其异常安全依赖于元素类型的操作。如果你在容器中存放了异常不安全的类型,那么容器的异常安全保证也会被破坏。例如,一个类型在拷贝赋值中先释放资源再分配新资源,如果分配失败抛出异常,对象就处于资源已释放但指针未置空的状态,这是灾难性的。
4.2 在析构函数和swap中禁止抛出异常
这值得再次强调,并给出更具体的指导:
- 析构函数:必须处理所有可能的异常,绝不能让其传播到析构函数之外。通常的做法是在可能抛出异常的操作周围加上
try...catch(...)块,并在catch块中记录错误或直接终止程序(如果错误不可恢复),但绝不再次抛出。 swap函数:应该设计为noexcept。交换操作通常只涉及交换指针、整数等内置类型的值,这些操作不会抛出异常。将其声明为noexcept能使标准库算法(如std::sort)和容器(如std::vector在重新分配时)使用更高效的移动或交换操作。
class MyType { public: ~MyType() noexcept { // 声明为noexcept try { releaseResource(); } catch (...) { // 严重错误,但必须抑制。可以记录日志。 std::abort(); // 或者在关键系统中,这是最后的手段。 } } void swap(MyType& other) noexcept { // 关键! using std::swap; swap(dataPtr_, other.dataPtr_); swap(size_, other.size_); } private: Resource* dataPtr_; std::size_t size_; };4.3 使用智能指针管理动态资源
手动new和delete是异常安全的最大敌人之一。在可能抛出异常的代码路径之间,一个new成功了,但后面的delete可能因为异常而无法执行。
解决方案:几乎总是使用std::unique_ptr或std::shared_ptr。
// 不安全 void unsafe() { MyClass* obj = new MyClass; someFunctionThatMayThrow(); // 如果这里抛异常,obj泄漏! delete obj; } // 安全 void safe() { auto obj = std::make_unique<MyClass>(); // C++14起,更安全高效 someFunctionThatMayThrow(); // 如果抛异常,obj的析构函数会被调用,内存自动释放。 }std::make_unique和std::make_shared不仅避免了显式的new,还提供了更强的异常安全。考虑这个有问题的构造:foo(std::unique_ptr<MyClass>(new MyClass), someFunctionThatMayThrow());编译器可能以任意顺序求值参数。如果先执行new MyClass,然后someFunctionThatMayThrow()抛出异常,那么new出来的内存就会泄漏。使用make_unique可以避免这个问题,因为参数的求值发生在make_unique函数内部,资源在创建后立即被智能指针管理。
5. 异常与错误码的抉择:何时用?怎么选?
C++同时支持异常和错误码两种错误处理机制。选择哪一种,常常让开发者纠结。我的经验法则是:用异常处理“异常”情况,用错误码处理“预期”错误。
5.1 异常的优势与适用场景
异常适用于那些不经常发生、且一旦发生,正常执行路径就无法继续的错误。
- 资源不足:内存分配失败(
std::bad_alloc)、磁盘已满。 - 逻辑错误:传入无效参数(
std::invalid_argument)、越界访问(std::out_of_range)。 - 运行时环境问题:文件未找到、网络连接断开。
- 跨越多个调用栈的错误传播:在深层嵌套的函数调用中发生的错误,需要传递到上层统一处理。
异常的核心优势在于分离错误处理代码和正常业务逻辑。你可以在一个较高的层次集中处理多种错误,而不需要在每一层函数调用后都检查返回值。
void processTransaction() { try { auto conn = openDatabaseConnection(); // 可能抛异常 auto data = fetchData(conn); // 可能抛异常 auto result = complexCalculation(data); // 可能抛异常 commitTransaction(conn, result); // 可能抛异常 } catch (const DatabaseException& e) { logError("Database error: ", e.what()); rollbackIfNeeded(); } catch (const std::exception& e) { logError("Standard exception: ", e.what()); // 处理其他标准异常 } catch (...) { logError("Unknown exception occurred."); // 处理未知异常 } }5.2 错误码的优势与适用场景
错误码(通常通过返回值、输出参数或std::error_code)适用于那些频繁发生、且是操作正常流程一部分的情况。
- 解析用户输入:格式错误很常见,不是异常。
- 查找操作:在容器中查找一个元素,没找到是正常情况。
- 非阻塞操作的结果:如“尝试获取锁失败”。
- 性能至关重要的底层循环:在紧密循环中,异常的抛出和捕获开销可能成为瓶颈。
- 与C语言或没有异常机制的代码交互。
C++11引入了<system_error>库,提供了std::error_code和std::error_condition,用于创建和传递可移植、可扩展的错误码,这是比使用原始整数或枚举更好的方式。
std::error_code openFile(const std::string& path, std::ifstream& file) { file.open(path); if (!file.is_open()) { return std::make_error_code(std::errc::no_such_file_or_directory); } return {}; // 默认构造的error_code表示“无错误” } void userFunction() { std::ifstream f; if (auto ec = openFile("data.txt", f)) { // 预期内的错误,进行本地处理 std::cerr << "Could not open file: " << ec.message() << "\n"; return; } // 正常流程继续... }5.3 混合使用与边界划定
在实际项目中,常常是混合使用的。一个通用的原则是:在模块或库的边界上明确你的错误处理策略。例如:
- 一个底层网络库可能使用错误码报告连接超时、数据包校验失败等常见错误。
- 一个使用该网络库的业务逻辑层,在遇到“连接被拒绝”的错误码时,可能会选择重试几次,如果最终失败,则抛出一个
NetworkUnavailableException给更上层的应用逻辑。 - 应用层捕获这个异常,可能向用户显示一个友好的错误提示。
绝对要避免的是:在同一个函数或紧密耦合的代码块中,对同一种错误既使用错误码又使用异常,这会让代码的逻辑变得极其混乱。
6. 实战中的常见陷阱与排查技巧
理论说再多,不如看看实际中容易栽跟头的地方。下面是我总结的几个典型陷阱和对应的解决思路。
6.1 陷阱一:构造函数中的异常与资源泄漏
这是新手最容易出问题的地方。构造函数如果中途抛出异常,那么该对象的析构函数不会被调用(因为对象被认为没有完全构造成功)。但是,所有已经构造完毕的成员子对象和基类子对象的析构函数会被调用。
class Problematic { public: Problematic() : ptr1(new int(42)), ptr2(new int(100)) { // 如果第二个new失败,ptr1指向的内存会泄漏! // 因为Problematic的析构函数不会运行,而int是内置类型,没有析构函数来释放它。 } ~Problematic() { delete ptr1; delete ptr2; } private: int* ptr1; int* ptr2; };解决方案:
- 使用成员初始化列表,并让成员自己管理资源(RAII)。
class Solution1 { std::unique_ptr<int> ptr1; std::unique_ptr<int> ptr2; public: Solution1() : ptr1(std::make_unique<int>(42)), ptr2(std::make_unique<int>(100)) { // 如果第二个make_unique失败,ptr1会被正确销毁。 } // 无需自定义析构函数! }; - 如果必须使用原始资源,在构造函数体内用try-catch块手动清理。
class Solution2 { int* ptr1; int* ptr2; public: Solution2() : ptr1(nullptr), ptr2(nullptr) { ptr1 = new int(42); try { ptr2 = new int(100); } catch (...) { delete ptr1; // 清理已分配的资源 throw; // 重新抛出异常 } } ~Solution2() { delete ptr1; delete ptr2; } };
显然,方案1(使用智能指针)更简洁、更安全,是首选。
6.2 陷阱二:异常与析构函数交互导致的“双重异常”
如果析构函数在栈回滚(处理异常)的过程中被调用,并且这个析构函数又抛出了新的异常,C++运行时就会调用std::terminate直接终止程序。这通常意味着资源可能无法被正确清理。
class Dangerous { public: ~Dangerous() { cleanup(); // 假设cleanup可能抛出异常 } private: void cleanup() { /* ... 可能失败的操作 ... */ } }; void foo() { Dangerous d; throw std::runtime_error("Something went wrong!"); // 栈回滚开始,调用d.~Dangerous() // 如果cleanup()抛出异常,程序立即终止! }解决方案:析构函数必须吞下所有异常。
class Safe { public: ~Safe() noexcept { // 声明为noexcept是良好的实践 try { cleanup(); } catch (...) { // 记录错误日志,但绝不能抛出! // std::cerr << "Destructor cleanup failed.\n"; // 或者调用std::abort(),如果这是不可接受的错误。 } } private: void cleanup() { /* ... */ } };6.3 陷阱三:异常规格(Exception Specifications)的误用
C++11之前有动态异常规格(throw(typeid)),C++11引入了noexcept。动态异常规格已被弃用,不应再使用。noexcept是一个有力的工具,但要用对地方。
- 错误用法:给可能抛出异常的函数声明
noexcept。这会导致std::terminate被调用,让调试变得困难。void mightThrow() noexcept { // 危险! // ... 一些操作 throw std::runtime_error("Oops"); // 程序直接终止! } - 正确用法:只对那些你确定不会抛出异常的函数使用
noexcept。这包括简单的getter/setter、交换函数、移动操作(如果它们不抛出)、析构函数。noexcept能帮助编译器优化,并允许标准库在特定场景下使用更高效的算法。void swap(MyType& a, MyType& b) noexcept { a.swap(b); } MyType::MyType(MyType&& other) noexcept = default; // 如果移动成员都是noexcept的
排查技巧:当程序意外调用std::terminate时,除了访问空指针等硬件异常,一个常见原因就是noexcept函数抛出了异常。使用调试器查看调用栈,找到触发终止的函数,检查其是否被错误地标记为noexcept。
6.4 陷阱四:异常安全与多线程
在多线程环境中,异常安全变得更加复杂。一个线程抛出的异常不能直接被另一个线程捕获。核心问题在于数据竞争和状态一致性。
- 基本场景:一个线程在修改共享数据时抛出异常,可能导致该数据处于不一致的状态,而其他线程可能正在读取或写入它。
- 锁的释放:这是RAII再次闪耀的地方。使用
std::lock_guard或std::unique_lock来管理互斥锁。无论正常返回还是异常退出,锁都会在栈回滚时被自动释放,避免死锁。std::mutex g_mutex; void threadSafeFunction() { std::lock_guard<std::mutex> lock(g_mutex); // 获取锁 // 对共享数据进行操作,这里可能抛出异常 modifySharedData(); } // lock离开作用域,锁被自动释放,即使modifySharedData抛异常。 - 承诺与原子性:在设计线程安全的接口时,要考虑每个操作提供的异常安全保证。例如,一个线程安全的
push操作应该提供强保证:要么元素被成功加入,要么队列状态完全不变。这通常需要精心设计内部数据结构和锁的粒度。
经验总结:在多线程代码中,将RAII用于锁管理是强制性的。对于复杂的数据结构操作,考虑使用“写时复制”或“事务性内存”等模式来简化强保证的实现。同时,线程间传递错误信息通常不直接使用异常,而是通过std::future、std::promise或线程安全的队列来传递错误码或异常对象(std::exception_ptr)。
7. 现代C++中的错误处理新趋势
C++标准在不断发展,社区也在探索错误处理的新方式。了解这些趋势有助于你写出更现代的代码。
7.1noexcept运算符与条件性异常规范
noexcept不仅可以作为函数说明符,还可以作为一个运算符,在编译期判断一个表达式是否可能抛出异常。这常用于模板元编程和条件性移动优化。
template <typename T> void swap(T& a, T& b) noexcept(noexcept(a.swap(b))) { a.swap(b); }上面的noexcept说明符是条件性的:仅当a.swap(b)这个表达式被判定为noexcept时,swap函数才是noexcept的。这允许标准库的std::swap等算法根据类型的实际属性选择最优的实现。
7.2std::optional与std::variant作为错误处理的轻量级替代
对于那些“可能有结果,可能没有”的场景(如查找、解析),使用std::optional比抛出异常或返回特殊错误码更清晰。
std::optional<int> findValue(const std::vector<int>& vec, int target) { auto it = std::find(vec.begin(), vec.end(), target); if (it != vec.end()) { return *it; } return std::nullopt; // 表示“没找到”,而不是错误 } void user() { if (auto val = findValue(myVec, 42)) { std::cout << "Found: " << *val << '\n'; } else { std::cout << "Not found.\n"; // 这是正常流程的一部分 } }std::variant可以用于返回多个可能类型中的一个,常用于返回一个成功值或一个错误对象,类似于其他语言中的Result或Either类型。C++23的std::expected正是为了这个目的而设计的。
7.3 协程(Coroutines)中的异常处理
C++20引入了协程。在协程中,异常可以跨挂起点传播,其机制与普通函数类似,但需要理解“承诺类型”的unhandled_exception成员函数。协程框架会捕获协程体内未处理的异常,并通过承诺对象传递给等待它的代码。
generator<int> myCoroutine() { co_yield 1; throw std::runtime_error("Error in coroutine"); co_yield 2; // 不会执行 } void caller() { auto gen = myCoroutine(); try { std::cout << gen.next() << '\n'; // 输出1 gen.next(); // 这里会抛出在协程中抛出的runtime_error } catch (const std::exception& e) { std::cout << "Caught: " << e.what() << '\n'; } }编写异常安全的协程,同样需要遵循RAII原则,确保在异常导致协程销毁时,所有持有的资源能被正确释放。
8. 设计异常安全代码的检查清单
在提交代码或进行评审前,可以对照这个清单快速检查你的代码是否具备良好的异常安全性:
- 资源管理:所有动态分配的资源(内存、文件、句柄、锁)是否都由RAII对象(智能指针、文件流、锁守卫)管理?
- 构造函数:如果构造函数可能失败,它是否保证了基本保证?已分配的资源在异常抛出前是否被正确清理?是否优先使用成员初始化列表和RAII成员?
- 赋值运算符:是否使用了拷贝并交换 idiom 来提供强保证?
swap操作是否被声明为noexcept? - 析构函数:析构函数是否被声明为
noexcept?它是否吞下了所有可能抛出的异常? - 异常声明:函数是否被正确标记了
noexcept(仅当它确实不抛异常时)?是否避免了已弃用的动态异常规格(throw(...))? - 错误传播:在深层嵌套的调用中,是使用异常还是错误码?这个选择在模块边界是否一致?
- 标准库使用:是否了解所使用的标准库操作(如
vector::push_back)的异常安全保证?容器中存储的类型是否是异常安全的? - 多线程:锁是否由RAII对象管理?异常是否会影响其他线程观察到的共享数据状态?
- 错误处理:
catch块是仅仅捕获了异常然后又原样抛出(没有做任何清理或上下文添加),还是进行了有意义的错误处理和恢复?是否避免了捕获所有异常(catch(...))而不重新抛出的情况(除非在最顶层)?
最后,记住没有银弹。异常安全是C++编程中一项需要持续关注和练习的技能。最好的学习方式就是去写代码,去故意制造异常,然后用调试器观察栈回滚和析构函数的调用顺序,去分析内存泄漏报告。当你对try块中某个操作抛出异常后,哪些对象会被销毁、按什么顺序销毁有了肌肉记忆时,你才算真正掌握了这门技艺。