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






