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