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















 
 
 








 
 
 
 