C++异常处理:从RAII到noexcept的工程实践与性能优化

📅 2026/7/19 4:46:04 👁️ 阅读次数 📝 编程学习
C++异常处理:从RAII到noexcept的工程实践与性能优化

1. 项目概述:为什么C++异常处理值得你花1.9万字来啃透?

如果你写过C++,尤其是写过一些规模稍大、需要处理各种边界情况和错误场景的项目,那你一定对if (ptr == nullptr)if (file.is_open())if (ret != 0)这类代码感到无比熟悉,甚至有些厌倦。它们像杂草一样遍布在你的代码逻辑中,把清晰的业务逻辑切割得支离破碎。更头疼的是,当错误发生在函数调用链的深处时,你不得不一层层地把错误码“冒泡”传回给调用者,这个过程不仅繁琐,还极易出错,导致错误信息丢失或被错误地处理。这就是传统的错误码(Error Code)机制带来的困境。

C++异常处理机制,就是为了解决这个问题而生的。它提供了一种将错误处理逻辑与正常业务逻辑分离的标准化方法。简单来说,当程序运行中遇到无法就地处理的异常情况(比如内存分配失败、文件不存在、除零错误)时,它可以“抛出”(throw)一个异常对象。这个异常会沿着函数调用栈自动向上“传播”,直到被某个能够处理它的“捕获”(catch)块拦截并处理。这个过程完全由语言运行时自动管理,你不需要手动检查每一步的返回值。

我之所以想用近两万字的篇幅来聊这个话题,是因为异常处理远不止try/catch/throw这三个关键字那么简单。它深刻地影响了C++的代码结构、资源管理(RAII)、性能考量,甚至是团队的编码规范。网上很多教程只讲语法,但实际项目中,什么时候该用异常?怎么设计异常安全的代码?异常对性能的影响到底有多大?noexcept该怎么用?这些才是真正让开发者困惑和踩坑的地方。这篇文章,我就结合自己这些年踩过的坑和积累的经验,带你从“会用”到“精通”,彻底搞懂C++异常处理的里里外外。

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

2.1throwtrycatch:基础三板斧

让我们从最基础的语法开始。异常处理的核心是三个关键字:throwtrycatch

throw表达式:用于抛出一个异常。你可以抛出几乎任何类型的对象,但最佳实践是抛出派生自标准库std::exception类或其子类的对象。

#include <stdexcept> #include <string> void connectToDatabase(const std::string& address) { if (address.empty()) { // 抛出一个标准异常对象 throw std::invalid_argument("数据库地址不能为空"); } // 模拟连接失败 bool connectionFailed = true; if (connectionFailed) { // 也可以抛出自定义异常类对象(推荐) throw std::runtime_error("无法连接到数据库: " + address); } // ... 正常连接逻辑 }

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

catch:用于捕获并处理特定类型的异常。捕获时遵循类型匹配原则,且catch(...)可以捕获所有类型的异常,通常用作最后的“安全网”。

#include <iostream> #include <stdexcept> void riskyOperation() { throw std::runtime_error("操作过程中发生了意外错误"); } int main() { try { riskyOperation(); // 其他可能抛出异常的代码 std::cout << "正常执行路径" << std::endl; // 这行不会被执行 } 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 (const std::exception& e) { // 捕获所有标准异常(因为std::runtime_error也派生自它,所以更具体的catch要放在前面) std::cerr << "标准异常: " << e.what() << std::endl; } catch (...) { // 捕获所有其他未知类型的异常。注意:这里无法获取异常对象。 std::cerr << "发生了未知类型的异常!" << std::endl; // 通常用于记录日志并终止程序,因为无法进行有意义的恢复。 throw; // 重新抛出,让上层或terminate处理 } return 0; }

注意catch子句的排列顺序至关重要。异常处理系统会按catch出现的顺序进行类型匹配。因此,应该先捕获更具体(派生类)的异常,再捕获更通用(基类)的异常。如果把catch (const std::exception& e)放在第一个,那么所有标准异常都会被它捕获,后面的catch (const std::runtime_error& e)就永远没有机会执行了。

2.2 异常对象的生命周期与传播机制

理解异常对象的生命周期是避免内存问题和理解性能开销的关键。当你执行throw e;时,到底发生了什么?

  1. 拷贝构造throw表达式会拷贝(或移动,如果类型支持且编译器优化)其操作数,生成一个异常对象。这个对象存储在一个由编译器管理的特殊内存区域(通常是堆或线程局部存储),它独立于正常的函数栈帧。
  2. 栈解旋:异常抛出后,程序控制流立即离开当前的try块,开始“栈解旋”过程。这个过程会按创建顺序的逆序,析构当前作用域(从throw点开始,到最近的try块结束)内的所有已构造成功的局部对象。这是RAII(资源获取即初始化)机制能保证资源不泄露的核心保障。
  3. 类型匹配:控制流向上回溯调用栈,检查每个try块后的catch子句,寻找第一个类型匹配的处理器。
  4. 捕获与处理:找到匹配的catch块后,其参数会通过拷贝或引用(取决于你如何声明)的方式初始化这个异常对象。然后执行catch块内的代码。
  5. 生命周期结束:当catch块执行完毕(除非重新抛出),这个异常对象才会被析构。

关键点:异常对象由编译器管理,其生命周期可能跨越多个函数栈帧。在catch块中,如果通过引用(如catch (const std::exception& e))来捕获,你得到的是对那个编译器管理对象的引用,没有额外拷贝开销。如果通过(如catch (std::exception e))捕获,则会发生一次拷贝构造。因此,始终建议通过const引用来捕获异常,除非你有特殊的理由需要一份副本。

重新抛出:在catch块中,你可以使用空的throw;语句将当前捕获的异常原封不动地继续向上层传播。这在当前catch块只完成了部分处理(如日志记录)但无法完全恢复时非常有用。

void logAndRethrow() { try { someOperation(); } catch (const std::exception& e) { std::cerr << "记录日志: " << e.what() << std::endl; // 清理当前层能清理的资源 cleanupPartialResources(); throw; // 重新抛出,异常类型和内容不变,继续向上传播 } }

2.3 标准异常体系:你的工具箱里有什么?

C++标准库提供了一套定义在<stdexcept><new><typeinfo>等头文件中的异常类体系。它们都继承自std::exception基类。了解它们有助于你抛出语义清晰的异常。

  • std::exception:所有标准库异常的基类。提供virtual const char* what() const noexcept成员函数,返回描述错误的C风格字符串。
  • 逻辑错误(通常由程序员错误导致,可在编码阶段避免)
    • std::invalid_argument:参数值不被接受。
    • std::domain_error:参数值在数学函数定义域之外。
    • std::length_error:试图创建超出最大长度的对象(如std::vectorstd::string)。
    • std::out_of_range:访问容器等元素时索引越界(如vector::at)。
  • 运行时错误(通常由程序运行时环境导致,难以在编码阶段完全避免)
    • std::runtime_error:一般运行时错误的基类。
    • std::range_error:计算结果超出有意义的范围。
    • std::overflow_error/std::underflow_error:算术运算上溢/下溢。
    • std::system_error:封装了操作系统错误码(errno)的异常,非常有用。

自定义异常:对于特定领域的错误,你应该创建自己的异常类,继承自std::runtime_errorstd::logic_error(根据错误性质)。这能让错误分类更清晰。

#include <stdexcept> #include <string> class NetworkTimeoutException : public std::runtime_error { public: explicit NetworkTimeoutException(const std::string& host, int timeout_ms) : std::runtime_error("网络连接超时: " + host + ", 超时时间: " + std::to_string(timeout_ms) + "ms") {} }; class DatabaseConnectionException : public std::runtime_error { public: explicit DatabaseConnectionException(const std::string& dbName, const std::string& reason) : std::runtime_error("数据库连接失败 [" + dbName + "]: " + reason) {} }; void queryDatabase() { // ... if (connectionLost) { throw DatabaseConnectionException("MyDB", "连接被对端重置"); } }

使用标准或自定义的异常体系,能让你的错误信息在传播过程中保持结构化和可读性,便于上层统一处理和日志记录。

3. 异常安全性与资源管理:编写健壮代码的核心

异常处理引入了一个核心挑战:当异常发生时,如何保证程序状态(尤其是资源)不被破坏?这就是异常安全性。C++通过RAII(资源获取即初始化)范式来优雅地解决这个问题。

3.1 异常安全性的基本等级

异常安全性通常分为几个等级,从弱到强:

  1. 无保证:如果抛出异常,程序可能处于任何状态,资源可能泄露,对象可能被破坏。这是最糟糕的情况,应极力避免。
  2. 基本保证:如果抛出异常,程序状态保持不变。这意味着所有资源都被正确释放(无泄露),且所有对象都处于有效但可能不确定的状态(例如,容器可能是空的)。这是最低要求
  3. 强保证:如果抛出异常,程序状态完全回滚到操作开始前的状态,就像这个操作从未执行过一样。这通常通过“拷贝-交换”惯用法实现。
  4. 不抛掷保证:承诺操作永远不会抛出异常。这对于析构函数和移动操作尤为重要。

3.2 RAII:异常安全的基石

RAII的核心思想是:将资源的生命周期与对象的生命周期绑定。在构造函数中获取资源,在析构函数中释放资源。由于栈解旋时会自动调用已构造对象的析构函数,因此无论函数是正常返回还是因异常退出,资源都能得到释放。

反面教材(原始指针,不安全)

void unsafeFunction() { int* ptr = new int[100]; // 资源获取 someOperationThatMayThrow(); // 可能抛出异常! delete[] ptr; // 如果上面抛异常,这行不会执行,内存泄露! }

正面教材(RAII,使用std::vector,安全)

void safeFunction() { std::vector<int> vec(100); // 资源获取在构造函数中完成 someOperationThatMayThrow(); // 可能抛出异常 // 无论是否抛异常,vec的析构函数都会在离开作用域时自动调用,释放内存。 }

标准库中的智能指针(std::unique_ptr,std::shared_ptr)、容器、文件流(std::fstream)、锁(std::lock_guard)等都是RAII的典范。在现代C++中,你应该几乎永远不需要直接使用new/delete

3.3 实现强异常安全保证:“拷贝-交换”惯用法

对于需要修改对象状态的操作,如何提供强保证?一个经典的方法是“拷贝-交换”。

假设我们有一个简单的Widget类,它管理一个动态数组:

#include <algorithm> #include <memory> class Widget { public: // ... 其他成员函数 void setData(const int* newData, std::size_t newSize) { // 方案1:直接修改,只提供基本保证。如果`new int[...]`失败,原数据已丢失。 // delete[] data_; // 先释放旧的 // data_ = new int[newSize]; // 可能抛std::bad_alloc // std::copy(...); // 方案2:拷贝-交换,提供强保证。 auto newDataPtr = std::make_unique<int[]>(newSize); // 1. 在临时对象上分配新资源 std::copy(newData, newData + newSize, newDataPtr.get()); // 2. 复制数据(可能抛异常,但旧状态完好) // 3. 交换,此操作通常为noexcept std::swap(data_, newDataPtr); size_ = newSize; // 4. 离开函数,newDataPtr(现在装着旧数据)被自动释放 } private: std::unique_ptr<int[]> data_; std::size_t size_ = 0; };

在这个setData的强保证版本中,所有可能失败的操作(内存分配、数据拷贝)都在修改this对象的状态之前,在一个临时对象(newDataPtr)上完成。只有所有这些操作都成功后,才用一个不会失败的swap操作来更新对象状态。这样,如果中途任何一步抛出异常,this对象仍然保持原来的状态。

3.4 构造函数与析构函数中的异常

这是一个需要特别注意的领域。

  • 构造函数中抛出异常:如果构造函数在执行过程中抛出异常,那么该对象的析构函数将不会被调用(因为对象构造未完成)。但是,所有已经构造成功的成员子对象和基类子对象,它们的析构函数会被调用(按与构造顺序相反的顺序)。因此,如果你的构造函数中已经分配了资源(比如用了new),必须在抛出异常前手动释放,或者更佳的做法是使用RAII成员(如智能指针)来管理这些资源,让它们的析构函数帮你清理。

  • 析构函数中抛出异常:这是极其危险的!如果栈解旋过程中(因另一个异常)调用的析构函数又抛出了异常,C++运行时将无法处理,通常会直接调用std::terminate()终止程序。因此,析构函数必须尽可能提供不抛掷保证。如果析构函数中调用的操作可能抛出异常(比如关闭文件失败),一定要在析构函数内部用try/catch将其捕获并处理(例如仅记录日志),绝不允许异常传播到析构函数之外。

class FileHandler { public: ~FileHandler() noexcept { // 标记为noexcept是一个好习惯 try { if (fileStream_.is_open()) { fileStream_.close(); // close()可能抛出异常 } } catch (const std::ios_base::failure& e) { // 在析构函数内部消化异常,只记录日志,不重新抛出 std::cerr << "警告:关闭文件时失败: " << e.what() << std::endl; } } private: std::fstream fileStream_; };

4. 异常处理的进阶话题与性能考量

4.1noexcept关键字:性能优化与契约声明

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

  1. 性能优化提示:告诉编译器该函数不会抛出异常。编译器可能基于此进行一些优化(例如,避免生成额外的栈解旋代码)。更重要的是,它影响了标准库中一些操作的行为。例如,std::vector在重新分配内存(push_back导致容量不足时)时,如果元素的移动构造函数是noexcept的,它会使用更高效的移动操作;否则,它会使用拷贝操作来保证强异常安全。

  2. 接口契约:向函数的调用者声明“我不会抛出异常”。如果标记为noexcept的函数抛出了异常,程序会直接调用std::terminate()终止。这是一种严格的承诺。

如何使用noexcept

  • 析构函数:默认就是隐式noexcept的,除非你显式指定为noexcept(false)。你应该保持析构函数为noexcept
  • 移动构造函数和移动赋值运算符:如果它们确实不会抛出异常,应该标记为noexcept。这能让你管理的对象在标准库容器中享受性能优化。
  • 交换操作swap函数通常应该标记为noexcept
  • 简单getter或数学函数:如果逻辑简单,确定不会失败(如int getValue() const { return value_; }),可以标记为noexcept
class MyType { public: // 移动操作声明为noexcept,使std::vector等能高效使用 MyType(MyType&& other) noexcept : data_(std::move(other.data_)), size_(other.size_) { other.size_ = 0; } MyType& operator=(MyType&& other) noexcept { if (this != &other) { data_ = std::move(other.data_); size_ = other.size_; other.size_ = 0; } return *this; } // 简单查询函数 int size() const noexcept { return size_; } private: std::unique_ptr<int[]> data_; int size_; };

noexcept运算符:这是一个编译期运算符,用于判断一个表达式是否声明为不抛出异常。常用于条件性noexcept声明。

template <typename T> void swap(T& a, T& b) noexcept(noexcept(a.swap(b))) { a.swap(b); } // 上面的声明意思是:swap函数是noexcept的,当且仅当a.swap(b)是noexcept的。

4.2 异常与性能:开销到底在哪里?

很多人拒绝使用异常,理由是“性能开销大”。我们需要客观分析这个开销:

  1. 无异常时的开销(零开销原则):在正常执行路径(不抛出异常)上,现代C++编译器实现的异常机制(如Itanium C++ ABI,被许多平台采用)通常几乎没有运行时开销。代码中不需要插入额外的检查指令。主要的“开销”是编译器需要生成一些额外的静态数据(异常表),用于描述栈解旋时需要清理哪些对象,这会略微增加二进制文件的大小。

  2. 抛出异常时的开销:当异常被抛出时,开销是显著的。这个过程包括:查找异常表、栈解旋(调用多个析构函数)、匹配catch子句等。这比简单地返回一个错误码要慢得多。

结论与建议

  • 异常应用于“异常”情况:异常应该用于表示那些不常发生的、严重的、程序通常无法就地恢复的错误(如内存耗尽、关键资源无法获取、严重逻辑错误)。对于频繁发生的、可预期的错误(如“文件未找到”,在交互式程序中很常见),使用错误码或std::optional可能更合适。
  • 性能关键路径:在性能极度敏感的代码段(如内层循环),如果错误检查非常频繁,使用错误码可能更好,因为检查一个布尔值或整数的开销远低于try块带来的潜在开销(即使不抛异常,进入try块也可能有极微小的代价,取决于编译器)。
  • 权衡可读性与性能:异常的最大优势是代码清晰。在复杂的错误传播场景中,异常可以避免深层次的嵌套if判断,将错误处理集中化。在大多数应用程序中,异常带来的清晰度收益远大于其在不常发生的错误路径上的性能开销。

4.3 异常规范(C++11之前)与noexcept(C++11之后)

C++98/03中有一种叫做“动态异常规范”的语法,例如void func() throw(std::runtime_error);。它声明函数可能只抛出std::runtime_error或其派生类型。这种特性在C++11中已被弃用,并在C++17中移除。原因是它运行时检查开销大,且实际效果不佳。

C++11的noexcept是它的替代品,但它是一个布尔条件(抛或不抛),而不是类型列表。noexcept主要是一个编译期和接口契约工具,而不是运行时的检查机制。

4.4 异常与多线程

在多线程程序中,异常不能跨线程传播。如果一个线程中抛出的异常没有被该线程自身捕获,程序会调用std::terminate()。因此,每个线程都应该有自己的顶层异常处理逻辑。

常见的模式是在线程入口函数的最外层包裹一个try/catch块:

void threadWorker() { try { // 线程的主要工作逻辑 doWork(); } catch (const std::exception& e) { // 将异常信息通过线程安全的方式传递回主线程,例如Promise/Future std::cerr << "工作线程异常: " << e.what() << std::endl; // 或者设置promise的值 // promise.set_exception(std::current_exception()); } catch (...) { std::cerr << "工作线程未知异常" << std::endl; } } int main() { std::thread t(threadWorker); // ... 其他逻辑 t.join(); return 0; }

C++11的<future>库提供了std::promisestd::future,可以更方便地在线程间传递异常。在线程中,你可以通过promise.set_exception(std::current_exception())来捕获并存储异常;在主线程中,调用future.get()时,如果工作线程抛出了异常,这个异常会在主线程中重新抛出。

5. 异常处理的最佳实践与常见陷阱

5.1 何时该用异常?何时不该用?

应该使用异常的场景:

  1. 构造函数失败:构造函数没有返回值,报告失败的最佳方式就是抛出异常。
  2. 操作符重载失败:例如operator new(内存分配失败)、operator/(除零错误)。
  3. 无法就地处理的错误:在函数调用链的深层发生的错误,需要让上层调用者知晓并处理。
  4. 不可恢复的错误:如内存耗尽、系统关键资源不可用等,通常需要终止程序或进行高层面的恢复。
  5. 逻辑错误:表示程序员的错误,如传入非法参数、违反前置条件等(使用std::invalid_argument,std::logic_error)。

可能不适合使用异常的场景:

  1. 频繁发生的、可预期的错误:例如,解析用户输入时格式错误很常见,使用错误码或std::optional/std::expected(C++23)可能更清晰高效。
  2. 性能极度敏感的代码路径:如实时系统的核心循环。
  3. 与C语言或没有异常机制的代码交互:跨越语言边界时,异常无法传播。通常需要在边界处捕获所有C++异常,并将其转换为错误码。
  4. 内存非常受限的嵌入式环境:异常机制需要额外的运行时支持(RTTI、异常表等),可能会增加代码体积。

5.2 常见陷阱与避坑指南

  1. 陷阱:在析构函数中抛出异常

    • 后果:可能导致程序立即终止(std::terminate)。
    • 避坑:确保析构函数不抛出异常。如果调用的函数可能抛异常,用try/catch在内部消化。
  2. 陷阱:吞掉所有异常(catch(...)但不做任何事)

    • 后果:错误被静默忽略,程序在未知的错误状态下继续运行,可能导致更严重的问题。
    • 避坑catch(...)应该用于记录日志、清理资源,然后要么重新抛出(throw;),要么以可控的方式终止程序。除非你百分百确定忽略是安全的(这种情况极少)。
    // 错误示例 try { riskyOp(); } catch (...) {} // 静默吞掉,非常危险! // 较好示例 try { riskyOp(); } catch (...) { logFatalError("Unknown exception in critical section"); cleanupResources(); // 尝试清理 std::exit(EXIT_FAILURE); // 或重新抛出 throw; }
  3. 陷阱:通过指针抛出在栈上分配的对象

    • 后果:捕获者可能试图delete一个栈对象,导致未定义行为。
    • 避坑:总是通过值抛出异常对象(编译器会管理其生命周期)。或者,如果你必须使用指针,确保它是动态分配的,并且有明确的归属约定(但强烈不推荐,用值抛出更简单安全)。
  4. 陷阱:异常安全漏洞——资源泄露

    • 后果:内存、文件句柄、锁等资源在异常发生时没有释放。
    • 避坑:严格遵守RAII原则,使用智能指针、容器、锁守卫等管理资源。
  5. 陷阱:异常规格不匹配(历史遗留问题)

    • 后果:如果函数抛出了其(已弃用的)动态异常规范中未列出的类型,会调用std::unexpected(),默认终止程序。
    • 避坑:不要再使用C++98风格的throw()异常规范。使用C++11的noexcept

5.3 错误处理策略的混合使用

在实际项目中,异常和错误码(或返回状态)不是互斥的,可以结合使用:

  • 模块/库的边界:如果一个库需要被多种语言(如C)调用,或者需要最大程度的兼容性,其公共API可能选择使用错误码。但在库的内部实现中,仍然可以使用异常来简化错误处理。
  • 快速失败与可恢复错误:对于程序员的逻辑错误(如断言失败),可以使用异常(如throw std::logic_error)快速失败。对于运行时环境错误,可以根据是否常见、是否可恢复,选择异常或错误码。
  • 使用std::optionalstd::expected:C++17的std::optional和C++23的std::expected提供了另一种优雅的错误处理方式,特别适合那些“可能有结果,也可能没有”的场景,比如查找、解析。
// 使用 std::optional 表示可能失败的操作 std::optional<int> parseInteger(const std::string& str) { try { return std::stoi(str); } catch (const std::invalid_argument&) { return std::nullopt; // 解析失败,返回空值 } catch (const std::out_of_range&) { return std::nullopt; } } // 调用方代码清晰 if (auto num = parseInteger(input)) { use(*num); } else { std::cout << "输入的不是有效整数" << std::endl; }

5.4 设计易于调试的异常信息

当异常被抛出时,清晰的错误信息至关重要。除了what()返回的字符串,有时还需要更多上下文。

  • 嵌套异常:C++11引入了std::nested_exceptionstd::throw_with_nested,允许你将低层异常包装在高层异常中,形成一个异常链,类似于Java的Throwable.getCause()。这在调试复杂调用栈时非常有用。
#include <stdexcept> #include <exception> #include <sstream> void lowLevelFunction() { throw std::runtime_error("磁盘IO失败"); } void highLevelFunction() { try { lowLevelFunction(); } catch (...) { std::stringstream ss; ss << "高层操作失败,原因: "; // 将当前捕获的异常与一个新的异常嵌套抛出 std::throw_with_nested(std::runtime_error(ss.str())); } } int main() { try { highLevelFunction(); } catch (const std::exception& e) { std::cerr << "捕获异常: " << e.what() << std::endl; // 尝试解嵌套 try { std::rethrow_if_nested(e); } catch (const std::exception& nested) { std::cerr << " 嵌套原因: " << nested.what() << std::endl; } } return 0; } // 输出可能: // 捕获异常: 高层操作失败,原因: // 嵌套原因: 磁盘IO失败
  • 记录堆栈信息:在调试版本中,你可以考虑在自定义异常类的构造函数中捕获当前的调用堆栈信息(可以使用平台相关API,如Linux的backtrace,Windows的CaptureStackBackTrace),并将其存储在异常对象中。当异常被捕获时,可以打印出堆栈轨迹,极大地方便定位问题。但这通常会增加开销,所以可能只用于调试构建。

6. 实战:一个异常安全的简单内存池设计

为了将以上概念融会贯通,我们设计一个极度简化的、异常安全的内存池。这个例子会展示RAII、强异常保证、移动语义与noexcept的结合。

目标:一个固定块大小的内存池。分配和释放操作提供强异常保证或不抛掷保证。

#include <memory> #include <vector> #include <stdexcept> #include <cstddef> class SimpleMemoryPool { struct Block { Block* next; // 指向下一个空闲块 // 这里可以放置用户数据,为了简单,我们只管理指针 }; public: // 构造函数:预分配一批内存块 explicit SimpleMemoryPool(std::size_t blockSize, std::size_t initialBlocks) : blockSize_(std::max(blockSize, sizeof(Block))) // 块大小至少能存一个Block指针 , storage_(std::make_unique<std::byte[]>(blockSize_ * initialBlocks)) { if (blockSize_ < sizeof(Block)) { throw std::invalid_argument("块大小必须至少能容纳一个指针"); } if (!storage_) { throw std::bad_alloc(); } // 将分配的大内存块切割成小块,并组织成空闲链表 std::byte* raw = storage_.get(); for (std::size_t i = 0; i < initialBlocks; ++i) { Block* block = reinterpret_cast<Block*>(raw + i * blockSize_); block->next = freeList_; freeList_ = block; } } // 禁止拷贝(资源独占) SimpleMemoryPool(const SimpleMemoryPool&) = delete; SimpleMemoryPool& operator=(const SimpleMemoryPool&) = delete; // 支持移动(noexcept,因为unique_ptr和指针的移动是noexcept的) SimpleMemoryPool(SimpleMemoryPool&& other) noexcept : blockSize_(other.blockSize_) , storage_(std::move(other.storage_)) , freeList_(other.freeList_) { other.blockSize_ = 0; other.freeList_ = nullptr; } SimpleMemoryPool& operator=(SimpleMemoryPool&& other) noexcept { if (this != &other) { // 先清理自身资源(析构是noexcept的) // 然后接管对方资源 blockSize_ = other.blockSize_; storage_ = std::move(other.storage_); freeList_ = other.freeList_; other.blockSize_ = 0; other.freeList_ = nullptr; } return *this; } // 分配一块内存:提供强异常保证 void* allocate() { if (freeList_ == nullptr) { // 空闲列表为空,尝试扩容(可能抛std::bad_alloc) expandPool(); // 如果expandPool成功返回,则freeList_不为空 } // 从空闲链表头部取出一块 Block* allocatedBlock = freeList_; freeList_ = freeList_->next; // 将返回的指针指向块内用户数据区(跳过Block头) return reinterpret_cast<std::byte*>(allocatedBlock) + sizeof(Block); } // 释放一块内存:提供不抛掷保证 void deallocate(void* ptr) noexcept { if (ptr == nullptr) { return; // 允许释放空指针,符合常规 } // 将用户指针转换回Block指针 std::byte* blockStart = reinterpret_cast<std::byte*>(ptr) - sizeof(Block); Block* blockToFree = reinterpret_cast<Block*>(blockStart); // 将块插回空闲链表头部 blockToFree->next = freeList_; freeList_ = blockToFree; } ~SimpleMemoryPool() noexcept = default; // unique_ptr的析构是noexcept的 private: std::size_t blockSize_ = 0; std::unique_ptr<std::byte[]> storage_; // RAII管理底层大内存 Block* freeList_ = nullptr; // 空闲链表头 // 扩容池子:私有函数,为allocate提供强保证 void expandPool() { const std::size_t newBlocks = 10; // 简单策略:每次扩10块 // 1. 分配新的大内存块(可能抛std::bad_alloc) auto newStorage = std::make_unique<std::byte[]>(blockSize_ * newBlocks); // 2. 将新块链入空闲链表(此操作不会抛异常) std::byte* raw = newStorage.get(); for (std::size_t i = 0; i < newBlocks; ++i) { Block* newBlock = reinterpret_cast<Block*>(raw + i * blockSize_); newBlock->next = freeList_; freeList_ = newBlock; } // 3. 关键:只有上面所有步骤都成功,才交换/合并存储。 // 这里我们简单地将新存储移动到成员变量,旧存储会被自动释放。 // 由于我们只是将新块加入了freeList_,旧storage_仍然有效(装着已分配出去的块), // 所以不能直接替换。我们需要管理多个storage_块。 // 为了简化示例,我们假设只有一个storage_,扩容时替换它(要求之前分配出去的块都已归还)。 // 更完善的实现需要维护一个storage_列表。 // 本例重点在异常安全,我们采用简化模型:扩容前池必须为空。 if (freeList_ != reinterpret_cast<Block*>(newStorage.get())) { // 说明还有块未归还,不能简单替换storage_。 // 在实际项目中,这里应抛出异常或实现更复杂的管理。 // 为了演示强保证,我们选择抛出异常,且此时尚未修改任何可见状态。 throw std::runtime_error("Cannot expand pool while blocks are still allocated"); } // 所有新块都已加入freeList_,现在可以安全替换storage_ storage_ = std::move(newStorage); // 如果上面任何一步(尤其是newStorage分配)失败抛出异常, // 则freeList_和storage_都保持原样(强保证)。 } };

这个设计如何体现异常安全?

  1. RAII:底层内存由std::unique_ptr<std::byte[]>管理,无论构造函数成功还是失败(比如initialBlocks参数为0),内存都会自动释放。
  2. 构造函数中的异常:如果std::make_unique失败(抛std::bad_alloc),storage_尚未被赋值,因此其析构函数不会被调用,没有资源泄露。如果参数检查失败,我们抛出std::invalid_argument,同样安全。
  3. allocate()的强保证
    • 如果freeList_不为空,操作只是指针操作,不会抛异常。
    • 如果freeList_为空,需要调用expandPool()
    • expandPool()中,先分配新内存(newStorage)。如果失败,异常直接抛出,freeList_storage_状态不变。
    • 然后链接新块到freeList_(不会抛异常)。
    • 最后进行状态更新(替换storage_)。如果在这之前或之中有任何问题,我们抛出异常,此时newStorage(临时对象)的析构函数会自动释放刚分配的内存,而freeList_storage_仍然指向旧的有效状态。
  4. deallocate()的不抛掷保证:标记为noexcept,只进行指针操作,绝不会抛出异常。这很重要,因为它可能被析构函数调用。
  5. 移动操作标记为noexcept:这使得SimpleMemoryPool对象可以被安全地放入std::vector等容器中,容器在重新分配内存时会使用高效的移动而非拷贝。

这个例子虽然简化,但展示了在资源管理类中系统性地应用异常安全原则的思路。在实际项目中,你需要根据具体需求(如线程安全、更复杂的分配策略等)进行扩展。