back_to_basics_raii_and_the_rule_of_zeroarthur_odwyercppcon_2019.pdf

//RAII: Resource acquisition is initialization。
RAII这个名字本身,告诉我们了什么?获取资源时(Resource acquisition),要初始化一个管理资源的类对象(Initialization)。这样,我们就可以利用C++类对象析构函数来自动清理资源了。
使用RAII,不仅仅让我们更方便的管理资源,同时也是在C++中,我们安全使用资源的唯一方式。如果不想泄漏资源,就不要使用无RAII保护的资源。

使用RAII取代手动资源管理

C++中,我们可能会遇到的资源管理代码包括下面这些:

  • malloc, free
  • fopen, fclose
  • new, delete
  • new[], delete[]
  • lock, unlock

如果忘记编写成对的资源释放代码,就会造成资源泄漏。即使我们记住写资源释放代码,由于C++支持异常,资源释放代码也很可能不被执行:

  1. void doSthBySomeObj() {
  2. auto *someObj = new SomeObj;
  3. auto *otherObj = new otherObj;
  4. someObj.doSth(); // may throw exception?
  5. otherObj.doOtherThing(); // may throw exception?
  6. delete someObj;
  7. delete otherObj;
  8. }

我们可能会想,加一个try-catch就可以了。确实如此,不过C++和Java不一样,C++中,每一个方法都可能会抛出异常,而且C++方法不需要声明可能抛出异常的类型。为了安全,我们需要在每一处使用到资源的地方加try-catch,而且还要将异常再次传播出去。

此外,这样的代码一旦变得更加复杂,维护难度会逐步变高,考虑如下的代码:

  1. void add_with_modification(int delta) {
  2. mu_.lock();
  3. // ... more code here
  4. value_ += delta;
  5. // Check for overflow.
  6. if (value_ > MAX_INT) {
  7. // Oops, forgot to unlock() before exit
  8. return;
  9. }
  10. // ... more code here
  11. mu_.unlock();
  12. }

如果使用RAII,我们就只需要正确初始化资源持有类,依赖资源持有类的destructor来释放资源。C++会保证,不论以任何方式离开当前作用域时,销毁在当前作用域内创建的对象,调用它们的destructor。

上面的代码,通过RAII优化后,写成如下这样:

  1. void doSthBySomeObj() {
  2. auto someObj = std::make_unique<SomeObj>();
  3. auto otherObj = std::make_unique<OtherObj>();
  4. someObj.doSth(); // may throw exception
  5. otherObj.doOtherThing(); // may throw exception
  6. }
  7. void add_with_modification(int delta) {
  8. std::lock_guard(mu_);
  9. // ... more code here
  10. value_ += delta;
  11. // Check for overflow.
  12. if (value_ > MAX_INT) {
  13. // Oops, forgot to unlock() before exit
  14. return;
  15. }
  16. // ... more code here
  17. }

标准库提供的RAII

  • std::lock_guard, std::unique_lock
  • std::shared_ptr, std::unique_ptr, std::weak_ptr
  • 对于指针和锁之外的资源,也可以通过 std::unique_ptr 自动释放,只要传入一个deletor即可

如何实现一个RAII类

首先,让我们定义什么是RAII类。
RAII类是自己管理资源的类,这种类在对象初始化时获取资源,在对象销毁时释放资源。

为了实现资源管理逻辑,RAII类需要实现自己的构造函数(获取资源)和析构函数(释放资源)。如果要支持copy构造、move构造、copy赋值、move赋值的话,还需要提供对应的函数,如果不想支持的话,需要将它们标记删除:

  • 拷贝构造函数 Copy Constructor
    • T(const T& other)
  • 移动构造函数 Move Constructor
    • T(T&& other)
  • 拷贝赋值函数 Copy assignment opearator
    • T& operator=(const T& other)
  • 移动赋值函数 Move assignment operator
    • T& operator=(T&& other)
  • 析构函数 Destructor
    • ~T()

在实现自己的RAII类的时候,赋值函数需要特别关注:

  • 避免在“自赋值(self-assignment)”时破坏数据结构。
    • 为了支持自赋值,copy-and-swap操作是推荐的实现赋值函数的方案。
  • 记住在move constructor中,要接管rvalue reference的资源,也就是说,要清空rvalue reference对象属性,确保rvalue销毁时,不释放被我们接管过来的资源。
  • 记住在move assignment中,除了接管rvalue reference的资源外,还需要清理当前对象资源。
    • 可以将当前对象属性和rvalue reference交换,来利用rvalue reference destructor来清理当前对象资源。

在实现RAII类时,可以只实现一个by value传值的赋值函数。
RAII赋值函数一般要进行 copy-and-swap 操作,不论是拷贝赋值函数还是移动赋值函数,都要先做copy操作;通过将参数定义为by-value传递的,函数调用发生时,copy操作就已经自动完成了,参数就是一个临时copy。

下面的类定义是一个标准模板:

  1. class Vec {
  2. private:
  3. int *ptr_;
  4. size_t size_;
  5. public:
  6. Vec() : ptr_(nullptr), size_(0) {}
  7. ~Vec() { delete [] ptr_; }
  8. Vec(const Vec& rhs) {
  9. ptr_ = new int[rhs.size_];
  10. size_ = rhs.size_;
  11. std::copy(rhs.ptr_, rhs.ptr_ + size_, ptr_);
  12. }
  13. Vec(Vec&& rhs) noexcept {
  14. // std::exchange的作用是,将第一个参数赋值为第二个参数,然后返回第一个参数一开始的值
  15. // 下面如果不用exchange,直接 ptr_ = rhs.ptr_ 会有什么问题?
  16. ptr_ = std::exchange(rhs.ptr_, nullptr);
  17. size_ = std::exchange(rhs.size_, 0);
  18. }
  19. Vec& operator=(Vec copy) {
  20. // 下面如果不互换copy和this的属性,而是直接 this->ptr_ = copy.ptr_ 会有什么问题?
  21. copy.swap(*this);
  22. return *this;
  23. }
  24. void swap(Vec& rhs) noexcept {
  25. using std::swap;
  26. swap(ptr_, rhs.ptr_);
  27. swap(size_, rhs.size_);
  28. }
  29. }

Rule Of Five

Rule Of Five是一个C++的最佳实践,指的是:当定义类时,如果我们要自己定义五个编译器默认提供实现的特殊函数中的任意一个,那么就要同时明确定义五个函数。
这样做的原因有两点:

  1. 一个类需要自定义这五个函数的原因一般是为了管理资源,而一个需要管理资源的类,需要同时处理好资源的转移(copy/move constructor, copy/move assignment)和资源的清理(destructor)。
  2. C++对于什么时候应该自动生成默认实现,有一套不太容易记住的规则,如果不显示定义这五个函数,我们很可能会看不出来究竟哪个函数是由C++默认提供的,哪个又没有默认提供,以及看不出来编译器提供的实现,参数究竟是 by-const-reference 传递的还是 by-reference 传递的。

尽可能不要实现自己的RAII类(Rule of zero)

如果可能,我们应该使用标准库中的资源管理类,避免自己管理资源,这样,构造、赋值、析构函数都可以用编译器默认实现的版本。