C++慣用法之消除垃圾收集器-資源獲取即初始化方法(RAII)

  • 2019 年 12 月 16 日
  • 筆記

C++慣用法之消除垃圾收集器-資源獲取即初始化方法(RAII)

0.導語

在C語言中,有三種類型的記憶體分配:靜態、自動和動態。靜態變數是嵌入在源文件中的常數,因為它們有已知的大小並且從不改變,所以它們並不那麼有趣。自動分配可以被認為是堆棧分配——當一個詞法塊進入時分配空間,當該塊退出時釋放空間。它最重要的特徵與此直接相關。在C99之前,自動分配的變數需要在編譯時知道它們的大小。這意味著任何字元串、列表、映射以及從這些派生的任何結構都必須存在於堆中的動態記憶體中。

程式設計師使用四個基本操作明確地分配和釋放動態記憶體:malloc、realloc、calloc和free。前兩個不執行任何初始化,記憶體可能包含碎片。除了自由,他們都可能失敗。在這種情況下,它們返回一個空指針,其訪問是未定義的行為;在最好的情況下,你的程式會崩潰。在最壞的情況下,你的程式看起來會工作一段時間,在崩潰前處理垃圾數據。

例如:

int main() {     char *str = (char *) malloc(7);     strcpy(str, "toptal");     printf("char array = "%s" @ %un", str, str);       str = (char *) realloc(str, 11);     strcat(str, ".com");     printf("char array = "%s" @ %un", str, str);       free(str);       return(0);  }  

輸出:

char array = "toptal" @ 2762894960  char array = "toptal.com" @ 2762894960  

儘管程式碼很簡單,但它已經包含了一個反模式和一個有問題的決定。在現實生活中,你不應該直接寫位元組數,而應該使用sizeof函數。類似地,我們將char *數組精確地分配給我們需要的字元串大小的兩倍(比字元串長度多一倍,以說明空終止),這是一個相當昂貴的操作。一個更複雜的程式可能會構建一個更大的字元串緩衝區,允許字元串大小增長。

1.RAII的發明:新希望

至少可以說,所有手動管理都是令人不快的。在80年代中期,Bjarne Stroustrup為他的全新語言C ++發明了一種新的範例。他將其稱為「資源獲取就是初始化」,其基本見解如下:可以指定對象具有構造函數和析構函數,這些構造函數和析構函數在適當的時候由編譯器自動調用,這為管理給定對象的記憶體提供了更為方便的方法。 需要,並且該技術對於不是記憶體的資源也很有用。

意味著上面的例子在c++中更簡潔:

int main() {     std::string str = std::string ("toptal");     std::cout << "string object: " << str << " @ " << &str << "n";       str += ".com";     std::cout << "string object: " << str << " @ " << &str << "n";       return(0);  }  

輸出:

string object: toptal @ 0x7fffa67b9400  string object: toptal.com @ 0x7fffa67b9400  

在上述例子中,我們沒有手動記憶體管理!構造string對象,調用重載方法,並在函數退出時自動銷毀。不幸的是,同樣的簡單也會導致其他問題。讓我們詳細地看一個例子:

vector<string> read_lines_from_file(string &file_name) {  	vector<string> lines;  	string line;    	ifstream file_handle (file_name.c_str());  	while (file_handle.good() && !file_handle.eof()) {  		getline(file_handle, line);  		lines.push_back(line);  	}    	file_handle.close();    	return lines;  }    int main(int argc, char* argv[]) {  	// get file name from the first argument  	string file_name (argv[1]);  	int count = read_lines_from_file(file_name).size();  	cout << "File " << file_name << " contains " << count << " lines.";    	return 0;  }  

輸出:

File makefile contains 38 lines.  

這看起來很簡單。vector被填滿、返回和調用。然而,作為關心性能的高效程式設計師,這方面的一些問題困擾著我們:在return語句中,由於使用了值語義,vector在銷毀之前不久就被複制到一個新vector中。

在現代C ++中,這不再是嚴格的要求了。C ++ 11引入了移動語義的概念,其中將原點保留在有效狀態(以便仍然可以正確銷毀)但未指定狀態。對於編譯器而言,返回調用是最容易優化以優化語義移動的情況,因為它知道在進行任何進一步訪問之前不久將銷毀源。但是,該示例的目的是說明為什麼人們在80年代末和90年代初發明了一大堆垃圾收集的語言,而在那個時候C ++ move語義不可用。

對於數據量比較大的文件,這可能會變得昂貴。讓我們對其進行優化,只返回一個指針。語法進行了一些更改,但其他程式碼相同:

vector<string> * read_lines_from_file(string &file_name) {  	vector<string> * lines;  	string line;    	ifstream file_handle (file_name.c_str());  	while (file_handle.good() && !file_handle.eof()) {  		getline(file_handle, line);  		lines->push_back(line);  	}    	file_handle.close();    	return lines;  }  int main(int argc, char* argv[]) {  	// get file name from the first argument  	string file_name (argv[1]);  	int count = read_lines_from_file(file_name).size();  	cout << "File " << file_name << " contains " << count << " lines.";    	return 0;  }  

輸出:

Segmentation fault (core dumped)  

程式崩潰!我們只需要將上述的lines進行記憶體分配:

vector<string> * lines = new vector<string>;  

這樣就可以運行了!

不幸的是,儘管這看起來很完美,但它仍然有一個缺陷:它會泄露記憶體。在C++中,指向堆的指針在不再需要後必須手動刪除;否則,一旦最後一個指針超出範圍,該記憶體將變得不可用,並且直到進程結束時作業系統對其進行管理後才會恢復。慣用的現代C++將在這裡使用unique_ptr,它實現了期望的行為。它刪除指針超出範圍時指向的對象。然而,這種行為直到C++11才成為語言的一部分。

在這裡,可以直接使用C++11之前的語法,只是把main中改一下即可:

vector<string> * read_lines_from_file(string &file_name) {  	vector<string> * lines = new vector<string>;  	string line;    	ifstream file_handle (file_name.c_str());  	while (file_handle.good() && !file_handle.eof()) {  		getline(file_handle, line);  		lines->push_back(line);  	}    	file_handle.close();    	return lines;  }    int main(int argc, char* argv[]) {  	// get file name from the first argument  	string file_name (argv[1]);  	vector<string> * file_lines = read_lines_from_file(file_name);  	int count = file_lines->size();  	delete file_lines;  	cout << "File " << file_name << " contains " << count << " lines.";    	return 0;  }  

手動去分配記憶體與釋放記憶體。

不幸的是,隨著程式擴展到上述範圍之外,很快就變得更加難以推理指針應該在何時何地被刪除。當一個函數返回指針時,你現在擁有它嗎?您應該在完成後自己刪除它,還是它屬於某個稍後將被一次性釋放的數據結構?一方面出錯,記憶體泄漏,另一方面出錯,你已經破壞了正在討論的數據結構和其他可能的數據結構,因為它們試圖取消引用現在不再有效的指針。

2.「使用垃圾收集器,flyboy!」

垃圾收集器不是一項新技術。它們由John McCarthy在1959年為Lisp發明。1980年,隨著Smalltalk-80的出現,垃圾收集開始成為主流。但是,1990年代代表了該技術的真正發芽:在1990年至2000年之間,發布了多種語言,所有語言都使用一種或另一種垃圾回收:Haskell,Python,Lua,Java,JavaScript,Ruby,OCaml 和C#是最著名的。

什麼是垃圾收集?簡而言之,這是一組用於自動執行手動記憶體管理的技術。它通常作為具有手動記憶體管理的語言(例如C和C ++)的庫提供,但在需要它的語言中更常用。最大的優點是程式設計師根本不需要考慮記憶體。都被抽象了。例如,相當於我們上面的文件讀取程式碼的Python就是這樣:

def read_lines_from_file(file_name):  	lines = []  	with open(file_name) as fp:  		for line in fp:  			lines.append(line)  	return lines    if __name__ == '__main__':  	import sys  	file_name = sys.argv[1]  	count = len(read_lines_from_file(file_name))  	print("File {} contains {} lines.".format(file_name, count))  

行數組是在第一次分配給它時出現的,並且不複製到調用範圍就返回。由於時間不確定,它會在超出該範圍後的某個時間被垃圾收集器清理。有趣的是,在Python中,用於非記憶體資源的RAII不是慣用語言。允許-我們可以簡單地編寫fp = open(file_name)而不是使用with塊,然後讓GC清理。但是建議的模式是在可能的情況下使用上下文管理器,以便可以在確定的時間釋放它們。

儘管簡化了記憶體管理,但要付出很大的代價。在引用計數垃圾回收中,所有變數賦值和作用域出口都會獲得少量成本來更新引用。在標記清除系統中,在GC清除記憶體的同時,所有程式的執行都以不可預測的時間間隔暫停。這通常稱為世界停止事件。同時使用這兩種系統的Python之類的實現都會受到兩種懲罰。這些問題降低了垃圾收集語言在性能至關重要或需要實時應用程式的情況下的適用性。即使在以下玩具程式上,也可以看到實際的性能下降:

$ make cpp && time ./c++ makefile  g++ -o c++ c++.cpp  File makefile contains 38 lines.  real    0m0.016s  user    0m0.000s  sys     0m0.015s    $ time python3 python3.py makefile  File makefile contains 38 lines.    real    0m0.041s  user    0m0.015s  sys     0m0.015s  

Python版本的實時時間幾乎是C ++版本的三倍。儘管並非所有這些差異都可以歸因於垃圾收集,但它仍然是可觀的。

3.所有權:RAII覺醒

我們知道對象的生存期由其範圍決定。但是,有時我們需要創建一個對象,該對象與創建對象的作用域無關,這是有用的,或者很有用。在C ++中,運算符new用於創建這樣的對象。為了銷毀對象,可以使用運算符delete。由new操作員創建的對象是動態分配的,即在動態記憶體(也稱為堆或空閑存儲)中分配。因此,由new創建的對象將繼續存在,直到使用delete將其明確銷毀為止。

使用new和delete時可能發生的一些錯誤是:

  • 對象(或記憶體)泄漏:使用new分配對象,而忘記刪除該對象。
  • 過早刪除(或懸掛引用):持有指向對象的另一個指針,刪除該對象,然而還有其他指針在引用它。
  • 雙重刪除:嘗試兩次刪除一個對象。

通常,範圍變數是首選。但是,RAII可以用作new和delete的替代方法,以使對象獨立於其範圍而存在。這種技術包括將指針分配到在堆上分配的對象,並將其放在句柄/管理器對象中。後者具有一個析構函數,將負責銷毀該對象。這將確保該對象可用於任何想要訪問它的函數,並且該對象在句柄對象的生存期結束時將被銷毀,而無需進行顯式清理。

來自C ++標準庫的使用RAII的示例為std :: string和std :: vector。

考慮這段程式碼:

void fn(const std::string& str)  {      std::vector<char> vec;      for (auto c : str)          vec.push_back(c);      // do something  }  

當創建vector,並將元素推入vector時,您不必擔心分配和取消分配此類元素記憶體。vector使用new為其堆上的元素分配空間,並使用delete釋放該空間。作為vector的用戶,您無需關心實現細節,並且會相信vector不會泄漏。在這種情況下,向量是其元素的句柄對象。

標準庫中使用RAII的其他示例是std :: shared_ptr,std :: unique_ptr和std :: lock_guard。

該技術的另一個名稱是SBRM,是範圍綁定資源管理的縮寫。

現在,我們將上述讀取文件例子,進行修改:

#include <iostream>  #include <vector>  #include <cstring>  #include <fstream>  #include <bits/unique_ptr.h>    using namespace std;  unique_ptr<vector<string>> read_lines_from_file(string &file_name) {      unique_ptr<vector<string>> lines(new vector<string>);      string line;        ifstream file_handle (file_name.c_str());      while (file_handle.good() && !file_handle.eof()) {          getline(file_handle, line);          lines->push_back(line);      }        file_handle.close();        return lines;  }  int main(int argc, char* argv[]) {  	// get file name from the first argument  	string file_name (argv[1]);  	int count = read_lines_from_file(file_name).get()->size();      cout << "File " << file_name << " contains " << count << " lines.";    	return 0;  }  

4.只有在最後,你才意識到RAII的真正力量。

自從編譯器發明以來,手動記憶體管理是程式設計師一直在想辦法避免的噩夢。RAII是一種很有前途的模式,但由於沒有一些奇怪的解決方法,它根本無法用於堆分配的對象,因此在C ++中會受到影響。因此,在90年代出現了垃圾收集語言的爆炸式增長,旨在使程式設計師生活更加愉快,即使以性能為代價。

最後,RAII總結如下:

  • 資源在析構函數中被釋放
  • 該類的實例是堆棧分配的
  • 資源是在構造函數中獲取的。

RAII代表「資源獲取是初始化」。

常見的例子有:

  • 文件操作
  • 智慧指針
  • 互斥量

5.參考文章

1.https://www.toptal.com/software/eliminating-garbage-collector#remote-developer-job

2.https://stackoverflow.com/questions/2321511/what-is-meant-by-resource-acquisition-is-initialization-raii