1. 项目概述:为什么C++程序员必须掌握异常处理?
干了这么多年C++,我见过太多因为异常处理不当而导致的程序崩溃、内存泄漏和数据损坏。很多刚入行的朋友,甚至一些有几年经验的开发者,对C++的异常处理机制都停留在“知道有try-catch这么个东西”的层面,真到了项目里,要么不敢用,要么用错了地方,结果就是代码里充斥着大量的错误码检查和if-else,逻辑支离破碎,维护起来简直是噩梦。
C++的异常处理,特别是try-catch机制,绝不仅仅是用来“抓崩溃”的。它是一种结构化的、将正常业务逻辑与错误处理逻辑分离的编程范式。想象一下,你写一个函数要从文件读取数据、解析、然后存入数据库。如果没有异常,你的代码流程是线性的、清晰的。但每一步都可能出错:文件打不开、数据格式不对、数据库连接失败。如果用传统的错误码,你的函数里会塞满if (ret != SUCCESS) { ... }的判断,真正的业务逻辑反而被淹没了。而异常机制允许你在任何一层函数中“抛出”(throw)一个错误信号,然后在一个集中的地方(catch块)进行统一处理,这样主流程的代码就干净多了。
最近在社区里,我看到很多关于“C++八股文”的讨论,异常处理几乎是面试必问。但问来问去,很多人还是说不清std::exception基类有什么用、noexcept关键字该什么时候加、以及为什么说“异常安全”是编写健壮库代码的基石。更有甚者,因为一些历史项目或特定平台(比如某些嵌入式环境)默认禁用异常,导致大家对这套机制产生了疏离感。但在我看来,只要你开发的是运行在主流操作系统(Windows/Linux/macOS)上的应用程序、服务或者库,异常处理就是你武器库里不可或缺的一件利器。它能帮你写出更健壮、更清晰、更易于维护的代码。接下来,我就结合自己踩过的坑和积累的经验,把C++异常处理那点事掰开揉碎了讲清楚。
2. 异常处理的核心机制与语法深潜
2.1throw、try、catch:三板斧的协作原理
异常处理的核心就三个关键字:throw,try,catch。它们的分工非常明确。
throw:这是异常的“发起者”。当函数执行过程中遇到了无法或不应在当前位置处理的错误时,就用throw抛出一个异常对象。这个对象可以是任何类型(int,string,自定义类),但最佳实践是抛出自std::exception派生的类对象。
void connectToDatabase(const std::string& config) { if (!validateConfig(config)) { throw std::invalid_argument("Invalid database configuration string."); } // 尝试连接... if (connectionFailed) { throw std::runtime_error("Failed to establish database connection."); } }这里的关键是,throw语句一旦执行,当前函数的执行流会立刻终止,程序控制权会沿着调用栈向上回退(栈展开,stack unwinding),直到找到一个能处理该类型异常的catch块。
try:这是异常的“监控区”。你把可能抛出异常的代码块用try{}包裹起来。一个try块后面必须紧跟一个或多个catch块。
try { // 可能抛出异常的代码 loadUserProfile(userId); processOrder(order); updateInventory(); }catch:这是异常的“处理者”。每个catch块就像是一个专门处理特定类型“故障”的维修站。catch后面的括号里声明了它能捕获的异常类型。
catch (const std::invalid_argument& e) { std::cerr << "参数错误: " << e.what() << std::endl; // 处理参数错误的逻辑,比如返回默认值或提示用户 } catch (const std::runtime_error& e) { std::cerr << "运行时错误: " << e.what() << std::endl; // 处理运行时错误的逻辑,比如重试或记录日志 } catch (...) { // 捕获所有未被前面catch处理的异常 std::cerr << "发生了未知异常!" << std::endl; // 通常用于记录日志和程序终止前的清理 }程序会按catch块出现的顺序进行匹配。catch (...)是兜底选项,但要慎用,因为它会捕获所有异常,可能让你错过更具体的错误信息。
注意:
catch块中异常对象的捕获方式。我强烈建议使用常量引用(const std::exception&)。这避免了不必要的对象拷贝(异常对象可能包含动态分配的内存),同时声明为const表明你不会在处理过程中修改它,更安全。直接按值捕获(std::exception e)会产生切片问题(如果捕获的是派生类对象)和拷贝开销。
2.2 标准异常体系:<stdexcept>里的宝藏
C++标准库提供了一套定义在<stdexcept>头文件中的异常类,它们都继承自std::exception。理解这个家族图谱,是你用好异常的基础。
std::exception (基类,定义了虚函数 what()) ├── std::logic_error (逻辑错误,通常可预防) │ ├── std::invalid_argument │ ├── std::domain_error │ ├── std::length_error │ └── std::out_of_range └── std::runtime_error (运行时错误,通常难以预防) ├── std::range_error ├── std::overflow_error ├── std::underflow_error ├── std::system_error (C++11,包含错误码) └── ...std::logic_error一系:表示程序逻辑上的错误,理论上在编码阶段就能通过检查避免。比如给函数传了非法参数(invalid_argument)、访问容器越界(out_of_range)。这类异常是你应该积极抛出的,用于约束API的调用者。std::runtime_error一系:表示程序运行时发生的、难以在编码阶段预料的错误。比如文件不存在、网络断开、算术运算溢出。这类异常用于响应外部环境的不确定性。
为什么强调使用标准异常?
- 语义清晰:
throw std::invalid_argument(...)比throw std::string("Invalid arg")或throw -1传达了明确得多的错误类型。 - 多态处理:你可以用
catch (const std::exception& e)捕获所有标准异常,通过e.what()获取错误信息。这为统一的错误日志和处理提供了可能。 - 社区惯例:这是C++社区的通用语言,使用标准异常能让你的代码更容易被其他开发者理解和集成。
2.3 栈展开与资源管理:RAII是如何兜底的
这是异常处理中最精妙也最容易出错的部分。当异常被抛出,栈展开过程开始。局部对象(在栈上创建的对象)会按照构造相反的顺序被析构。这就是RAII(Resource Acquisition Is Initialization)理念发挥威力的地方。
RAII是异常安全的基石。它的核心思想是:将资源的生命周期绑定到一个局部对象的生命周期上。在构造函数中获取资源(如内存、文件句柄、锁),在析构函数中释放资源。这样,无论函数是正常返回还是因异常退出,只要局部对象离开其作用域,析构函数就会被调用,资源就能被安全释放。
看一个反面例子:
void badFunction() { int* ptr = new int[100]; // 资源获取 someOperationThatMayThrow(); // 可能抛出异常! delete[] ptr; // 如果上面抛异常,这行永远执行不到 -> 内存泄漏 }如果someOperationThatMayThrow()抛出异常,delete[] ptr不会被执行,导致内存泄漏。
使用RAII改造后(借助std::vector或std::unique_ptr):
void goodFunction() { std::vector<int> vec(100); // 资源获取在构造函数中 // 或者 std::unique_ptr<int[]> ptr(new int[100]); someOperationThatMayThrow(); // 可能抛出异常! } // 无论是否抛异常,vec(或ptr)的析构函数都会在这里被自动调用,释放内存。因为std::vector和std::unique_ptr的析构函数会自动管理内存,所以即使发生异常,内存泄漏也不会发生。这就是所谓的“基本异常安全保证”:资源不泄漏。
实操心得:在C++中,对于动态资源,永远优先使用智能指针(
std::unique_ptr,std::shared_ptr)和标准库容器(std::vector,std::string等),而不是裸的new/delete。这是避免因异常导致资源泄漏的最简单、最有效的方法。对于文件、锁等其他资源,使用对应的RAII包装类(如C++17的std::filesystem路径操作,或自定义的锁守卫std::lock_guard)。
3. 从入门到精通:异常处理实战策略
3.1 基础用法模式:如何组织你的try-catch
在实际项目中,try-catch的放置位置很有讲究,主要分为两种模式:
1. 集中处理模式(推荐在应用顶层使用):这种模式将try-catch放在一个很高的层次,比如main()函数,或者一个请求处理循环的顶层。它的目的是捕获所有未被处理的异常,进行最后的日志记录、错误上报或优雅降级,防止程序崩溃。
int main() { try { // 整个应用程序的核心逻辑 Application app; app.initialize(); app.run(); app.shutdown(); } catch (const std::exception& e) { // 集中记录所有标准异常 std::cerr << "Fatal error: " << e.what() << std::endl; logToFile(e.what()); return EXIT_FAILURE; } catch (...) { // 兜底,处理非标准异常 std::cerr << "Fatal error: Unknown exception." << std::endl; return EXIT_FAILURE; } return EXIT_SUCCESS; }这种模式保证了程序的健壮性,即使底层某个模块爆出未预期的异常,整个服务也不会无声无息地崩溃,而是能留下错误线索。
2. 局部恢复模式:在某个具体的操作可能失败,但你有一个合理的备选方案时使用。例如,从多个备用服务器读取配置,一个失败了就试下一个。
std::string loadConfig() { std::vector<std::string> servers = {"primary.conf", "backup.conf", "default.conf"}; for (const auto& server : servers) { try { return readFileFromNetwork(server); // 可能抛出 std::runtime_error } catch (const std::runtime_error& e) { std::cerr << "Failed to load config from " << server << ": " << e.what() << std::endl; // 继续尝试下一个 continue; } } // 所有备用方案都失败,抛出异常或返回硬编码默认值 throw std::runtime_error("All config servers failed."); }3. 资源清理边界模式:确保在资源持有对象的析构函数中绝不抛出异常。如果析构函数可能抛出异常(比如关闭文件失败),一定要在内部try-catch并吞掉或记录它。因为如果栈展开过程中析构函数又抛出异常,程序会直接调用std::terminate()终止,这是非常严重的。
class FileHandler { std::FILE* fp; public: ~FileHandler() noexcept { // C++11后,析构函数默认是noexcept的,这里显式声明更好 if (fp) { try { if (std::fclose(fp) != 0) { // 关闭失败,但我们在析构函数里不能抛异常 // 只能记录日志 perror("File close failed"); } } catch (...) { // 捕获所有异常,防止异常逃逸出析构函数 // 通常只记录日志,不做其他操作 std::cerr << "Unexpected error during file close." << std::endl; } } } };3.2 自定义异常类:打造你的错误语义体系
当标准异常不足以清晰表达你的错误时,就需要自定义异常类。一个好的自定义异常类能极大提升代码的可读性和可调试性。
设计要点:
- 必须公有继承自
std::exception或其派生类(如std::runtime_error)。这样才能被通用的catch (const std::exception&)捕获。 - 重写
what()方法,返回描述错误的C风格字符串。 - 利用构造函数初始化基类,将错误信息传递给基类存储。
- 可以添加额外的成员变量来携带更丰富的错误上下文(比如错误码、模块名、相关ID等)。
示例:一个简单的数据库异常类
#include <stdexcept> #include <string> class DatabaseException : public std::runtime_error { private: int errorCode_; std::string sqlState_; public: // 构造函数,初始化基类并保存额外信息 DatabaseException(const std::string& message, int errorCode, const std::string& sqlState) : std::runtime_error(message), errorCode_(errorCode), sqlState_(sqlState) {} // 获取错误码 int errorCode() const noexcept { return errorCode_; } // 获取SQL状态 const std::string& sqlState() const noexcept { return sqlState_; } // 可以重写what()以包含更多信息(注意:what()返回的指针必须在其对象生命周期内有效) // 通常直接使用基类的what()即可,它已经包含了构造时传入的message。 }; // 使用 void executeQuery(const std::string& sql) { // ... 执行数据库操作 if (queryFailed) { throw DatabaseException("Failed to execute query", 1064, "42000"); } } // 捕获 try { executeQuery("SELECT * FROM non_existent_table"); } catch (const DatabaseException& e) { std::cerr << "DB Error [" << e.sqlState() << "]: " << e.what() << " (Code: " << e.errorCode() << ")" << std::endl; // 可以根据errorCode进行更精细的错误处理 } catch (const std::exception& e) { // 其他标准异常 }通过自定义异常,错误处理从简单的字符串匹配升级为结构化的类型和状态判断,代码的健壮性和可维护性大大提高。
3.3noexcept关键字:性能与契约的权衡
C++11引入了noexcept关键字,它有两个主要作用:
- 声明函数不会抛出异常:
void myFunc() noexcept;这是一个对编译器和调用者的承诺。 - 运算符,判断表达式是否可能抛出异常:
bool isNoExcept = noexcept(myFunc());
为什么要用noexcept?
- 性能优化:编译器知道一个函数是
noexcept后,可以进行一些优化。例如,标准库容器(如std::vector)在移动元素时,如果移动构造函数是noexcept的,它会使用更高效的移动操作;否则,为了保持强异常安全保证,它可能会回退到拷贝操作。 - 接口契约:明确告知函数的调用者“我不会抛异常”,简化了调用方的错误处理逻辑。这对于析构函数、移动操作、交换(
swap)函数等至关重要。 - 程序终止:如果一个声明为
noexcept的函数内部抛出了异常,程序会直接调用std::terminate()终止,而不是展开栈。这用于那些绝对不能失败的关键函数。
使用指南:
- 析构函数:默认就是
noexcept的,除非你明确知道它可能抛异常(这通常是个坏设计),否则不要改变它。 - 移动构造函数/移动赋值运算符:如果它们确实不会抛异常,一定要加上
noexcept。这是对标准库容器的友好承诺,能提升性能。 - 交换(
swap)函数:通常也应该是noexcept的。 - 简单Getter/Setter、数学计算等显然不会失败的操作,可以声明为
noexcept。 - 对于其他函数,要谨慎。如果你不能100%确定函数及其调用的所有子函数都不会抛异常,就不要加
noexcept。错误的noexcept声明比不声明更危险,因为它会导致不可预期的程序终止。
class MyType { public: ~MyType() noexcept = default; // 好 MyType(MyType&& other) noexcept // 好,移动操作不抛异常 : data_(std::move(other.data_)) {} MyType& operator=(MyType&& other) noexcept { // 好 if (this != &other) { data_ = std::move(other.data_); } return *this; } void swap(MyType& other) noexcept { // 好 std::swap(data_, other.data_); } int getValue() const noexcept { // 好,简单的getter return value_; } // 谨慎!如果complexOperation可能抛异常,这就错了 // void risky() noexcept { complexOperation(); } };4. 异常安全等级与高级话题
4.1 理解异常安全保证:从弱到强的承诺
当你设计一个函数或类时,需要考虑它在面对异常时的行为。这被称为“异常安全保证”,通常分为几个等级:
- 无保证(No guarantee):如果发生异常,程序可能处于任何状态——资源泄漏、数据损坏、崩溃。这是最糟糕的情况,应极力避免。
- 基本保证(Basic guarantee):如果发生异常,程序状态仍然有效(即所有不变量仍然保持),但具体是哪个有效状态可能不确定。不会发生资源泄漏。这是大多数操作应该提供的最低安全保证。RAII是实现基本保证的关键。
- 强保证(Strong guarantee):如果操作因异常而失败,程序状态会完全回滚到操作调用之前的状态。就像这个操作从来没执行过一样。这通常通过“拷贝-交换”(copy-and-swap)惯用法来实现,但可能有性能开销。
- 不抛异常保证(Nothrow guarantee):操作保证永远不会抛出异常。这通常通过
noexcept声明。像析构函数、移动操作、swap函数都应追求此保证。
举例说明:假设我们有一个Widget类,它管理一个动态数组。
class Widget { int* data_; size_t size_; public: // 基本保证:如果new失败(抛bad_alloc),data_还是nullptr,size_为0,状态有效(空)。 void append(int value) { int* newData = new int[size_ + 1]; // 可能抛std::bad_alloc std::copy(data_, data_ + size_, newData); newData[size_] = value; delete[] data_; // 如果上面成功了,这里不会抛异常 data_ = newData; ++size_; } // 强保证:使用“拷贝-交换”惯用法 void appendStrong(int value) { Widget temp = *this; // 拷贝构造,可能抛异常,但*this不变 temp.append(value); // 在副本上操作,可能抛异常 swap(temp); // swap通常是noexcept的。如果成功,状态被原子性替换。 } void swap(Widget& other) noexcept { std::swap(data_, other.data_); std::swap(size_, other.size_); } };append提供了基本保证:如果new失败,原对象没变;如果new成功但后续操作失败(虽然这个简单例子没有),原对象也没变(因为我们在成功分配新内存后才修改原指针)。appendStrong通过先拷贝、在副本上操作、再交换的方式,提供了强保证:要么完全成功,要么完全不影响原对象。
4.2 异常与构造函数/析构函数的特殊关系
构造函数:如果构造函数中抛出异常,那么该对象的构造就被认为是失败的。已经构造完成的成员子对象会被自动析构(因为它们是完整的对象),但这个对象本身的析构函数不会被调用(因为它还没有完全构造成功)。这意味着,如果你在构造函数中手动获取了资源(比如用new分配了内存),并且在该资源被安全地交给某个成员对象(如智能指针)管理之前抛出了异常,就会导致资源泄漏。因此,构造函数的资源初始化最好使用成员初始化列表,并依赖成员对象自身的RAII管理。
class Problematic { int* ptr1; int* ptr2; public: Problematic() : ptr1(new int(42)) { // ptr1成功分配 ptr2 = new int(100); // 如果这里new失败,抛出std::bad_alloc // 那么ptr1指向的内存就泄漏了!因为Problematic的析构函数不会被调用。 } ~Problematic() { delete ptr1; delete ptr2; } }; class Solution { std::unique_ptr<int> ptr1; // 使用智能指针 std::unique_ptr<int> ptr2; public: Solution() : ptr1(std::make_unique<int>(42)), ptr2(std::make_unique<int>(100)) { // 如果make_unique失败(极罕见),已构造的ptr1会被正确析构,释放内存。 // 无需手动清理。 } // 无需自定义析构函数! };析构函数:如前所述,析构函数默认是noexcept的。在栈展开过程中,如果析构函数抛出异常,而当前已经有异常在传播(即处于stack unwinding状态),程序会立即调用std::terminate()终止。这就是为什么析构函数必须尽可能不抛异常,任何可能失败的操作都应在内部try-catch并处理掉。
4.3 异常处理的开销与禁用异常
异常处理机制确实会带来一些运行时开销,即使没有异常抛出。这些开销主要来自编译器需要生成额外的代码来跟踪栈上对象的生命周期,以便在异常发生时正确展开栈。这可能会轻微增加代码体积(通常称为“代码膨胀”)并在某些情况下影响性能。
因此,在一些对性能和体积有极端要求的场景下,如嵌入式系统、游戏引擎核心循环、高频交易系统等,开发者会选择禁用C++异常。这通常通过编译器标志实现(如GCC/Clang的-fno-exceptions,MSVC的/EHs-c-)。
禁用异常后的编程范式:
- 错误码(Error Codes):函数通过返回值或输出参数返回错误状态。这是最传统的方式,但会导致调用方需要频繁检查错误,代码冗长。
- 返回期望值(Expected)或可选值(Optional):C++17引入了
std::optional,可以表示一个可能不存在的值。社区也有类似std::expected的提案和第三方库(如tl::expected),它可以同时携带成功值或错误信息,比普通错误码更类型安全。 - 断言(Assertions):用于捕捉编程错误(逻辑错误),在调试版本中终止程序并给出提示,在发布版本中通常被移除。它不能用于处理运行时错误。
决策建议:
- 对于大多数应用程序、服务、库,启用异常是更好的选择。它带来的代码清晰度和健壮性优势,远超过其微小的性能开销。
- 如果你在开发一个基础库(如STL的替代品),并且希望库能在禁用异常的环境中使用,那么你需要设计两套接口,或者完全避免抛出异常,转而使用错误码或
noexcept版本。 - 除非你有确凿的性能分析数据证明异常是瓶颈,否则不要轻易禁用异常。现代编译器和标准库对异常处理的优化已经做得相当好了。
5. 常见陷阱、调试技巧与最佳实践
5.1 新手常犯的五个错误及避坑指南
- 在析构函数中抛出异常:这是“双重异常”问题,会导致程序立即终止。务必确保析构函数是
noexcept的,内部可能失败的操作要用try-catch块包裹并处理。 - 捕获异常后不处理或“吞掉”异常:空的
catch块或仅打印日志而不采取任何恢复或传播措施,会掩盖严重的程序错误,使得调试极其困难。// 坏例子 try { riskyOperation(); } catch (...) { /* 什么都不做 */ } // 错误被无声吞没! // 好例子:至少记录日志,或者重新抛出 try { riskyOperation(); } catch (const std::exception& e) { logError(e.what()); // 根据情况决定:如果可以恢复,就恢复;否则,考虑重新抛出或终止。 // throw; // 重新抛出当前异常 } - 按值捕获异常导致切片(Slicing):如果捕获派生类异常时使用了基类的值类型,派生类的额外信息会丢失。
try { throw DatabaseException(...); } catch (std::exception e) { // 按值捕获,发生切片!DatabaseException的errorCode等信息丢失。 std::cout << e.what() << std::endl; } // 正确做法:按常量引用捕获 catch (const std::exception& e) { ... } - 异常规格(Exception Specifications)的误用:C++98风格的动态异常规格(如
void func() throw(std::bad_alloc);)已被弃用(C++11起弃用,C++17移除)。不要使用它。使用noexcept代替。 - 将异常用于正常的控制流:异常处理机制开销较大,不应该用于像检查文件是否存在、用户输入是否有效等常规控制流程。对于这些可预期的“错误”,应该使用返回值或状态码。
// 坏例子:用异常检查文件存在性 try { std::ifstream file("maybe_exists.txt"); if(!file) throw ...; } catch(...) { /* 处理不存在 */ } // 好例子:使用返回值或标准库函数 if (std::filesystem::exists("maybe_exists.txt")) { ... }
5.2 异常与多线程:std::exception_ptr的妙用
在多线程编程中,子线程中抛出的异常无法被主线程直接捕获。如果子线程异常未被捕获,程序会调用std::terminate()。为了在线程间传递异常,C++11引入了<future>库和std::exception_ptr。
std::exception_ptr是一个共享指针,指向一个被捕获的异常对象。你可以通过std::current_exception()获取当前处理异常的exception_ptr,也可以通过std::rethrow_exception(ptr)重新抛出它。
典型用法:
#include <iostream> #include <thread> #include <future> #include <stdexcept> void worker(std::promise<int>& prom) { try { // 模拟一些工作,可能抛出异常 throw std::runtime_error("Something bad happened in worker thread!"); prom.set_value(42); // 如果成功,设置值 } catch (...) { // 捕获所有异常,并通过promise传递出去 prom.set_exception(std::current_exception()); } } int main() { std::promise<int> prom; std::future<int> fut = prom.get_future(); std::thread t(worker, std::ref(prom)); try { int result = fut.get(); // 这里会等待,并可能重新抛出worker线程中的异常 std::cout << "Result: " << result << std::endl; } catch (const std::exception& e) { std::cerr << "Exception from thread: " << e.what() << std::endl; } t.join(); return 0; }通过std::promise和std::future,我们可以安全地将子线程中的异常传递到主线程进行处理,这是编写健壮多线程程序的关键技术。
5.3 调试与排查:当异常“神出鬼没”时怎么办
异常调试有时很棘手,尤其是当异常被某个地方的catch (...)吞掉,或者传播路径很长的时候。
- 利用调试器的异常断点:现代IDE(如Visual Studio、CLion、VS Code with GDB/LLDB)都支持设置“第一次机会异常”断点。在异常被抛出的瞬间,调试器就会中断,你可以看到完整的调用栈,这对于定位异常源头至关重要。
- 不要轻易使用
catch (...):在开发阶段,尽量避免使用捕获所有异常的块。如果必须使用,确保在其中记录详细的上下文信息(如时间、函数名、参数等),并考虑重新抛出(throw;)以便让调试器捕获。 - 自定义异常的
what()信息:在构造异常时,提供尽可能详细的信息,包括文件名、行号(可以用__FILE__和__LINE__宏)、函数名、相关变量值等。这能极大简化事后日志分析。#define THROW_EXCEPTION(msg) \ throw std::runtime_error(std::string(__FILE__) + ":" + std::to_string(__LINE__) + " - " + msg) void someFunc(int arg) { if (arg < 0) { THROW_EXCEPTION("Argument must be non-negative, got: " + std::to_string(arg)); } } - 全局异常处理器:在一些框架或应用中,可以设置全局的、未捕获异常的处理函数,通过
std::set_terminate或std::set_unexpected(后者已弃用)来安装一个回调,在程序因异常即将终止前进行最后的日志记录或清理。这对于生产环境的问题追踪很有帮助。
5.4 最佳实践总结清单
最后,把我认为最重要的几条异常处理最佳实践列出来,你可以把它当作一份自查清单:
- 优先使用标准异常:从
std::logic_error和std::runtime_error派生你的自定义异常。 - 始终按常量引用捕获:
catch (const std::exception& e)。 - 拥抱RAII:对所有资源使用智能指针和RAII包装类,这是实现基本异常安全保证的最简单方法。
- 析构函数必须不抛异常:确保析构函数是
noexcept的,内部潜在失败的操作要try-catch住。 - 谨慎使用
noexcept:只为那些真正不会失败的操作添加noexcept声明,移动操作、交换操作、析构函数是重点候选。 - 异常是给“异常情况”的:不要用异常来处理正常的、可预期的流程分支(如文件未找到)。
- 避免空的
catch块:至少记录日志。如果不知道如何处理,考虑重新抛出(throw;)。 - 在构造函数中初始化资源:使用成员初始化列表,并让成员对象自己管理资源。
- 考虑强异常安全保证:对于关键操作,考虑使用“拷贝-交换”惯用法来提供强保证。
- 在多线程中使用
std::future传递异常:不要让线程异常无人处理而终止整个进程。 - 为异常提供丰富的上下文信息:在
what()消息中包含有助于调试的数据。
掌握异常处理,是区分C++新手和熟练工的重要标志。它不仅仅是语法,更是一种关乎程序健壮性、可维护性和设计思想的编程哲学。刚开始可能会觉得有些复杂,但一旦习惯,你就会发现它能让你写出更干净、更安全、更易于推理的代码。