Skip to content
cxxzsh
Go back

RAII:为什么资源应该绑定对象生命周期

资源获取之后,最终通常需要执行一次对应的释放操作。例如,互斥锁在使用后需要解锁:

std::mutex mutex;

void update()
{
  mutex.lock();

  if (!ready()) {
    return; // 忘记解锁
  }

  mutate();
  mutex.unlock();
}

正常执行到函数结尾时没有问题,但提前 return 会跳过 unlock()。如果 mutate() 抛出异常,也会产生相同的问题。

手动释放资源要求每条退出路径都正确执行清理操作。函数越复杂,遗漏的可能性越大。

Table of contents

Open Table of contents

RAII 是什么

RAII 是 Resource Acquisition Is Initialization 的缩写。它把资源有效期映射到对象生命周期:

对于具有自动存储期的局部对象,离开作用域时会自动调用析构函数。正常结束、提前 return 和异常导致的栈展开,都会触发这个过程。

这是一种确定性的清理机制。资源何时释放由对象生命周期决定,不依赖垃圾回收器何时运行,也不要求调用方记住每条退出路径上的清理操作。

资源不只包括动态分配的内存,还包括:

RAII 的重点不是资源存放在哪里,而是谁拥有资源,以及哪个对象负责在生命周期结束时释放资源。

用对象管理锁

标准库提供了 std::lock_guard

std::mutex mutex;

void update()
{
  std::lock_guard<std::mutex> lock(mutex);

  if (!ready()) {
    return;
  }

  mutate();
}

lock 初始化时会锁住 mutex,离开作用域时会析构并自动解锁。函数中不再需要手动调用 unlock()

这段代码仍然可能因为其他原因出错,例如锁的粒度不合适,或者多个锁之间形成死锁。RAII 只保证已经获取的锁会在对象析构时释放。

封装文件句柄

C 标准库中的文件句柄需要手动关闭:

std::FILE* handle = std::fopen("result.txt", "w");

if (!handle) {
  throw std::runtime_error("failed to open file");
}

std::fputs("hello\n", handle);
std::fclose(handle);

如果在 fopen()fclose() 之间提前返回或抛出异常,文件句柄就不会被关闭。

可以用一个对象封装文件句柄:

#include <cstdio>
#include <stdexcept>
#include <string_view>
#include <utility>

class File
{
public:
  explicit File(const char* path)
    : handle_(std::fopen(path, "w"))
  {
    if (!handle_) {
      throw std::runtime_error("failed to open file");
    }
  }

  ~File() noexcept
  {
    close();
  }

  File(const File&) = delete;
  File& operator=(const File&) = delete;

  File(File&& other) noexcept
    : handle_(std::exchange(other.handle_, nullptr))
  {
  }

  File& operator=(File&& other) noexcept
  {
    if (this != &other) {
      close();
      handle_ = std::exchange(other.handle_, nullptr);
    }

    return *this;
  }

  void write(std::string_view text)
  {
    if (!handle_) {
      throw std::logic_error("file is not open");
    }

    if (std::fwrite(text.data(), 1, text.size(), handle_) != text.size()) {
      throw std::runtime_error("failed to write file");
    }
  }

private:
  void close() noexcept
  {
    if (handle_) {
      std::fclose(handle_);
      handle_ = nullptr;
    }
  }

  std::FILE* handle_ = nullptr;
};

使用时不再手动调用 fclose()

void save()
{
  File file("result.txt");
  file.write("hello\n");
}

save() 结束时,file 会析构,文件句柄随之关闭。如果 write() 之后又增加了可能抛出异常的操作,清理逻辑仍然有效。

File 表达独占所有权,因此不能复制。否则两个对象会管理同一个文件句柄,并在析构时重复关闭它。

移动构造函数和移动赋值运算符会转移句柄,并把源对象置为空。移动后的 File 仍然可以析构或重新赋值,但不能继续写入。移动操作标记为 noexcept,因为它们只转移句柄,不需要分配资源。

析构函数同样标记为 noexceptstd::fclose() 仍然可能失败,但析构阶段没有可靠的错误返回通道,尤其是在异常展开过程中。真实项目如果必须处理关闭失败,通常需要额外提供显式的 close()flush() 操作,在对象析构前完成可报告错误的收尾流程。

实际项目中,写文件通常优先使用标准库已经提供的 std::ofstream。上面的封装用于展示低层 RAII 类型需要处理的设计问题。

成员构造失败时,谁负责清理

局部对象离开作用域时会析构,这比较直观。更容易忽略的是:即使外层对象没有构造成功,已经构造完成的成员也会被清理。

#include <cstddef>
#include <vector>

class BufferedFile
{
public:
  BufferedFile(const char* path, std::size_t buffer_size)
    : file_(path)
    , buffer_(buffer_size)
  {
  }

private:
  File file_;
  std::vector<char> buffer_;
};

成员按照声明顺序初始化,与构造函数初始化列表中的书写顺序无关。这里会先构造 file_,再构造 buffer_

假设文件已经成功打开,但 buffer_ 分配内存时抛出异常:

对象构造状态是否调用析构函数
file_已经构造完成
buffer_构造失败
BufferedFile没有构造完成

BufferedFile 的析构函数不会运行,因为这个对象从未构造完成。但 file_ 已经是一个完整对象,栈展开时会调用它的析构函数并关闭文件。buffer_ 自己没有构造完成,因此不会调用它的析构函数;它在构造过程中已经完成的内部清理,由 std::vector 自己负责。

BufferedFile 不需要编写析构函数,也不需要捕获异常后手动关闭文件。Filestd::vector 分别管理自己的资源,高层类型通过组合它们获得构造阶段的异常安全。相同规则也适用于已经构造完成的基类子对象。

如果所有成员都构造成功,销毁外层对象时会按照声明顺序的反方向析构。成员之间存在生命周期依赖时,应当先声明被依赖的成员,让依赖它的成员更晚构造、更早析构。

这里保证的是文件句柄不会泄漏,不保证失败后系统状态会恢复到操作之前。打开文件可能已经创建或截断了文件;后续构造失败时,RAII 会关闭文件,但不会自动删除新文件或恢复被截断的内容。

如果要求操作要么全部成功,要么失败后看起来什么都没有发生,还需要结合临时文件、原子重命名或其他提交策略。

Rule of Zero 和 Rule of Five

Rule of Zero 不是“不实现任何构造函数”。它关注的是不要手写负责资源所有权的析构、复制和移动操作。

高层类型仍然可以实现非复制、非移动构造函数,用来接收参数、校验输入和建立对象不变量:

class Document
{
public:
  explicit Document(std::string name)
    : name_(std::move(name))
  {
    if (name_.empty()) {
      throw std::invalid_argument("document name is empty");
    }
  }

private:
  std::string name_;
  std::vector<std::string> paragraphs_;
};

Document 有一个用户提供的、带参数的构造函数,但仍然符合 Rule of Zero,因为它没有声明析构、复制构造、复制赋值、移动构造和移动赋值。

对于这个 Document,编译器仍然会隐式声明 Rule of Five 所说的五个函数:

函数是否隐式声明实际行为
析构函数分别析构 name_paragraphs_
复制构造函数分别复制 name_paragraphs_
复制赋值运算符分别复制赋值 name_paragraphs_
移动构造函数分别移动 name_paragraphs_
移动赋值运算符分别移动赋值 name_paragraphs_

这些函数通常只在程序确实需要使用时才会被隐式定义。成员自己负责资源管理,因此编译器生成的成员级操作已经足够。

所以,如果“实现自己的构造函数”指的是 Document(std::string) 这类非复制、非移动构造函数,答案是:编译器仍然可以隐式声明这五个函数。

可以不传实参调用的构造函数称为默认构造函数。最常见的是没有参数的 Document(),但所有参数都有默认实参的构造函数也属于默认构造函数。

struct A
{
}; // 编译器隐式声明默认构造函数

struct B
{
  B() = default; // 显式默认化的默认构造函数
};

struct C
{
  C() {} // 用户提供的默认构造函数
};

struct D
{
  explicit D(int size = 0); // 仍然是默认构造函数
};

只要类声明了构造函数或构造函数模板,编译器就不会再隐式声明默认构造函数。如果类型设计上存在合法的默认状态,并且还需要支持 Document document;,就必须显式提供默认构造函数。用户提供的构造函数不会因为自身存在,就要求手写复制、移动或析构操作。

隐式声明也不代表函数一定可用。如果成员或基类不支持对应操作,默认化的函数可能被定义为删除。例如,包含 std::unique_ptr 的类型通常仍会隐式声明复制构造和复制赋值,但这两个函数会因为 std::unique_ptr 不可复制而被定义为删除。

编译器生成的成员级复制和移动只有在能够保持类型不变量时才适用。如果类型保存了指向自身成员的指针、外部注册关系或其他不能直接按成员复制的状态,就需要重新审视复制和移动语义。

如果“实现自己的构造函数”指的是复制构造函数或移动构造函数,规则就不同。用户声明复制构造、移动构造、复制赋值、移动赋值或析构中的任何一个,都不应再假设编译器会自动提供完整的移动语义。此时应当逐项决定其他操作是 = default= delete,还是需要自定义实现。

File 位于更低的一层:它直接持有原始文件句柄,因此必须自定义析构逻辑。一旦自定义析构、复制或移动操作,就应该一起审视 Rule of Five 所说的五个特殊成员函数:

为什么移动经常可以标记为 noexcept

拷贝和移动的核心区别不是参数类型,而是操作完成后是否必须保留源对象的值。

拷贝需要让源对象和目标对象同时保持原来的值。对于持有资源的类型,这通常意味着申请一份新的资源:

内存分配、句柄复制和元素复制都可能失败,因此这类复制构造函数可能抛出异常。不过,拷贝并不是必然抛异常;复制 int 或只包含固定大小数值成员的类型通常不会抛异常。

移动允许修改源对象。File 的移动构造函数不需要打开第二个文件,只需要把句柄交给目标对象,再把源对象置为空:

File(File&& other) noexcept
  : handle_(std::exchange(other.handle_, nullptr))
{
}

这段操作不申请新资源,也不调用可能失败的外部操作,因此可以诚实地标记为 noexcept

移动也不是必然不抛异常。如果移动实现需要分配内存,或者某个成员的移动构造函数可能抛异常,那么外层类型的移动也可能抛异常。编译器生成的移动构造函数会逐个移动基类和成员,它是否为 noexcept 取决于这些子对象的移动操作。

noexcept 是接口承诺,不是让函数自动变得安全的开关。如果标记为 noexcept 的移动操作仍然让异常逃出,程序会调用 std::terminate()

std::move 本身也不会执行移动,更不会让操作变成 noexcept。它只是把表达式转换为可以匹配移动操作的右值。

类似 std::vector 的容器在重新分配存储空间时,需要把已有元素转移到新空间。如果元素的移动构造函数可能抛异常,但复制构造函数可用,容器可能选择复制元素,以便失败时保留旧元素不变。一个真实可靠的 noexcept 移动构造函数可以让容器放心地使用移动。

独占资源类型通常删除复制操作,并实现 noexcept 移动。更高层的业务类型则应尽量通过组合已有 RAII 类型回到 Rule of Zero。

RAII 和栈不是一回事

RAII 经常和局部变量一起出现,但它不等于“把资源放在栈上”。

File 示例里:

资源管理对象也可以作为另一个对象的成员,或者由其他 RAII 对象间接管理。关键在于:资源的释放操作由某个对象的析构函数负责。

智能指针也是 RAII 的应用,但 RAII 不等于智能指针。std::unique_ptr 管理动态分配的对象,std::lock_guard 管理锁,std::ofstream 管理文件。它们使用的是同一个思路。

RAII 也可以管理临时状态

RAII 不只负责调用 close()unlock()delete。它也可以在作用域结束时恢复状态或执行回滚:

class Transaction
{
public:
  explicit Transaction(Database& database)
    : database_(database)
  {
    database_.begin();
  }

  ~Transaction() noexcept
  {
    if (!committed_) {
      database_.rollback_noexcept();
    }
  }

  void commit()
  {
    database_.commit();
    committed_ = true;
  }

private:
  Database& database_;
  bool committed_ = false;
};

如果调用方没有显式执行 commit(),析构函数会自动回滚。相同思路也适用于恢复临时配置、结束性能 trace 和撤销注册操作。

这里假设 rollback_noexcept() 已经定义了失败处理策略。析构函数不能简单地把异常抛给调用方。错误记录、重试、终止进程还是交给上层恢复策略,需要根据业务约束单独设计。

RAII 和智能指针

智能指针是 RAII 在动态对象所有权上的应用:

类型语义
std::unique_ptr独占所有权
std::shared_ptr共享所有权
std::weak_ptr不延长生命周期的观察关系

RAII 解决资源清理时机,智能指针进一步表达动态对象的所有权。std::shared_ptr 的控制块、循环引用、线程安全边界和 API 设计属于独立主题。

边界

RAII 解决的是资源释放时机问题,不会自动解决所有资源管理问题:

RAII 提供的是资源安全的基础机制,不会替代所有权设计、并发设计和错误恢复策略。

小结



Next Post
从一条编译命令开始理解 C++ 程序