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

ouyang 發表於 2021-04-20

本文整理了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)。