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_back
,ptr_
所指向的資源將會被釋放,然後指向一個新申請的資源。當離開作用域時,局部對象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
來讓編譯器生成默認的成員函數。如果某個類不直接管理任何資源,而僅使用vector
和string
之類的庫,那麼我們就不應該為它編寫任何特殊的成員函數,使用默認的即可。這就是我們所說的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)。