C++异常处理全解析:从语法到RAII与异常安全实战

1. 项目概述:为什么C++异常处理是每个开发者必须跨过的坎

干了这么多年C++,我见过太多因为异常处理不当而导致的线上事故。从内存泄漏到程序崩溃,从数据不一致到难以定位的诡异Bug,很多问题的根源都指向了异常处理这块“硬骨头”。很多刚入行的朋友,甚至一些有经验的开发者,对C++异常的理解还停留在“try-catch-throw”这三个关键词的表面,知其然不知其所以然。今天,我就结合自己踩过的坑和积累的经验,把C++异常处理这件事掰开揉碎了讲清楚。这不仅仅是语法,更是一种健壮性编程的思维模式。无论你是正在准备面试,被“C++异常处理”这个八股文问题困扰,还是在开发中遇到了“Microsoft Visual C++ Redistributable”缺失导致的运行时异常,亦或是想写出更稳定、更易维护的C++代码,这篇文章都会给你带来实实在在的帮助。我们会从最基础的语法讲起,深入到标准库异常、RAII资源管理、异常安全保证,再到现代C++(C++11/17/20)带来的新特性,最后聊聊实战中的最佳实践和那些教科书上不会写的“坑”。

2. C++异常处理的核心机制与语法精讲

2.1 异常处理的基本骨架:try,catch,throw

异常处理的本质是程序控制流的非本地跳转。当函数执行过程中遇到无法或不应在本地处理的错误时,它可以通过throw抛出一个异常对象。这个异常对象会沿着调用栈向上“冒泡”,直到被某个try块后的catch子句捕获并处理。如果始终未被捕获,程序会调用std::terminate终止。

throw表达式:这是异常的发起者。你可以抛出几乎任何类型的对象:基本类型(int,const char*)、标准库异常类型、或自定义类型的对象。但最佳实践是抛出派生自std::exception的类对象,因为这样能利用多态性进行统一捕获。

// 抛出基本类型(不推荐,信息量少) throw -1; throw “Something bad happened!”; // 抛出标准库异常(推荐) #include <stdexcept> throw std::runtime_error(“Database connection failed”); throw std::out_of_range(“Vector index out of bounds”); // 抛出自定义异常对象(更推荐) class MyAppException : public std::runtime_error { public: MyAppException(const std::string& msg, int errorCode) : std::runtime_error(msg), m_errorCode(errorCode) {} int getErrorCode() const { return m_errorCode; } private: int m_errorCode; }; throw MyAppException(“Failed to load config”, 1001);

try块:将可能抛出异常的代码包裹起来。一个try块后面必须紧跟一个或多个catch块。try块定义了异常监控的范围。

catch块:异常的处理者。每个catch块指定它能捕获的异常类型。异常处理系统会按catch块出现的顺序进行匹配,第一个类型匹配(或能通过基类引用捕获)的catch块会被执行。

try { // 可能抛出异常的代码 riskyOperation(); anotherRiskyCall(); } catch (const MyAppException& e) { // 优先捕获更具体的自定义异常 std::cerr << “App Error [“ << e.getErrorCode() << “]: “ << e.what() << std::endl; // 可能进行恢复或记录日志后重新抛出 // throw; // 重新抛出当前异常,不改变其类型 } catch (const std::runtime_error& e) { // 捕获所有运行时错误 std::cerr << “Runtime error: “ << e.what() << std::endl; } catch (const std::exception& e) { // 捕获所有标准异常(基类捕获) std::cerr << “Standard exception: “ << e.what() << std::endl; } catch (…) { // 捕获所有未被前面catch块处理的异常,包括非std::exception派生的 std::cerr << “Unknown exception caught!” << std::endl; // 注意:catch(…) 块中无法访问异常对象 }

注意:catch (…)是最后的防线,但它是一把双刃剑。它可能掩盖真正的编程错误(如内存访问违规,在C++中这通常是未定义行为,不一定表现为C++异常)。通常只在需要保证程序不崩溃的顶层(如事件循环)或进行资源清理时使用。

2.2 异常对象的生命周期与拷贝开销

理解异常对象的生命周期对写出高效、正确的代码至关重要。当你执行throw e;时,发生的过程并不简单:

  1. 异常对象的创建:表达式e会被用来初始化一个临时对象。这个临时对象位于编译器管理的特殊内存区域(不一定是堆栈),它的生命周期会持续到异常被完全处理完毕。
  2. 拷贝/移动构造:如果e是一个局部对象,会调用其拷贝构造函数或移动构造函数(如果可用且e是右值)来创建这个临时对象。这就是为什么自定义异常类型通常应该有可访问的拷贝/移动构造函数。
  3. 栈展开:控制权从throw点开始,沿着调用链向上回溯。这个过程称为栈展开。在回溯过程中,离开作用域的局部对象(不包括由new分配的对象)会按照与构造相反的顺序被析构。
  4. 匹配与捕获:找到匹配的catch块后,如果catch的参数是按值捕获,会再次发生一次拷贝,将异常临时对象拷贝到catch的参数中。如果参数是按引用捕获(通常是const引用),则不会发生这次拷贝,直接绑定到异常临时对象。强烈建议使用const &来捕获异常,以避免不必要的拷贝并保留多态性。
  5. 处理完成:catch块执行完毕后,异常临时对象被销毁。

实操心得:为了最小化开销,确保你的异常类:

  • 继承自std::exception(或其子类)。
  • 实现noexcept的拷贝构造函数和移动构造函数(如果包含资源)。
  • 核心错误信息尽量存储在成员变量中,而不是在what()返回的字符串里动态分配(虽然标准库实现通常动态分配)。一个常见的模式是在构造函数中格式化好字符串存为std::string成员,what()直接返回其c_str()

2.3 标准库异常体系深度解析

C++标准库提供了一套完整的异常类层次结构,定义在<stdexcept>,<new>,<typeinfo>等头文件中。这套体系是你的第一道防线,应该优先使用。

std::exception (定义于 <exception>) ├── std::bad_alloc (定义于 <new>) ├── std::bad_cast (定义于 <typeinfo>) ├── std::bad_typeid (定义于 <typeinfo>) ├── std::bad_exception (定义于 <exception>) ├── std::logic_error (定义于 <stdexcept>) │ ├── std::domain_error │ ├── std::invalid_argument │ ├── std::length_error │ └── std::out_of_range └── std::runtime_error (定义于 <stdexcept>) ├── std::overflow_error ├── std::range_error ├── std::underflow_error └── std::system_error (C++11起,定义于 <system_error>)
  • std::logic_error:表示程序逻辑上的错误,理论上可以在编码阶段通过代码审查发现。例如,传递了无效参数给函数(std::invalid_argument),索引越界(std::out_of_range)。这类异常通常意味着程序有Bug。
  • std::runtime_error:表示程序运行时发生的、无法仅通过读代码预测的错误。例如,文件未找到、网络连接断开、数据格式错误。这类异常是外部环境或资源状态导致的。
  • std::bad_alloc:new操作符无法分配足够内存时抛出。
  • std::bad_cast:dynamic_cast对引用类型进行向下转型失败时抛出(对指针类型失败返回nullptr,不抛出)。
  • std::system_error(C++11):这是一个强大的新增特性,它封装了操作系统错误码(errno)和std::error_code。在处理文件I/O、网络、线程等系统调用时异常有用。

使用示例:

#include <vector> #include <stdexcept> #include <system_error> #include <iostream> #include <fstream> void useStandardExceptions() { std::vector<int> vec = {1, 2, 3}; try { // 逻辑错误示例 if (vec.size() > 10) { // 假设我们逻辑上认为size不应大于10 throw std::logic_error(“Vector size exceeds logical limit”); } int val = vec.at(10); // 访问越界,抛出 std::out_of_range } catch (const std::out_of_range& e) { std::cerr << “Caught out_of_range: “ << e.what() << ‘\n’; } // 运行时错误示例 std::ifstream file(“nonexistent.txt”); try { if (!file.is_open()) { // 使用system_error携带系统错误码 throw std::system_error(errno, std::generic_category(), “Failed to open file”); } } catch (const std::system_error& e) { std::cerr << “Caught system_error: “ << e.what() << “\nError code: “ << e.code().value() << “ (“ << e.code().message() << “)” << std::endl; } }

3. 异常安全保证:编写健壮代码的核心哲学

异常安全不仅仅是捕获异常,更重要的是当异常被抛出时,你的程序、你的对象、你的数据结构处于何种状态。C++社区通常用三个级别的异常安全保证来描述代码的健壮性:

3.1 三级异常安全保证

  1. 基本保证:如果异常被抛出,程序仍处于有效状态。不会发生资源泄漏(如内存、文件句柄),所有对象仍处于可析构状态。但是,程序的具体状态可能是未知的(例如,一个容器可能只被部分修改)。
  2. 强保证:如果异常被抛出,程序的状态完全回滚到操作调用之前的状态。就像这个操作从未发生过一样。这通常通过“拷贝-交换”惯用法或事务性操作来实现。
  3. 不抛掷保证:承诺操作永远不会抛出异常。例如,析构函数、移动操作、交换操作通常应提供不抛掷保证。在C++11后,可以用noexcept关键字来声明和检查。

3.2 实现强异常安全保证的经典模式:“拷贝-交换”惯用法

假设我们要实现一个简单的String类,其赋值运算符operator=需要提供强异常安全保证。

#include <algorithm> // for std::swap #include <cstring> // for std::strlen, std::strcpy class String { public: // 基本构造函数 String(const char* data = “”) { if (data) { m_size = std::strlen(data); m_data = new char[m_size + 1]; std::strcpy(m_data, data); } else { m_size = 0; m_data = new char[1]; m_data[0] = ‘\0’; } } // 拷贝构造函数(提供基本保证) String(const String& other) : m_size(other.m_size) { m_data = new char[m_size + 1]; std::strcpy(m_data, other.m_data); } // 析构函数(不抛掷保证) ~String() noexcept { delete[] m_data; } // 交换函数(不抛掷保证,是关键!) friend void swap(String& first, String& second) noexcept { using std::swap; // 启用ADL swap(first.m_size, second.m_size); swap(first.m_data, second.m_data); } // 拷贝赋值运算符(提供强保证!) String& operator=(String other) { // 注意:参数是按值传递! // 这里调用了拷贝构造函数。如果拷贝构造失败(bad_alloc), // 异常会在进入函数体之前抛出,*this的状态完全未受影响。 swap(*this, other); // swap是noexcept的,绝不会失败。 // 离开时,other(现在持有*this的旧数据)被析构。 return *this; } // 移动赋值运算符(通常也是noexcept) String& operator=(String&& other) noexcept { swap(*this, other); return *this; } private: size_t m_size; char* m_data; };

为什么这是强保证?operator=的参数是String other,这是一个按值传递。当执行str1 = str2;时:

  1. 首先,调用String的拷贝构造函数,用str2来初始化临时对象other。这一步可能抛出异常(如std::bad_alloc)。如果抛出,str1的状态没有任何改变,满足强保证。
  2. 如果拷贝成功,我们进入函数体。swap(*this, other)noexcept的,保证不会失败。这一步高效地交换了*thisother的内容。
  3. 函数结束,局部变量other(现在持有str1的旧数据)被析构,资源被正确释放。

整个过程中,如果任何一步失败,str1的原始状态都保持不变。这就是强异常安全保证。

3.3 RAII:异常安全的基石

资源获取即初始化是C++管理资源的黄金法则,也是实现异常安全的核心手段。其思想是:将资源(内存、文件、锁、网络连接等)的生命周期绑定到一个局部对象的生命周期上。对象构造时获取资源,对象析构时自动释放资源。由于栈展开时会析构局部对象,因此即使发生异常,资源也能被正确释放,避免了泄漏。

标准库中的RAII范例:

  • std::unique_ptr<T>,std::shared_ptr<T>: 管理动态内存。
  • std::vector<T>,std::string: 管理动态数组和字符串。
  • std::ifstream,std::ofstream: 管理文件句柄。
  • std::lock_guard<std::mutex>,std::unique_lock<std::mutex>(C++11): 管理互斥锁。
  • std::thread(C++11): 管理线程句柄。

实战示例:对比有RAII和无RAII的代码

// 糟糕的、非异常安全的代码 void processFileBad(const char* filename) { FILE* f = fopen(filename, “r”); if (!f) return; char* buffer = (char*)malloc(1024); if (!buffer) { fclose(f); // 记得关闭文件!容易忘记。 return; } // ... 对buffer和文件进行操作 // 如果这里抛出异常?文件句柄和内存都会泄漏! free(buffer); fclose(f); } // 良好的、异常安全的RAII代码 #include <memory> #include <fstream> #include <vector> #include <stdexcept> void processFileGood(const std::string& filename) { // 使用ifstream管理文件,构造函数可能抛出异常,但析构函数会自动关闭文件 std::ifstream file(filename); if (!file.is_open()) { throw std::runtime_error(“Cannot open file: “ + filename); } // 使用vector管理内存,无需手动new/delete std::vector<char> buffer(1024); // 或者使用unique_ptr管理自定义内存块 auto uniqueBuffer = std::make_unique<char[]>(1024); // ... 进行操作 // 即使这里抛出异常,file、buffer、uniqueBuffer的析构函数都会被自动调用, // 确保资源被正确释放。这就是基本异常安全保证。 }

重要提示:永远不要在析构函数中抛出异常!如果栈展开过程中析构函数又抛出异常,程序会立即调用std::terminate终止。确保析构函数是noexcept的。如果析构函数必须执行可能失败的操作(如写入日志),请用try-catch(…)吞掉异常。

4. 现代C++中的异常处理新特性与最佳实践

4.1noexcept关键字:性能与契约

C++11引入了noexcept说明符和运算符,它有两个主要作用:

  1. 作为说明符:声明函数不会抛出任何异常。这是对编译器和调用者的承诺。

    void mySwap(Type& a, Type& b) noexcept; // 承诺不抛异常 void compute() noexcept; // 承诺不抛异常
    • 动机:如果函数声明为noexcept,编译器可以生成更优化的代码(例如,在移动构造函数中,编译器知道它不会失败,可以安全地执行更激进的优化)。标准库中的许多操作(如std::vector::resize)在移动元素时会检查移动构造函数是否为noexcept,如果是,则使用移动;否则,可能使用拷贝以保证强异常安全。
    • 后果:如果一个声明为noexcept的函数内部抛出了异常,程序会直接调用std::terminate()终止。因此,只对真正不会失败的操作使用noexcept
  2. 作为运算符:noexcept(expression),在编译期判断一个表达式是否可能抛出异常。

    static_assert(noexcept(std::swap(a, b)), “swap should be noexcept”); template<typename T> void doSwap(T& a, T& b) noexcept(noexcept(std::swap(a, b))) { // 此函数的noexcept性质依赖于std::swap(a,b)是否noexcept std::swap(a, b); }

最佳实践:

  • 析构函数、移动构造函数、移动赋值运算符、交换函数应该必须尽可能声明为noexcept
  • 对于简单、确定性的函数(如getter、setter、数学运算),如果确定不会失败,可以声明为noexcept
  • 对于可能失败的操作(如I/O、内存分配、网络请求),不要声明为noexcept

4.2 异常规格的演进:从throw()noexcept

在C++11之前,使用throw()来声明函数不抛出异常(动态异常规格)。例如:

void oldFunc() throw(); // C++98/03风格,表示不抛出任何异常 void oldFunc2() throw(std::runtime_error, std::logic_error); // 只抛出这两种异常

C++11中,throw()(无参数)被弃用,并用noexcept替代。throw(type1, type2…)语法被彻底移除。因为动态异常规格在运行时检查,带来开销且效果不佳。noexcept是编译时信息,更高效。

4.3 标准库中的异常安全增强

现代C++标准库本身提供了很强的异常安全保证。例如:

  • std::vector::push_back在C++11后,如果元素的移动构造函数是noexcept的,则提供强保证;否则,提供基本保证(可能发生拷贝,如果拷贝失败,vector状态不变)。
  • 智能指针(unique_ptr,shared_ptr)的操作基本都是异常安全的。
  • 很多算法(如std::sort)也提供了特定的异常安全保证。

4.4 自定义异常类的现代实现

结合现代C++特性,我们可以写出更安全、更高效的自定义异常类。

#include <stdexcept> #include <string> #include <utility> class MyModernException : public std::runtime_error { public: // 使用explicit防止隐式转换 explicit MyModernException(std::string msg, int code = 0) : std::runtime_error(msg), m_code(code), m_message(std::move(msg)) { // m_message是副本,用于可能需要的其他操作 // std::runtime_error 内部也会存储一份消息 } // 禁止拷贝赋值,但允许移动(编译器生成的通常就够用) MyModernException(const MyModernException&) = default; MyModernException& operator=(const MyModernException&) = delete; // 通常异常不需要赋值 MyModernException(MyModernException&&) noexcept = default; // what() 已经由 std::runtime_error 实现 int getCode() const noexcept { return m_code; } const std::string& getFullMessage() const noexcept { return m_message; } private: int m_code; std::string m_message; // 额外的信息存储 };

5. 实战中的疑难杂症与性能考量

5.1 异常 vs 错误码:如何选择?

这是一个经典争论。两者各有适用场景:

使用异常的情况:

  • 错误需要跨多层调用栈处理:错误发生在深层函数,但需要在高层统一处理(如记录日志、通知用户)。
  • 错误是“异常”的、不可预期的:如文件不存在、网络断开、内存不足。这些不是常规控制流的一部分。
  • 构造函数和运算符重载中报告错误:构造函数没有返回值,运算符重载通常需要保持与内置类型相似的语法,使用错误码会很别扭。
  • 希望利用RAII自动清理资源时:异常与RAII是天作之合。

使用错误码(或std::optionalstd::expected(C++23))的情况:

  • 错误是常规控制流的一部分:例如,解析用户输入时遇到格式错误,这很常见,不应视为“异常”。
  • 性能极其关键的代码路径(热点路径):异常的机制(栈展开、查找catch块)比检查错误码有更高的开销。在紧密循环中,频繁抛出和捕获异常会严重影响性能。
  • 与C语言或没有异常机制的代码交互:如操作系统API、C库。
  • 需要在编译时确定所有可能的错误:异常是动态类型,编译时难以分析。

混合使用模式:一种常见的模式是,在底层模块内部使用错误码,在模块边界(公共API)将严重的错误码转换为异常抛出,给上层使用者一个清晰的错误处理接口。

5.2 异常的性能开销到底有多大?

“异常很慢”是一个常见的误解,需要细化:

  • 正常执行路径(无异常抛出):在现代编译器和硬件上,开销几乎为零。编译器使用“零开销异常处理”技术(如Itanium C++ ABI中的表驱动机制),将异常处理信息放在单独的数据段。不抛出异常时,代码路径上没有额外的判断指令。
  • 抛出和捕获异常时:开销显著。这涉及查找异常处理表、栈展开、调用析构函数等一系列复杂操作。可能比函数返回慢几个数量级。

结论:异常适用于“罕见”的错误路径。如果你的错误发生频率很高(比如,在处理网络包时,每几个包就有一个格式错误),那么使用错误码或std::optional会是更好的选择。

5.3 构造函数中的异常:资源管理的关键

构造函数没有返回值,因此报告错误的唯一方式就是抛出异常。这要求构造函数必须是异常安全的。

问题:如果构造函数在初始化多个成员时中途失败(比如,第四个成员的构造函数抛出异常),那么前三个已经成功构造的成员需要被正确析构,否则会资源泄漏。

解决方案:使用成员初始化列表,并依赖成员自身的RAII属性。如果成员是内置类型或具有不抛异常的构造函数,则没问题。如果成员构造可能失败,考虑使用智能指针来延迟资源的获取,或将可能失败的部分移到单独的初始化函数中(但这会破坏构造函数的完整性)。

class ResourceHolder { public: ResourceHolder(const std::string& name) : m_name(name) // 基本类型或noexcept构造 , m_file(name) // std::ifstream构造可能抛出异常 , m_buffer(std::make_unique<char[]>(1024)) // make_unique可能抛出bad_alloc { // 如果这里还有可能失败的操作... if (!m_file.is_open()) { // 构造函数已部分完成,但析构函数会被调用,清理m_file和m_buffer // 我们抛出一个异常来报告失败 throw std::runtime_error(“Failed to open file in constructor”); } // 所有成员初始化成功,构造函数完成 } // 编译器生成的析构函数会按逆序析构m_buffer, m_file, m_name // 由于它们都是RAII对象,资源会被正确释放。 private: std::string m_name; std::ifstream m_file; std::unique_ptr<char[]> m_buffer; };

5.4 多线程环境下的异常处理

线程函数的异常不能跨线程传播。如果线程函数中抛出的异常未被捕获,std::terminate会被调用。

正确做法:

  1. 在线程函数内部用try-catch(…)块包裹所有代码,捕获所有异常。
  2. 将捕获的异常信息通过线程安全的方式(如std::promise/std::future、原子变量、队列)传递回主线程或负责的线程。
  3. 在主线程中检查并处理这些异常。
#include <thread> #include <future> #include <iostream> #include <stdexcept> void workerFunction(std::promise<int>& resultPromise) { try { // 模拟工作,可能抛出异常 int heavyResult = doHeavyComputation(); if (heavyResult < 0) { throw std::runtime_error(“Computation resulted in negative value”); } resultPromise.set_value(heavyResult); // 传递结果 } catch (…) { // 捕获所有异常,并通过promise传递异常 resultPromise.set_exception(std::current_exception()); } } int main() { std::promise<int> prom; std::future<int> fut = prom.get_future(); std::thread worker(workerFunction, std::ref(prom)); worker.detach(); // 或join() try { int result = fut.get(); // 这里会等待,并可能重新抛出worker线程中的异常 std::cout << “Result: “ << result << std::endl; } catch (const std::exception& e) { std::cerr << “Worker thread threw: “ << e.what() << std::endl; } return 0; }

5.5 处理第三方库和系统错误

很多C库和操作系统API通过返回值或全局变量errno报告错误。在与这些接口交互时,需要将错误码转换为C++异常,以融入统一的错误处理框架。

使用std::system_error

#include <system_error> #include <cstring> void readFromFileDescriptor(int fd) { char buffer[1024]; ssize_t bytesRead = read(fd, buffer, sizeof(buffer)); if (bytesRead == -1) { // 将errno转换为system_error并抛出 throw std::system_error(errno, std::generic_category(), “read() failed”); } // … 处理数据 }

处理new的失败:默认情况下,new在分配失败时抛出std::bad_alloc。你也可以使用std::nothrow版本使其返回nullptr

int* p1 = new int; // 失败则抛 std::bad_alloc int* p2 = new (std::nothrow) int; // 失败则返回 nullptr if (!p2) { // 处理分配失败,使用错误码逻辑 }

6. 调试与排查:让异常信息成为你的帮手

6.1 获取并打印有意义的调用栈信息

默认的异常what()信息通常只有字符串,没有文件、行号、调用栈。这在调试时非常痛苦。有几种改进方法:

  1. 自定义异常类携带更多信息:

    class TraceableException : public std::runtime_error { public: TraceableException(const std::string& msg, const char* file, int line) : std::runtime_error(msg), m_file(file), m_line(line) { m_what = std::string(file) + “:” + std::to_string(line) + “: “ + msg; } const char* what() const noexcept override { return m_what.c_str(); } private: std::string m_file; int m_line; std::string m_what; }; // 使用宏简化抛出 #define THROW_TRACEABLE(msg) throw TraceableException(msg, __FILE__, __LINE__)
  2. 使用第三方库:如Boost.Exception,它提供了强大的能力,可以在抛出点添加任意多的错误信息(文件、行号、调用栈、变量值等)。

    #include <boost/exception/all.hpp> typedef boost::error_info<struct tag_errinfo_file, std::string> errinfo_file; typedef boost::error_info<struct tag_errinfo_line, int> errinfo_line; void someFunction() { BOOST_THROW_EXCEPTION(std::runtime_error(“Oops!”) << errinfo_file(__FILE__) << errinfo_line(__LINE__)); } // 捕获后可以提取这些信息
  3. 平台相关的调用栈捕获:在Linux上可以使用backtrace()系列函数,在Windows上可以使用CaptureStackBackTrace等API。可以将这些信息集成到你的异常类中。但这通常比较复杂,且会影响性能。

6.2 常见的异常处理反模式与陷阱

  1. 吞掉所有异常(catch(…) {}然后什么都不做):这是最糟糕的做法,它掩盖了错误,让程序在未知状态下继续运行,可能导致更严重的数据损坏。
  2. 在析构函数中抛出异常:如前所述,这会导致程序立即终止。
  3. 抛出指针而非对象:throw new MyException();这会导致内存泄漏,因为捕获方需要记得delete。总是按值抛出对象。
  4. 使用异常进行常规控制流:就像用大炮打蚊子,性能差且代码难以理解。比如,用异常来实现“文件结束”判断。
  5. 不完整的异常规格:在C++11前使用throw(type),但遗漏了可能抛出的类型,会导致std::unexpected()被调用。现代C++应使用noexcept或什么都不写。
  6. 在构造函数中未正确处理资源:如果构造函数中new了多个资源,前几个成功,后一个失败,必须确保前几个被释放。使用RAII对象(如std::unique_ptr)来管理每个独立资源。
  7. 异常安全问题与数据结构:编写如链表、树等数据结构时,要特别注意操作(如插入、删除)的异常安全性。通常需要仔细安排代码顺序,或使用“先准备后备,再提交”的策略。

6.3 设计异常安全的API指南

  1. 明确文档化每个函数可能抛出的异常类型及其异常安全保证级别(基本、强、不抛掷)。
  2. 优先使用RAII类型作为成员变量和局部变量。
  3. 在修改对象状态之前,先完成所有可能抛出异常的操作。例如,先分配新内存,复制数据,最后再交换指针。
  4. 提供“不抛掷”的交换函数(swap),它是实现强异常安全赋值操作的关键。
  5. 对于可能失败的操作,考虑提供“try”版本和“普通”版本。例如,std::vector::push_back(可能抛异常)和std::vector::emplace_back,同时标准库也有std::filesystem::copystd::filesystem::copy可能抛异常,而std::filesystem::copy则有接收std::error_code&参数的不抛异常版本。
  6. 在模块或类的边界,将内部使用的错误码转换为一致的异常类型抛出,对外提供清晰的异常接口。

C++异常处理是一个庞大而深入的课题,它不仅仅是try-catch-throw的语法,更关乎程序的设计哲学、资源管理和健壮性。理解并善用异常,能让你写出更清晰、更安全、更易于维护的C++代码。从今天开始,检查你的代码,看看那些返回错误码的if语句,是否可以用异常和RAII进行更优雅的重构。记住,异常不是用来替代所有错误处理,而是用来处理那些真正的、意外的“异常”情况。用好它,你的代码质量会提升一个档次。