Item 19: 使用srd::shared_ptr來管理共享所有權的資源


本文翻譯自modern effective C++,由於水平有限,故無法保證翻譯完全正確,歡迎指出錯誤。謝謝!

博客已經遷移到這里啦

使用帶垃圾回收機制語言的程序員指出並嘲笑C++程序員需要遭受防止資源泄漏的痛苦。“多么原始啊”他們嘲笑道,“20世紀60年代的Lisp留下的備忘錄你還不記得了嗎?機器(而不是人類)應該管理資源的生命周期”。C++開發人員轉了轉他們的眼睛,“你所說的備忘錄是指,那些資源只有內存以及資源的回收時間不確定的時候嗎?我們更喜歡比較普遍以及可預測的析構函數,謝謝你。”但是我們只是虛張聲勢而已。垃圾回收機制確實很方便,而且手動的生命周期管理確實看起來像:使用石刀和熊皮來構造一個記憶存儲電路(意味着幾乎不可能的任務,constructing a mnemonic memory circuit using
stone knives and bear skins,出自星際迷途)。為什么我們不能同時擁有兩個世界的精華部分呢:創造一個系統,這個系統能自動工作(比如垃圾回收機制),還能應用到所有資源上以及能擁有可預測的生命周期(比如析構函數)?

C++11中是用std::shared_ptr把兩個世界的優點綁定在一起的。通過std::shared_par可以訪問對象,這個對象的生命周期由智能指針以共享所有權的語義來管理。沒有一個明確的std::shared_ptr占有這個對象。取而代之的是,所有指向這個對象的std::shared_ptr一起合作來確保:當這個對象不再被需要的時候,它能被銷毀。當最后一個指向對象的std::shared_ptr不再指向這個對象(比如,因為std::shared_ptr被銷毀了或者指向了別的對象)std::shared_ptr會銷毀它指向的對象。就像垃圾回收機制一樣,客戶不需要管理被指向的對象的生命周期了,但是和析構函數一樣,對象的銷毀的時間是確定的。

通過查看引用計數(reference count,一個和資源關聯的值,這個值能記錄有多少std::shared_ptr指向資源),一個std::shared_ptr能告訴我們它是否是最后一個指向這個資源的指針。std::shared_ptr的構造函數會增加引用計數(通常,而不是總是,請看下面),std::shared_ptr的析構函數會減少引用計數,拷貝operator=既增加也減少(如果sp1和sp2是指向不同對象的std::shared_ptr,賦值操作“sp1 = sp2”會修改sp1來讓它指向sp2指向的對象。這個賦值操作最后產生的效果就是:原本被sp1指向的對象的引用計數減少了,同時被sp2指向的對象的引用計數增加了。)如果一個std::shared_ptr看到一個引用計數在一次自減操作后變成0了,這就意味着沒有別的std::shared_ptr指向這個資源了,所以std::shared_ptr就銷毀它了。

引用計數的存在帶來的性能的影響:

  • std::shared_ptr是原始指針的兩倍大小,因為它們在內部包含了一個指向資源的原始指針,同時包含一個指向資源引用計數的原始指針。

  • 引用計數的內存必須動態分配。概念上來說,引用計數和被指向的資源相關聯,但是被指向的對象不知道這件事。因此它們沒有地方來存放引用計數。(這里隱含一個令人愉快的提示:任何對象,即使是built-in類型的對象都能被std::shared_ptr管理)Item 21解釋了,當使用std::make_shared來創建std::shared_ptr時,動態分配的花費能被避免,但是這里有一些無法使用std::make_shared的情況。不管哪種方式,引用計數被當成動態分配的數據來存儲。

引用計數的增加和減少操作必須是原子的,因為在不同的線程中可能同時有多個reader和writer。舉個例子,在某個線程中指向的一個資源的std::shared_ptr正在調用析構函數(因此減少它指向的資源的引用計數),同時,在不同的線程中,一個指向相同資源的std::shared_ptr被拷貝了(因此增加了資源的引用計數)。原子操作通常比非原子操作更慢,所以即使引用計數常常只有一個字節的大小,你應該假設對它們的讀寫是相當費時的。

不知道我之前寫的“std::shared_ptr的構造函數只是“通常”增加它指向的對象的引用計數”有沒有刺激到你的好奇心。創建一個指向某個對象的std::shared_ptr總是產生一個額外std::shared_ptr指向這個對象,所以為什么我們不能總是增加它的引用計數呢?

move構造函數,這就是原因。從另外一個std::shared_ptr移動構造一個std::shared_ptr會設置源std::shared_ptr為null,這意味着舊的std::shared_ptr停止指向資源的同時新的std::shared_ptr開始指向資源。所以,它不需要維護引用計數。因此move std::shared_ptr比拷貝它們更快:拷貝需要增加引用計數,但是move不會。這對賦值操作來說也是一樣的,所以move構造比起拷貝構造更快,move operator=比拷貝operator=更快。

和std::unique_ptr(看Item 18)相似的是,std::shared_ptr使用delete作為它默認的資源銷毀機制,但是它也能支持自定義的deleter。但是,它的設計和std::unique_ptr不一樣。對於std::unique_ptr來說,deleter的類型是智能指針類型的一部分。但是對std::shared_ptr來說,它不是:

auto loggingDel = [] (Widget *pw)			//自定義deleter
				 {
				 	makeLogEnty(pw);
				 	delete pw;
				 }

std::unique_ptr<						//deleter的類型是指針
	Widget, decltype(loggingDel)		//類型的一部分
	> upw(new Widget, loggingDel);

std::shared_ptr<Widget>					//deleter的類型不是指針
	spw(new Widget, loggingDel);		//類型的一部分

std::shared_ptr的設計更加靈活。考慮一下兩個std::shared_ptr ,它們帶有不同的自定義deleter。(比如,因為自定義deleter是通過lambda表達式確定的):

auto customDeleter1 = [](Widget *pw) { ... };		//自定義deleter
auto customDeleter2 = [](Widget *pw) { ... };		//不同的類型

std::shared_ptr<Widget> pw1(new Widget, customDeleter1);
std::shared_ptr<Widget> pw2(new Widget, customDeleter2);

因為pw1和pw2有相同類型,它們能被放在同一個容器中:

std::vector<std::shared_ptr<Widget>> vpw{ pw1, pw2 };

它們能互相賦值,並且它們都能被傳給一個函數作為參數,只要這個函數的參數是std::shared_ptr 類型。這些事使用std::unique_ptr(根據自定義deleter來區分類型)都做不到,因為自定義deleter的類型會影響到std::unique_ptr的類型。

另外一個和std::unique_ptr不同的地方是,指定一個自定義deleter不會改變一個std::shared_ptr對象的大小。無論一個deleter是什么,一個std::shared_ptr對象都是兩個指針的大小。這是一個好消息,但是它也會讓你隱約感到一點不安。自定義deleter可以是一個仿函數,並且仿函數能包含任意多的數據。這意味着它們能變得任意大。那么一個std::shared_ptr怎么能指向一個任意大小的deleter卻不使用任何內存呢?

它不能,它必須要用更多的內存。但是,這些內存不是std::shared_ptr對象的一部分。它在堆上,或者,如果一個std::shared_ptr的創造者利用std::shared_ptr支持自定義內存分配器的特性來優化它,那么它就在內存分配器管理的內存中。我之前提過一個std::shared_ptr對象包含一個指向引用計數(std::shared_ptr指向的對象的引用計數)的指針。這是對的,但是我有點誤導你了,因為,引用計數只是更大的數據結構(被稱為控制塊(control block))的一部分。每一個被std::shared_ptr管理的對象都有一個控制塊。除了引用計數,控制塊還包含:一個自定義deleter的拷貝(如果有的話),一個自定義內存分配器的拷貝(如果有的話),額外的數據(包括weak count, Item 21中解釋的第二個引用計數,但是我們在本Item中會忽略這個數據)。我們能把和std::shared_ptr 對象關聯的內存模型想象成這個樣子:

control_block.bmp

一個對象的控制塊是被指向這個對象的第一個std::shared_ptr創建的。至少這是應該發生的。通常,一個創建std::shared_ptr的函數是不可能知道是否有其他std::shared_ptr已經指向這個對象了,所以控制塊的創建遵循這些規則:

  • std::make_shared(看Item 21)總是創建一個控制塊,它制造一個新對象,所以可以肯定當std::make_shared被調用的時候,這個對象沒有控制塊。

  • 當一個std::shared_ptr構造自一個獨占所有權的指針(也就是,一個std::unique_ptr或std::auto_ptr)時,創造一個控制塊。獨占所有權的指針不使用控制塊,所以被指向的對象沒有控制塊。(作為構造的一部分,std::shared_ptr需要承擔被指向對象的所有權,所以獨占所有權的指針被設置為null)

  • 當使用一個原始指針調用std::shared_ptr的構造函數構造函數時,它創造一個控制塊。如果你想使用一個已經有控制塊的對象來創建一個std::shared_ptr的話,你可以傳入一個std::shared_ptr或一個std::weak_ptr(看Item 20)作為構造函數的參數,但不能傳入一個原始指針。使用std::shared_ptr或std::weak_ptr作為構造函數的參數不會創建一個新的控制塊,因為它們能依賴傳入的智能指針來指向必要的控制塊。

這些規則導致的一個結果就是:用一個原始指針來構造超過一個的std::shared_ptr對象會讓你免費坐上通往未定義行為的粒子加速器,因為被指向的對象會擁有多個控制塊。多個控制塊就意味着多個引用計數,多個引用計數就意味着對象會被銷毀多次(一個引用計數一次)。這意味着這樣的代碼是很糟糕很糟糕很糟糕的:

auto pw = new Widget;							//pw是原始指針

...

std::shared_ptr<Widget> spw1(pw, loggingDel);	//創建一個*pw的控制塊

...

std::shared_ptr<Widget> spw2(pw, loggingDel);	//創建第二個*pw的控制塊

創建一個原始指針pw指向動態分配的對象是不好的,因為它和這一整章的建議相違背:比起原始指針優先使用智能指針(如果你已經忘記這個建議的動機了,在115頁刷新一下你的記憶)但是先把它放在一邊。創建pw的這一行在格式上是令人厭惡的,但是至少它不會造成未定義的程序行為。

現在,用原始指針調用spw1的構造函數,所以它為指向的對象創建了一個控制塊(因此也創建了一個引用計數)。在這種情況下,被指向對象就是pw(也就是pw指向的對象)。就其本身而言,這是可以的,但是spw2的構造函數的調用,使用的是同樣的原始指針,所以它也為pw創建一個控制塊(因此又創建了一個引用計數)。因此pw有兩個引用計數,每個引用計數最終都會變成0,並且這最終將企圖銷毀pw兩次。第二次銷毀會造成未定義行為。

關於std::shared_ptr的使用,上面的例子給我們兩個教訓。第一,盡量避免傳入一個原始指針給一個std::shared_ptr的構造函數。通常的替換品是使用std::make_shared(看Item 21),但是在上面的例子中,我們使用了自定義deleter,那就不能使用std::make_shared了。第二,如果你必須傳入一個原始指針給std::shared_ptr的構造函數,那么用“直接傳入new返回的結果”來替換“傳入一個原始指針變量”。如果上面的代碼的第一部分被寫成這樣:

std::shared_ptr<Widget> spw1(new Widget, loggingDel);	//直接使用new

這樣就沒有來自“使用同樣的原始指針來創建第二個std::shared_ptr”的誘惑了。取而代之的是,代碼的作者會很自然地使用spw1做為一個初始化參數來創建spw2(也就是,將調用std::shared_ptr的拷貝構造函數),並且這將不會造成任何問題:

std::shared_ptr<Widget> spw2(spw1);		//spw2使用的控制塊和spw1一樣

使用原始指針變量作為std::shared_ptr構造函數的參數時,有一個特別讓人驚奇的方式(涉及到this指針)會產生多個控制塊。假設我們的程序使用std::shared_ptr來管理Widget對象,並且我們有一個數據結構保存處理過的Widget:

std::vector<std::shared_ptr<Widget>> processedWidgets;

進一步假設Widget有一個成員函數來做相應的處理:

class Widget {
public:
	...
	void process();
	...
};

這里有一個“看起來合理”的方法能用在Widget::process上:

void Widget::process()
{
	...										//處理Widget

	processedWidgets.emplace_back(this);	//把它加到處理過的Widget的
											//列表中去,這是錯誤的!
}

注釋上說這會產生錯誤已經說明了一切(或者大部分事實,錯誤的地方是傳入this,而不是emplace_back的使用。如果你不熟悉emplace_back,請看Item 42),這段代碼能編譯,但是它傳入一個原始指針(this)給一個std::shared_ptr的容器。因此std::shared_ptr的構造函數將為它指向的Widget(*this)創建一個新的控制塊。直到你意識到如果在成員函數外面已經有std::shared_ptr指向這個Widget前,這聽起來都是無害的,這是對未定義行為的賭博,設置以及匹配。

std::shared_ptr的API包括一個為這種情況專用的工具。它有着標准C++庫所有名字中有可能最奇怪的名字:std::enable_shared_from_this。如果你想要一個類被std::shared_ptr管理,你能繼承自這個基類模板,這樣就能用this指針安全地創建一個std::shared_ptr。在我們的例子中,Widget應該像這樣繼承std::enable_shared_form_this:

class Widget: public std::enable_shared_from_this<Widget> {
public:
	...
	void process();
	...
};

就像我之前說的,std::enable_shared_from_this是一個基類模板。它的類型參數總是派生類的名字,所以Widget需要繼承一個std::enable_shared_from_this 。如果“派生類繼承的基類需要用派生類來作為模板參數”讓你感到頭疼的話,不要去思考這個問題。代碼是完全合理的,並且這背后是已經建立好的一個設計模式,它有一個標准的名字,雖然這個名字幾乎和std::enable_shared_from_this一樣奇怪。名字是“奇特的遞歸模板模式”(The Curiously Recurring Template
Pattern, 簡稱CRTP)。如果你想要學一下這個方面的知識的話,打開你的搜索引擎把,因為在這里,我們需要回到std::enable_shared_from_this。

std::enable_shared_from_this定義一個成員函數來創建一個指向正確對象的std::shared_ptr,但是它不復制控制塊。成員函數是shared_from_this,並且當你想讓std::shared_ptr指向this指針指向的對象時,你可以在成員函數中使用它。這里給出Widget::process的安全實現:

void Widget::process()
{
	//和以前一樣,處理Widget
	...

	//把指向當前對象的std::shared_ptr增加到processedWidgets中去
	processedWidgets.emplace_back(shared_from_this());
}

在其內部,shared_from_this查找當前對象的控制塊,並且創建一個新的std::shared_ptr並讓它指向這個控制塊。這個設計依賴於當前的對象已經有一個相關聯的控制塊了。這樣的話,這里就必須有一個存在的std::shared_ptr(比如,一個調用shared_from_this的成員函數的外部)指向當前的對象。如果沒有這樣的std::shared_ptr存在(也就是如果當前對象沒有和任何控制塊關聯),即使shared_from_this通常會拋出一個異常,它的行為還將是未定義的。

為了防止客戶在一個std::shared_ptr指向這個對象前,調用成員函數(這個成員函數調用了shared_from_this),繼承自std::enable_shared_from_this的類常常聲明它們的構造函數為private,並且讓客戶通過調用一個返回std::shared_ptr的工廠函數來創建對象,舉個例子,看起來像這樣:

class Widget: public std::enable_shared_from_this<Widget> {
public:
	//工廠函數,完美轉發參數給一個private
	//構造函數

	template<typename... Ts>
	static std::shared_ptr<Widget> create(Ts&&... params);

	...
	void process();
	...

private:
	...					//構造函數
};

現在,你可能只能模糊地回想起我們對控制塊的討論是出於:理解和std::shared_ptr有關的費用。現在我們理解了怎么避免創建太多的控制塊,讓我們回到原始的話題。

一個控制塊通常只有幾個字節的大小,盡管自定義deleter和自定義內存分配器能讓它變得更大。控制塊的通常實現可能比你想象的要更加復雜。它利用繼承,甚至一個虛函數(用來確保指向的對象被正確地銷毀)這意味着使用std::shared_ptr也會招致使用虛函數(被控制塊使用)的成本。

讀了關於動態分配控制塊,任意大的deleter和內存分配器,虛函數機制,以及原子引用計數操作。你對std::shared_ptr的熱情可能多少已經衰減了。很好,它們不是每一種資源管理問題的最好解決辦法。但是為了它們提供的功能,std::shared_ptr的這些付出還是合理的。在典型的條件下,當使用默認deleter以及默認內存分配器,並且使用std::make_shared來創建std::shared_ptr時,控制塊只有3字節的大小,並且它的分配本質上是免費的(這包括被指向的對象的內存的分配,細節部分看Item 21)解引用一個std::shared_ptr不會比解引用一個原始指針更昂貴。執行一個需要改動引用計數的操作(比如,拷貝構造函數或拷貝operator=,析構函數)需要承擔一個或兩個原子操作,但是這些操作通常被映射到獨立的機器指令上,所以即使他們可能比起非原子指令更昂貴,但是他們仍然是單條指令。控制塊中的虛函數機制,在每個被std::shared_ptr管理的對象中只使用一次:對象銷毀的時候。

用這些適度的花費作為交換,你能得到的是,對動態分配資源的生命周期的自動管理。大多數時候,對於共享所有權的對象的生命周期,比起手動管理來說,使用std::shared_ptr是更好的選擇。如果你發現你在糾結是否承擔得起std::shared_ptr所帶來的負擔,你需要再考慮一下你是否真的需要共享所有權。如果獨享所有權能夠做到的話,std::unique_ptr是更好的選擇。它的性能狀況和原始指針是很接近的,並且從std::unique_ptr“升級”到std::shared_ptr也很簡單,因為一個std::shared_ptr能使用一個std::unique_ptr來創建。

反過來就不對了。一旦你已經把對資源的生命周期的管理交給了std::shared_ptr,你的想法就不能再改變了。即使它的引用計數是1,你也不能改變資源的所有權,也就是說,讓一個std::unique_ptr來管理它。std::shared_ptr和資源之間的所有權合同指出它是“死前永遠在一起”的類型,沒有分離,沒有取消,沒有分配。

另外std::shared_ptr不能和數組一起工作。到目前為止這是另外一個和std::unique_ptr不同的地方,std::shared_ptr的API被設計為只能作為單一對象的指針。這里沒有std::shared_ptr<T[]>。有時候,“聰明的”程序員會這么想:使用一個std::shared_ptr 來指向一個數組,確定一個自定義deleter來執行數組的銷毀(也就是delete[])。這能編譯通過,但是它是一個可怕的想法。首先,std::shared_ptr沒有提供operator[],所以數組的索引操作就要求基於指針運算來實現,這很尷尬。另外,對於單個對象來說,std::shared_ptr支持從“派生類到基類的”轉換,但是當應用到數組中時,這將開啟一扇未知的大門(就是這個原因,std::unique_ptr<T[]>API禁止這樣的轉換)。最重要的是,既然C++11已經給出了多種built-in數組的替代品(比如,std::array,std::vector,std::string),聲明一個指向原始數組的智能指針總是標識着,這是一個糟糕的設計。

你要記住的事
  • std::shared_ptr提供和垃圾回收機制差不多方便的方法,來對任意的資源進行共享語義的生命周期管理。
  • 比起std::unique_ptr,std::shared_ptr對象常常是它的兩倍大,需要承擔控制塊的間接費用,並且需要原子的引用計數操作。
  • 默認的資源銷毀操作是通過delete進行的,但是自定義deleter是支持的。deleter的類型不會影響到std::shared_ptr的類型。
  • 避免從原始指針類型的變量來創建std::shared_ptr。


免責聲明!

本站轉載的文章為個人學習借鑒使用,本站對版權不負任何法律責任。如果侵犯了您的隱私權益,請聯系本站郵箱yoyou2525@163.com刪除。



 
粵ICP備18138465號   © 2018-2025 CODEPRJ.COM