程序師世界是廣大編程愛好者互助、分享、學習的平台,程序師世界有你更精彩!
首頁
編程語言
C語言|JAVA編程
Python編程
網頁編程
ASP編程|PHP編程
JSP編程
數據庫知識
MYSQL數據庫|SqlServer數據庫
Oracle數據庫|DB2數據庫
 程式師世界 >> 編程語言 >> C語言 >> C++ >> 關於C++ >> Effective Modern C++ 條款19 用std::shared_ptr管理共享所有權的資源

Effective Modern C++ 條款19 用std::shared_ptr管理共享所有權的資源

編輯:關於C++

那些用帶垃圾回收器語言的程序員指出並取笑C++程序員還要去防止內存洩漏。MDZZ,他們笑道,“你沒有從上世紀六十年代的Listp中得到啟示錄嗎?管理資源的應該是機器本身,而不是程序員。”C++開發者不以為然,“你指的啟示錄是——只有內存算是資源和不確定的資源回收時間嗎?我們更喜歡析構函數的概述性和可預測性,謝謝。”不過我們吹牛有點過了,垃圾回收器是挺方便的,相比之下手動管理資源生命期就像是用石刀和熊毛片制作記憶存儲器電路。為什麼我們不得同時得到魚和熊掌呢:系統即可以自動工作(就像垃圾回收器),又可預知資源回收的時間(就像析構函數)。

在C++11,std::shared_ptr就同時得到魚和熊掌了。借助std::shared_ptr的對象通過共享所有權來管理生命期。std::shared_ptr並不會直接擁有對象,取而代之的是,所有std::shared_ptr都指向那個對象,並且保證在不需要指向的資源的時候銷毀資源。當最後一個指向對象的std::shared_ptr不再指向它時(例如,因為std::shared_ptr被銷毀或者指向另一個對象),std::shared_ptr就會銷毀它指向的對象。用垃圾回收器的話,用戶不需要關心指向對象的生命期,但是用析構函數的話,銷毀對象的時間點是確定的。

一個std::shared_ptr可以告訴我們:它是否是最後一個指向該資源的指針,這是通過咨詢資源的引用計數(reference count)——一個負責監控有多少std::shared_ptr指向資源的值。std::shared_ptr的構造函數會增加這個計數(通常會,看下面的內容),拷貝賦值運算也會。(如果shared_ptr對象sp1和sp2指向不同的對象,那麼賦值語句“sp1 = sp2”會導致sp1指向sp2指向的對象。賦值語句還會導致sp1原來指向的對象的引用計數減一,sp2指向的對象的引用計數加一。)如果一個std::shared_ptr在遞減後發現引用計數為0,意味著沒有其他的std::shared_ptr指向這資源,所以銷毀資源。

引用計數是默默工作的:

std::shared_ptr的大小是原生指針的兩倍,因為它包含一個指向資源的原生指針,還有資源的引用計數。 引用計數所用的內存一定是動態分配的。概念上,引用計數是與指向的對象相關聯的,但是指向的對象對引用計數一無所知,因此它們沒有存儲引用計數的地方。(讓人愉快的是一些內置類型也可以用std::shared_ptr管理。)條款21會說明用std::make_shared創建std::shared_ptr可以避免動態分配的開銷,不過有些情況下std::make_shared是不能用的。總之,引用計數是用動態分配的數據。 增加和減少引用計數一定是原子操作,因為在不同的線程中會同時存在讀者和寫者。例如,在一個線程中,std::shared_ptr正在析構(因此會減少引用計數),同時在另一個線程,指向相同資源的std::shared_ptr正在被拷貝(因此後增加引用計數)。原子操作通常會比非原子操作慢,所以盡管引用計數通常只有一字(word)的尺寸,你也應該認定讀寫引用計數是比較昂貴的。

當我說std::shared_ptr的構造函數“通常”會增加引用計數時,有激起你的好奇心嗎?創建一個指向某對象的std::shared_ptr,總是會產生多一個指向該對象std::shared_pt的啊,那為什麼不是總是增加引用計數呢?

移動構造,這就是原因。用一個std::shared_ptr移動構造另一個std::shared_ptr,這回導致源std::shared_ptr設置為空,這意味著舊的std::shared_ptr在新的std::shared_ptr完成的時候就不再指向資源。這樣的結果是,不需要操作引用計數。因此移動一個std::shared_ptr比拷貝更快:拷貝需要增加引用計數,但移動不需要。賦值也一樣,移動賦值要比拷貝賦值塊。

類似於std::unique_ptr(條款18),std::shared_ptr使用delete作為默認的銷毀資源手段,但它也支持自定義刪 除器。但是,支持的設計與std::unique_ptr不同。對於std::unique_ptr,自定義刪除器的類型是智能指針類型的一部分,但對於std::shared_pt,卻不是這樣:
auto loggingDel = [](Widget *pw)
{
makeLogEntry(pw);
delete pw;
};

std::unique_ptr // 刪除器的類型是
upw(new Widget, loggingDel); // 指針類型的一部分

std::shared_ptr // 刪除器的類型不是指針類型的一部分
spw(new Wiget, loggingDel);

std::shared_ptr的設計更加靈活。想象一下我們有兩個std::shared_ptr,而指針的刪除器的類型不同:
auto customDelete1 = [](Widget *pw) { ... }; // 自定義刪除器
auto customDelete2 = [](Widget *pw) { ... }; // 兩個類型不同

std::shared_ptr pw1(new Widget, customDelete1);
std::shared_ptr pw2(new Widget, customDelete2);

因為pw1和pw2的類型相同,所以他們可以放進同一個容器:
std::vector> vpw{ pw1, pw2 };

它們也可以相互賦值,都可以傳遞給接受std::shared_ptr的函數。這些事情在帶有不同的刪除器的std::unique_ptr之間是做不到的,因為std::unique_ptr的類型會受到自定義刪除器類型的影響。

還有一個和std::unique_ptr不同,指定自定義刪除器不會改變std::shared_ptr對象的大小。不管是什麼刪除器,一個std::shared_ptr對象的大小都是兩個原生指針。這是好消息,那又會讓你感到不安,自定義刪除器可以是函數對象,而函數對象可以無限大,std::shared_ptr是如何引用一個隨意大小的刪除器而又不使用多余內存的呢?

其實它不行,它可能要使用更多內存。但是這內存不屬於std::shared_ptr的一部分。這內存在堆上,或者,如果std::shared_ptr的創建者利用std::shared_ptr支持自定義分配器的特性,那麼這份內存就由分配器管理。我只是簡單地提起一個std::shared_ptr對象包含一個指針和引用計數,那是對的,不過有點誤導人,因為引用計數是一個更大的數據結構的一部分,這個數據結構是control block(控制塊)。每個shared_ptr管理的對象都有一個控制塊,這個控制卡除了包含引用計數外,還有一份自定義刪除器的拷貝(如果指定的話)。如果指定了自定義分配器,控制卡也包含它的拷貝。控制塊可能還有其他數據——就像條款21解釋那樣,間接引用計數(作為弱引用),不過在本條款,我們先忽視這數據。我們可以將std::shared_ptr管理的內存視圖化,就像這樣:

這裡寫圖片描述

當指向某對象的第一個std::shared_ptr創建時,該對象的控制塊就建立的。至少我們可以這樣假定。通常情況下,創建std::shared_ptr的函數不可能知道是否已經有其它的std::shared_ptr指向該對象,所以控制塊創建要服從以下規則:

std::make_shared(看條款21)總是會創建控制塊。它是加工一個剛new出來的對象,所以這個對象一定不會有控制塊。 當std::shared_ptr由獨占所有權指針(即std::unique_ptrstd::auto_ptr)構造時,控制塊會被創建。獨占所有權的指針不會使用控制塊,所以它指向的對象應該沒有控制塊。(這種構造函數呢,std::shared_ptr會承擔指向對象的所有權,然後獨占所有權指針被設置為空。) 當以原生指針為參數調用std::shared_ptr的構造函數時,會創建控制塊。如果你想從已有控制塊的對象創建一個std::shared_ptr,你可能要傳遞一個std::shared_ptrstd::weak_ptr(看條款20)作為構造函數的參數,而不是原生指針。接受std::shared_ptrstd::weak_ptr為參數的構造函數不會創建新的控制塊,因為它們可以依賴於傳進來的智能指針的控制塊。

這樣的規則會導致一個問題:由單一的原生指針構造std::shared_ptr構造多次會導致未定義行為,因為指向的對象會有多個控制塊。多個控制塊意味著多個引用計數,多個引用計數意味著多次被銷毀。那意味著像下面這樣的代碼是糟糕的,很糟糕的,非常糟糕的:
auto pw = new Widget; // pw 是原生指針
...
std::shared_ptr spw1(pw, loggingDel); // 為*pw創建控制塊
...
std::shared_ptr spw2(pw, loggingDel); // 為*pw創建第二個
控制塊

通過指向動態分配的對象的原生指針創建std::shared_ptr是糟糕的,因為它與本章的建議背道而馳了:用智能指針代替原生指針。不過先把它放到一邊,創建原生指針pw只是一種文體上的憎惡,但至少不會導致未定義行為。

現在呢,spw1以原生指針進行構造,因為它為指向對象創建了一個控制塊(也因此有一份引用計數),在這例子中,它指向的對象是*pw。就其本身而言,這是ok的,但spw2以相同的原生指針進行構造,它也為*pw創建了一個控制塊(和一份引用計數)。因此*pw有兩份引用計數,兩份最終都會變成0,這也最終造成了銷毀*pw兩次,第二次銷毀就會造成未定義行為。

關於std::shared_ptr的使用至少兩點要講。第一,避免用原生指針構造std::shared_ptr。通常的選擇是使用std::make_shared(看條款21),但在某些例子中,我們需要用自定義刪除器,這時沒辦法使用make_shared。第二,如果你一定要用原生指針構造std::shared_ptr,那麼直接把new出來的結果傳遞過去,而不是傳遞原生指針變量。如果第一部分的代碼是這樣寫的:
std::shared_ptr spw1(new Widget, loggingDel); // 直接用new

這就讓用相同的原生指針創建第二個std::shared_ptr變得不那麼誘人。與之代替的是,代碼的作者會很自然的用spw1來初始化spw2(調用的是std::shared_ptr的拷貝構造),這樣就沒什麼問題了:
std::shared_ptr spw2(spw1); // spw2和spw1使用相同的控制塊

令人特別驚訝的是使用原生指針變量作為std::shared_ptr的構造函數的參數,會導致這個指針涉及多個控制塊。假如我們的程序使用std::shared_ptr管理Widget對象,然後我們有個數據結構來記錄已被加工的Widget對象:
std::vector> processdWidgets;

進一步假設Widget有個成員函數來進行加工:
class Widget {
public:
...
void process();
...
};

這裡的Widget::process實現看起來情有可原:
void Widget::process()
{
... // 加工Widget
processedWidgets.emplace_back(this);// 把該對象添加到已加工鏈表中
}// 這樣做是錯的

注釋已經說明了一切,或者說明了大部分。(錯的部分是傳遞this,而不是使用emplace_back,如果你不熟悉emplace_back,請看條款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_from_this,就像這樣:
class Widget : public std::enable_shared_from_this {
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,當你在成員函數裡面,想要一個指向與this指向對象相同的std::shared_ptr時,你就可以使用它了。這裡是Widget::process的安全實現:
void Widget::process()
{
// 像以前一樣,加工Widget
...
// 把指向當前對象的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通常會拋異常。

為了防止成員函數使用shared_from_this之前沒有std::shared_ptr指向當前對象,繼承std::enable_shared_from_this的類常常把構造函數私有,然後讓用戶調用返回類型是std::shared_ptr工的廠函數,從而創建對象。例如,Widget的代碼是這樣的:
class Widget : public std::enable_shared_from_this {
public:
// 工廠函數把參數完美轉發給私有的構造函數
template
static std::shared_ptr create(Ts&&... params);
...
void process(); // 如前
...
private:
... // 構造函數
};

現在呢,你可能只是隱約想起關於控制塊的討論是——因我們要理解std::shared_ptr關聯控制塊的開銷而引起的。既然我們已經知道如何避免創建多個控制塊了,之後我們就回歸主題吧。

一個控制塊通常只有幾個字(word)的大小,即使自定義刪除器和分配器會讓它變大。通常控制塊的實現遠比你想象中復雜。然後它還有虛函數,(這用來確保指向的對象使用合適的析構,即使用默認還是自定義)那意味著std::shared_ptr使用的控制塊也要承擔虛函數裝置的開銷。

知道了動態分配的控制塊、任意大的刪除器和分配、虛函數的裝置、原子操作的引用計數後,你對std::shared_ptr的熱情可能在某種程度上衰落了。這是正常的,它們不是解決資源管理問題的最好方法,但鑒於它提供的功能,std::shared_ptr要求的開銷是情有可原的。典型情況下,std::shared_ptr是通過std::make_shared創建的,使用的是默認的刪除器和默認的分配器,控制塊的大小只有兩道三個字(word),然後動態分配的開銷基本沒有。(這包含分配給指向對象的內存,具體細節看條款21。)解引用一個std::shared_ptr的開銷不比解引用原生指針大,引用計數操作(例如,拷貝構造,拷貝賦值,析構)涉及到一或兩個原子操作,這些操作通常會轉化為一條機器指令,所以盡管它們可能比非原子指令開銷大,但是它們依舊是單一機器指令。通常情況下,管理對象的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管理資源的生命期,盡管引用計數是一,你也不能收回資源的所有權,然後用std::unique_ptr來管理資源。std::shared_ptr和它指向的資源之間的所有權協議是——到死都要一起,不分離,不廢除,不分配。(意思是不能用賦值的方法來把shared_ptr轉換為unique_ptr?)

還有點東西,就是std::shared_ptr不能用於數組。與std::unique_ptr不同,std::shared_ptr的API只為單獨的對象設計,沒有std::shared_ptr。偶爾,一些“聰明”的開發者無意中發現可以使用一個std::shared_ptr指向數組,然後指定自定義刪除器(delete[])。這樣是可以通過編譯的,但這是個可怕的想法,首先,std::shared_ptr沒有提供operator[]成員函數,所以索引數組的值需要尴尬的指針算數表達式。其次呢,std::shared_ptr對於單獨的對象,支持派生類到基類的指針轉換,那是數組卻不行。(因為這個原因,std::unique_ptr的API禁止這樣的轉換。)最重要的是,C++11中,那麼多種替代內置數組的選擇(例如,std::arraystd::vectorstd::string),聲明一個指向原生數組的智能指針幾乎一定是爛設計的表現。


總結

需要記住的4點:

std::shared_ptr提供了一種方便的垃圾回收方法,針對於任意的共享生命期的資源。 與std::unique_ptr相比,std::shared_ptr對象通常是它的兩倍大,需要控制塊和原子操作的引用計數。 默認銷毀資源的方式是delete,但支持自定義刪除器。刪除器的類型不影響std::shared_ptr的類型。 避免用原生指針變量來創建std::shared_ptr
  1. 上一頁:
  2. 下一頁:
Copyright © 程式師世界 All Rights Reserved