C++慣用法之資源獲取即初始化方法(RAII)
本文轉載自微信公眾號「光城」,作者 lightcity 。轉載本文請聯(lián)系光城公眾號。
0.導語
在C語言中,有三種類型的內存分配:靜態(tài)、自動和動態(tài)。靜態(tài)變量是嵌入在源文件中的常數(shù),因為它們有已知的大小并且從不改變,所以它們并不那么有趣。自動分配可以被認為是堆棧分配——當一個詞法塊進入時分配空間,當該塊退出時釋放空間。它最重要的特征與此直接相關。在C99之前,自動分配的變量需要在編譯時知道它們的大小。這意味著任何字符串、列表、映射以及從這些派生的任何結構都必須存在于堆中的動態(tài)內存中。
程序員使用四個基本操作明確地分配和釋放動態(tài)內存:malloc、realloc、calloc和free。前兩個不執(zhí)行任何初始化,內存可能包含碎片。除了自由,他們都可能失敗。在這種情況下,它們返回一個空指針,其訪問是未定義的行為;在最好的情況下,你的程序會崩潰。在最壞的情況下,你的程序看起來會工作一段時間,在崩潰前處理垃圾數(shù)據(jù)。
例如:
- int main() {
- char *str = (char *) malloc(7);
- strcpy(str, "toptal");
- printf("char array = \"%s\" @ %u\n", str, str);
- str = (char *) realloc(str, 11);
- strcat(str, ".com");
- printf("char array = \"%s\" @ %u\n", str, str);
- free(str);
- return(0);
- }
輸出:
- char array = "toptal" @ 2762894960
- char array = "toptal.com" @ 2762894960
盡管代碼很簡單,但它已經(jīng)包含了一個反模式和一個有問題的決定。在現(xiàn)實生活中,你不應該直接寫字節(jié)數(shù),而應該使用sizeof函數(shù)。類似地,我們將char *數(shù)組精確地分配給我們需要的字符串大小的兩倍(比字符串長度多一倍,以說明空終止),這是一個相當昂貴的操作。一個更復雜的程序可能會構建一個更大的字符串緩沖區(qū),允許字符串大小增長。
1.RAII的發(fā)明:新希望
至少可以說,所有手動管理都是令人不快的。在80年代中期,Bjarne Stroustrup為他的全新語言C ++發(fā)明了一種新的范例。他將其稱為“資源獲取就是初始化”,其基本見解如下:可以指定對象具有構造函數(shù)和析構函數(shù),這些構造函數(shù)和析構函數(shù)在適當?shù)臅r候由編譯器自動調用,這為管理給定對象的內存提供了更為方便的方法。 需要,并且該技術對于不是內存的資源也很有用。
意味著上面的例子在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對象,調用重載方法,并在函數(shù)退出時自動銷毀。不幸的是,同樣的簡單也會導致其他問題。讓我們詳細地看一個例子:
- 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中。
在現(xiàn)代C ++中,這不再是嚴格的要求了。C ++ 11引入了移動語義的概念,其中將原點保留在有效狀態(tài)(以便仍然可以正確銷毀)但未指定狀態(tài)。對于編譯器而言,返回調用是最容易優(yōu)化以優(yōu)化語義移動的情況,因為它知道在進行任何進一步訪問之前不久將銷毀源。但是,該示例的目的是說明為什么人們在80年代末和90年代初發(fā)明了一大堆垃圾收集的語言,而在那個時候C ++ move語義不可用。
對于數(shù)據(jù)量比較大的文件,這可能會變得昂貴。讓我們對其進行優(yōu)化,只返回一個指針。語法進行了一些更改,但其他代碼相同:
- 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++中,指向堆的指針在不再需要后必須手動刪除;否則,一旦最后一個指針超出范圍,該內存將變得不可用,并且直到進程結束時操作系統(tǒng)對其進行管理后才會恢復。慣用的現(xiàn)代C++將在這里使用unique_ptr,它實現(xiàn)了期望的行為。它刪除指針超出范圍時指向的對象。然而,這種行為直到C++11才成為語言的一部分。
在這里,可以直接使用C++11之前的語法,只是把main中改一下即可:
- 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;
- }
手動去分配內存與釋放內存。
不幸的是,隨著程序擴展到上述范圍之外,很快就變得更加難以推理指針應該在何時何地被刪除。當一個函數(shù)返回指針時,你現(xiàn)在擁有它嗎?您應該在完成后自己刪除它,還是它屬于某個稍后將被一次性釋放的數(shù)據(jù)結構?一方面出錯,內存泄漏,另一方面出錯,你已經(jīng)破壞了正在討論的數(shù)據(jù)結構和其他可能的數(shù)據(jù)結構,因為它們試圖取消引用現(xiàn)在不再有效的指針。
2.“使用垃圾收集器,flyboy!”
垃圾收集器不是一項新技術。它們由John McCarthy在1959年為Lisp發(fā)明。1980年,隨著Smalltalk-80的出現(xiàn),垃圾收集開始成為主流。但是,1990年代代表了該技術的真正發(fā)芽:在1990年至2000年之間,發(fā)布了多種語言,所有語言都使用一種或另一種垃圾回收:Haskell,Python,Lua,Java,JavaScript,Ruby,OCaml 和C#是最著名的。
什么是垃圾收集?簡而言之,這是一組用于自動執(zhí)行手動內存管理的技術。它通常作為具有手動內存管理的語言(例如C和C ++)的庫提供,但在需要它的語言中更常用。最大的優(yōu)點是程序員根本不需要考慮內存。都被抽象了。例如,相當于我們上面的文件讀取代碼的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))
行數(shù)組是在第一次分配給它時出現(xiàn)的,并且不復制到調用范圍就返回。由于時間不確定,它會在超出該范圍后的某個時間被垃圾收集器清理。有趣的是,在Python中,用于非內存資源的RAII不是慣用語言。允許-我們可以簡單地編寫fp = open(file_name)而不是使用with塊,然后讓GC清理。但是建議的模式是在可能的情況下使用上下文管理器,以便可以在確定的時間釋放它們。
盡管簡化了內存管理,但要付出很大的代價。在引用計數(shù)垃圾回收中,所有變量賦值和作用域出口都會獲得少量成本來更新引用。在標記清除系統(tǒng)中,在GC清除內存的同時,所有程序的執(zhí)行都以不可預測的時間間隔暫停。這通常稱為世界停止事件。同時使用這兩種系統(tǒng)的Python之類的實現(xiàn)都會受到兩種懲罰。這些問題降低了垃圾收集語言在性能至關重要或需要實時應用程序的情況下的適用性。即使在以下玩具程序上,也可以看到實際的性能下降:
- $ 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覺醒
我們知道對象的生存期由其范圍決定。但是,有時我們需要創(chuàng)建一個對象,該對象與創(chuàng)建對象的作用域無關,這是有用的,或者很有用。在C ++中,運算符new用于創(chuàng)建這樣的對象。為了銷毀對象,可以使用運算符delete。由new操作員創(chuàng)建的對象是動態(tài)分配的,即在動態(tài)內存(也稱為堆或空閑存儲)中分配。因此,由new創(chuàng)建的對象將繼續(xù)存在,直到使用delete將其明確銷毀為止。
使用new和delete時可能發(fā)生的一些錯誤是:
- 對象(或內存)泄漏:使用new分配對象,而忘記刪除該對象。
- 過早刪除(或懸掛引用):持有指向對象的另一個指針,刪除該對象,然而還有其他指針在引用它。
- 雙重刪除:嘗試兩次刪除一個對象。
通常,范圍變量是首選。但是,RAII可以用作new和delete的替代方法,以使對象獨立于其范圍而存在。這種技術包括將指針分配到在堆上分配的對象,并將其放在句柄/管理器對象中。后者具有一個析構函數(shù),將負責銷毀該對象。這將確保該對象可用于任何想要訪問它的函數(shù),并且該對象在句柄對象的生存期結束時將被銷毀,而無需進行顯式清理。
來自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
- }
當創(chuàng)建vector,并將元素推入vector時,您不必擔心分配和取消分配此類元素內存。vector使用new為其堆上的元素分配空間,并使用delete釋放該空間。作為vector的用戶,您無需關心實現(xiàn)細節(jié),并且會相信vector不會泄漏。在這種情況下,向量是其元素的句柄對象。
標準庫中使用RAII的其他示例是std :: shared_ptr,std :: unique_ptr和std :: lock_guard。
該技術的另一個名稱是SBRM,是范圍綁定資源管理的縮寫。
現(xiàn)在,我們將上述讀取文件例子,進行修改:
- #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的真正力量。
自從編譯器發(fā)明以來,手動內存管理是程序員一直在想辦法避免的噩夢。RAII是一種很有前途的模式,但由于沒有一些奇怪的解決方法,它根本無法用于堆分配的對象,因此在C ++中會受到影響。因此,在90年代出現(xiàn)了垃圾收集語言的爆炸式增長,旨在使程序員生活更加愉快,即使以性能為代價。
最后,RAII總結如下:
- 資源在析構函數(shù)中被釋放
- 該類的實例是堆棧分配的
- 資源是在構造函數(shù)中獲取的。
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