CppCon 2019 | Back to Basics: RAII and The Rule of Zero

本文整理了Arthur O’Dwyer在CppCon 2019上關於RAII的演講,演講的slides可以在此鏈接進行下載。

在C++程式中,我們往往需要管理各種各樣的資源。資源通常包括以下幾種:

  • Allocated memory (malloc/free, new/delete, new[]/delete[])
  • POSIX file handles (open/close)
  • C File handles (fopen/fcolse)
  • Mutex locks (pthread_mutex_lock/pthread_mutex_unlock)
  • C++ threads (spawn/join)

上面這些資源,有些的管理權是獨佔的(比如mutex locks),而另一些的管理權則可以是共享的(比如堆、文件句柄等)。重要的是,程式需要採取一些明確的措施才能釋放資源。下面,我們將以經典的堆分配為例,來說明資源管理中的若干問題。

下面的程式碼實現了一個非常樸素的向量類,它提供了push_back介面,每次調用push_back都會釋放舊資源,然後申請新資源。

class NaiveVector {
 public:
  int *ptr_;
  size_t size_;
  
  NaiveVector()  : ptr_(nullptr), size_(0) {}
  void push_back(int newvalue) {
    int *newptr = new int[size_ + 1];
    std::copy(ptr_, ptr_ + size_, newptr);
    delete [] ptr_;
    ptr_ = newptr;
    ptr_[size_++] = newvalue;
  }
};

在上面程式碼的第6行,構造函數正確地初始化了ptr_size_。在push_back函數的實現中,也正確地實現了資源的申請和釋放。到目前為止,一切看起來都如我們所願,沒有發生任何的資源泄漏。

{
  NaiveVector vec;   // here ptr_ is initialized with 0 elements
  vec.push_back(1);  // ptr_ is correctly updated with 1 element
  vec.push_back(2);  // ptr_ is correctly updated with 2 elements
}

考慮上面這塊程式碼,在作用域中,我們創建了一個NaiveVector類型的對象vec,然後調用兩次push_back函數。每次調用push_backptr_所指向的資源將會被釋放,然後指向一個新申請的資源。當離開作用域時,局部對象vec被銷毀,但此時vec對象中的ptr_成員仍然指向著某個資源,在銷毀vec對象時,該資源並沒有被釋放,這就導致了資源的泄露。

顯然,為了防止資源泄漏,我們需要在銷毀vec對象時正確地釋放掉它所管理的那些資源。注意到在創建某個類型的對象時,編譯器會調用該類型的構造函數;相應地,當某個對象的生命周期結束時,編譯器會調用析構函數來銷毀該類型的對象。還是以上面的程式碼為例,在第2行編譯器調用NaiveVector的構造函數創建對象;在第5行離開作用域時,編譯器會調用析構函數銷毀局部對象vec。因此,我們只需要實現一個析構函數並在其中釋放掉所管理的資源,就能避免對象析構時的資源泄漏。新版的NaiveVector實現如下所示,其中第14行實現了析構函數。

class NaiveVector {
 public:
  int *ptr_;
  size_t size_;
  
  NaiveVector()  : ptr_(nullptr), size_(0) {}
  void push_back(int newvalue) {
    int *newptr = new int[size_ + 1];
    std::copy(ptr_, ptr_ + size_, newptr);
    delete [] ptr_;
    ptr_ = newptr;
    ptr_[size_++] = newvalue;
  }
  ~NaiveVector() { delete [] ptr_; }
};

然而,實現了析構函數以後,NaiveVector仍然會導致資源泄漏,這是由對象的拷貝操作引起的。如果我們沒有為該類實現拷貝構造函數,那麼編譯器會生成一個合成的拷貝構造函數。合成拷貝構造函數的行為非常簡單,它會逐一拷貝對象中的每個成員。對於指針類型的成員來說,它僅拷貝指針的值。

{
  NaiveVector v;
  v.push_back(1);
  {
    NaiveVector w = v;
  }
  std::cout << v[0] << "\n";
}

上面程式碼的第5行調用了NaiveVector類型的合成拷貝構造函數。拷貝操作完成後,w.ptr_v.ptr_指向同一塊記憶體資源。當執行到第6行時,離開了w對象的作用域,編譯器會調用w的析構函數來釋放w.ptr_所管理的資源並銷毀該對象。由於w.ptr_v.ptr_指向同一塊資源,而這一塊資源已經被w的析構函數釋放掉了,因此在第7行對v[0]的訪問就成了未定義行為。此外,在第8行離開v對象的作用域時,編譯器又會調用v的析構函數來釋放資源,這就導致了對同一塊資源的重複釋放,這同樣是一個未定義行為。

正確地實現拷貝構造函數可以解決上述問題。換句話說,如果我們為某個類實現了析構函數,那麼我們同樣需要為它實現拷貝構造函數。析構函數負責釋放資源以避免泄漏,而拷貝構造函數負責拷貝資源以避免重複釋放。下面的程式碼實現了相應的拷貝構造函數。

class NaiveVector {
 public:  
  int *ptr_;
  size_t size_;
  
  NaiveVector()  : ptr_(nullptr), size_(0) {}
  ~NaiveVector() { delete [] ptr_; }

  NaiveVector(const NaiveVector& rhs) {
    ptr_ = new int[rhs.size_];
    size_ = rhs.size_;
    std::copy(rhs.ptr_, rhs.ptr_ + size_, ptr_);
  }
};

僅僅實現拷貝構造函數還不夠,我們還需要實現拷貝賦值運算符。類似於合成拷貝構造函數,合成拷貝賦值運算符同樣是拷貝每個成員的值。當離開對象的作用域時,合成拷貝賦值運算符同樣會導致資源的重複釋放。因此,我們還需要實現拷貝賦值運算符。下面的程式碼正確地實現了拷貝賦值運算符。

class NaiveVector {
  int *ptr_;
  size_t size_;
 public:
  NaiveVector()  : ptr_(nullptr), size_(0) {}
  ~NaiveVector() { delete [] ptr_; }

  NaiveVector(const NaiveVector& rhs) {
    ptr_ = new int[rhs.size_];
    size_ = rhs.size_;
    std::copy(rhs.ptr_, rhs.ptr_ + size_, ptr_);
  }
  
  NaiveVector& operator=(const NaiveVector& rhs) {
    NaiveVector copy = rhs;
    copy.swap(*this);
    return *this;
  }
};

綜合上面的分析,我們可以得出結論——如果一個類需要直接管理某些資源,那麼我們就要收手動地為這個類實現三個特殊的成員函數:

  • 析構函數,負責釋放資源
  • 拷貝構造函數,負責拷貝資源
  • 拷貝賦值運算符,負責釋放運算符左邊的資源並拷貝運算符右面的資源
    這就是大名鼎鼎的The Rule of Three。另外,需要注意的是,我們可以通過拷貝並交換原語(copy-and-swap idiom)來實現拷貝複製運算符。欸,為什麼需要通過拷貝並交換來實現拷貝賦值運算符呢?直接像下面這樣,先釋放舊資源再申請新資源不行嗎?
NaiveVector& NaiveVector::operator=(const NaiveVector& rhs) {
  delete ptr_;
  ptr_ = new int[rhs.size_];
  size_ = rhs.size_;
  std::copy(rhs.ptr_, rhs.ptr_ + size_, ptr_);
  return *this;
}

答案顯然是不行,因為上面的這種實現不能正確地處理自我賦值(self-assignment)的情況。在自我賦值的情況下,ptr_所指向的資源被釋放,新申請的資源中包含的均是未定義的值,此時顯然已經無法進行正確的拷貝操作。而在下面的拷貝並交換實現中,我們在修改*this對象之前就對rhs進行了一次完整的拷貝(通過拷貝構造函數),這就避免了自我賦值中的陷阱。

NaiveVector& NaiveVector::operator=(const NaiveVector& rhs) {
  NaiveVector copy(rhs);
  copy.swap(*this);
  return *this;
}

RAII的全稱為Resource Acquisition Is Initialization,意思是資源獲取即初始化。表面上看,RAII是關於初始化的,但實際上RAII更注重於資源的正確釋放。使用RAII有助於我們寫出異常安全的程式碼。考慮下面的程式碼,在第3行我們申請了記憶體資源,如果此時程式拋出異常,那麼已經申請的資源就不能正確地被釋放,從而導致記憶體泄漏。

int main() {
  try {
    int *arr = new int[4];
    throw std::runtime_error("for example");
    delete [] arr; // clean up
  } catch (const std::exception& e) {
    std::cout << "Caught an exception: " << e.what() << "\n";
  }
  return 0;
}

為了避免這個問題,我們可以使用RAII技術,將資源釋放操作放到析構函數中。這樣的話,即使程式拋出了異常,也能夠正確地釋放掉相應的資源。

struct RAIIPtr {
  int *ptr_;
  RAIIPtr(int *p) ptr_(p) {}
  ~RAIIPtr() { delete [] ptr_; }
};

int main() {
  try {
    RAIIPtr arr = new int[4];
    throw std::runtime_error("for example");
  } catch (const std::exception& e) {
    std::cout << "Caught an exception: " << e.what() << "\n";
  }
  return 0;
}

注意上面的RAIIPtr實現仍然可能會導致資源泄漏,因為我們沒有實現拷貝構造函數和拷貝賦值運算符。當然,通過向拷貝構造函數和拷貝賦值運算符添加=delete,我們可以讓RAIIPtr變成不可拷貝的(non-copyable)。

struct RAIIPtr {
  int *ptr_;
  RAIIPtr(int *p) ptr_(p) {}
  ~RAIIPtr() { delete [] ptr_; }

  RAIIPtr(const RAIIPtr&) = delete;
  RAIIPtr& operator=(const RAIIPtr&) = delete;
};

使用=delete之後,編譯器就不會為RAIIPtr生成任何拷貝構造函數和拷貝賦值運算符,任何拷貝操作都會被拒絕。類似地,我們可以通過=default來讓編譯器生成默認的成員函數。如果某個類不直接管理任何資源,而僅使用vectorstring之類的庫,那麼我們就不應該為它編寫任何特殊的成員函數,使用默認的即可。這就是我們所說的The Rule of Zero。

移動語義和The Rule of Five

C++11中引入了右值引用和移動語義,由此產生了移動構造函數和移動拷貝賦值運算符。一般來說,移動一個對象比拷貝一個對象的速度要快,尤其是當對象較大的時候。

class NaiveVector {
  // copy constructor
  NaiveVector(const NaiveVector& rhs) {
    ptr_ = new int[rhs.size_];
    size_  = rhs.size_;
    std::copy(rhs.ptr_, rhs.ptr_ + size_, ptr_);
  }
  
  // move constructor
  NaiveVector(NaiveVector&& rhs) {
    ptr_ = std::exchange(rhs.ptr_, nullptr);
    size_ = std::exchange(rhs.size_, 0);
  }
};

因此,為了保證正確性和性能,我們有了The Rule of Five——如果某個類直接管理某種資源,那麼我們可能需要實現以下五個特殊的成員函數:

  • 析構函數,負責釋放資源
  • 拷貝構造函數,負責拷貝資源
  • 移動構造函數,負責轉移資源的所有權
  • 拷貝賦值運算符,負責釋放運算符左邊的資源並拷貝運算符右邊的資源
  • 移動賦值運算符,負責釋放運算符左邊的資源並轉移運算符右邊資源的所有權

需要注意的是,拷貝賦值運算符和移動賦值運算符的實現幾乎一致,僅有微小的差別:

NaiveVector& NaiveVector::operator=(const NaiveVector& rhs) {
  NaiveVector copy(rhs);
  copy.swap(*this);
  return *this;
}

NaiveVector& NaiveVector::operator=(NaiveVector&& rhs) {
  NaiveVector copy(std::move(rhs));
  copy.swap(*this);
  return *this;
}

因此,一種想法是只實現一個賦值運算符(by-value assignment operator),將拷貝和移動的選擇權交給函數的調用者,如下所示。不過這種實現方式並不常見,最好還是將拷貝賦值和移動賦值分開實現,畢竟STL就是這麼做的 😛 。

NaiveVector& NaiveVector::operator=(NaiveVector copy) {
  copy.swap(*this);
  return *this;
}

根據上面的描述,我們衍生出The Rule of Four (and a half)——如果某個類直接管理某種資源,那麼我們可能需要實現以下四個特殊的成員函數,以確保正確性和性能:

  • 析構函數,負責釋放資源
  • 拷貝構造函數,負責拷貝資源
  • 移動構造函數,負責轉移資源的所有權
  • by-value assignment operator,負責釋放運算符左邊的資源並轉移運算符右邊資源的所有權
    另外,我們還需要實現兩個版本的swap函數,一個作為成員函數,一個作為非成員函數。根據The Rule of Four (and a half),我們實現了一個較為高效的Vec類:
class Vec {
 public:
  int *ptr_;
  int size_;

  Vec(const Vec& rhs) {
    ptr_ = new int[rhs.size_];
    size_ = rhs.size_;
    std::copy(rhs.ptr_, rhs.ptr_ + size_, ptr_);
  }
  
  Vec(Vec&& rhs) noexcept {
    ptr_ = std::exchange(rhs.ptr_, nullptr);
    size_ = std::exchange(rhs.size_, 0);
  }
  // two-argument swap, to make efficiently "std::swappable"
  friend void swap(Vec& a, Vec& b) noexcept {
    a.swap(b);
  }
  
  ~Vec() {
    delete [] ptr_;
  }

  Vec& operator=(Vec copy) {
    copy.swap(*this);
    return *this;
  }
  // member swap, for simplicity
  void swap(Vec& rhs) noexcept {
    using std::swap;
    swap(ptr_, rhs.ptr_);
    swap(size_, rhs.size_);
  }
};

通過將原始指針更換為unique_ptr,我們可以實現一個接近The Rule of Zero的Vec類:

class Vec {
 public:
  std::unique_ptr<int[]> uptr_;
  int size_;
  
  // copy the resource
  Vec(const Vec& rhs) {
    uptr_ = std::make_unique<int[]>(rhs.size_);
    size_ = rhs.size_;
    std::copy(rhs.uptr_, rhs.uptr_ + rhs.size_, uptr_);
  }

  // transfer ownership
  Vec(Vec&& rhs) noexcept = default;
  
  friend void swap(Vec& a, Vec& b) noexcept {
    a.swap(b);
  }

  // free the resource
  ~Vec() = default;
  
  // free and transfer ownership
  Vec& operator=(Vec copy) {
    copy.swap(*this);
    return *this;
  }

  // swap ownership
  void swap(Vec& rhs) noexcept {
    using std::swap;
    swap(uptr_, rhs.uptr_);
    swap(size_, rhs.size_);
  }
};

當然,真正的The Rule of Zero,還是得靠std::vector來實現:

class Vec {
 public:
  std::vector<int> vec_;
  
  Vec(const Vec& rhs) = default;
  Vec(Vec&& rhs) noexcept = default;
  Vec& operator=(const Vec& rhs) = default;
  Vec& operator=(Vec&& rhs) = default;
  ~Vec() = default;
  
  // swap ownership
  // now only for performance, not correctness
  void swap(Vec& rhs) noexcept {
    vec_.swap(rhs.vec_);
  }

  friend void swap(Vec& a, Vec& b) {
    a.swap(b);
  }
};

總結一下,如果某個類需要直接管理資源,那麼為了保證正確性,我們需要為該類實現析構函數、拷貝構造函數和拷貝賦值運算符(The Rule of Three);為了保證性能,我們還可以實現移動構造函數和移動拷貝賦值運算符(The Rule of Five)。如果某個類不直接管理資源,那麼就不要實現任何特殊的成員函數(The Rule of Zero)。