程序師世界是廣大編程愛好者互助、分享、學習的平台,程序師世界有你更精彩!
首頁
編程語言
C語言|JAVA編程
Python編程
網頁編程
ASP編程|PHP編程
JSP編程
數據庫知識
MYSQL數據庫|SqlServer數據庫
Oracle數據庫|DB2數據庫
 程式師世界 >> 編程語言 >> C語言 >> C++ >> 關於C++ >> Effective Modern C++ 條款21 比起直接使用new,更偏愛使用std::make_unique和std::make_shared

Effective Modern C++ 條款21 比起直接使用new,更偏愛使用std::make_unique和std::make_shared

編輯:關於C++

比起直接使用new,更偏愛使用std::make_unique和std::make_shared

讓我們從std::make_uniquestd::make_shared之間的比較開始講起吧。std::make_shared是C++11的一部分,可惜的是,std::make_unique不是,它在C++14才納入標准庫。如果你使用的是C++11,不用憂傷,因為std::make_unique的簡單版本很容易寫出來,不信你看:
template
std::unique_ptr make_unique(Ts&&... params)
{
return std::unique_ptr(new T(std::forward(params)...));
}

就像你看到的那樣,make_unique只是把參數完美轉發給要創建對象的構造函數,再從new出來的原生指針構造std::unique_ptr,最後返回創建的std::unique_ptr。這種形式的函數不支持數組和自定義刪除器(看條款18),但它說明了只要一點點工作,你就可以創造你需要的make_unique了。你要記住不要把你自己的版本放入命名空間std,因為當你提升到C++14標准庫實現的時候,你不會想要它和標准庫的版本沖突。

std::make_uniquestd::make_shared是三個make函數中的其中兩個,而make函數是:把任意集合的參數完美轉發給動態分配對象的構造函數,然後返回一個指向那對象的智能指針。第三個make函數是std::allocate_shared,它的行為與std::make_shared類似,除了它第一個參數是個分配器,指定動態分配對象的方式。

通過瑣碎比較使用make函數和不使用make函數創建智能指針,揭露了使用make函數更可取的第一個原因。考慮以下:
auto upw1(std::make_unique()); // 使用make函數

std::unique_ptr upw2(new Widget); // 不使用make函數

auto spw1(std::make_shared()); // 使用make函數

std::shared_ptr spw2(new Widget); // 不使用make函數

它們本質上的不同是:使用new的版本重復著需要創建的類型(即出現了兩次Widget),而使用make’函數不需要。重復出現類型和軟件工程的關鍵原則產生沖突:應該避免代碼重復。源碼中重復的代碼會增加編譯時間,導致對象代碼膨脹,並且通常會讓代碼庫更難運行——這經常引發不合邏輯的代碼,而不合邏輯的代碼庫一般會出現bug。除非是寫兩次比寫一個更有效果,不然誰不喜歡少些點代碼嗎?


更偏愛使用make函數的第二個原因異常安全。假如我們有個函數,根據優先級來處理Widget:
void processWidget(std::shared_ptr spw, int priority);

值傳遞std::shared看起來有點奇怪,不過條款41會解釋如果processWidget內部總是復制std::shared_ptr(例如,把它存儲在一個數據結構中,這個數據結構會監測Widget是否被處理),這設計是個合理的選擇。

現在呢,假如我們有個計算優先級的函數,
int computePriority();

然後我們用它和new創建的智能指針作為參數調用processWidget:
processWidget(std::shared_ptr(new Widget),
computePriority()); // 可能會資源洩漏

就如注釋所說,這代碼中new出來的Widget可能會洩漏,但是為什麼?std::shared_ptr是為了防止資源洩漏而設計的,當最後一個指向資源的std::shared_ptr對象消失,它們指向的資源也會被銷毀。如果每個人無論什麼地方都使用std::shared_ptr,C++還有內存洩漏這回事嗎?

答案是在編譯期間,源代碼轉換為目標碼時(*.o文件)。在運行時間,函數的參數在函數運行前必須被求值,所以調用processWidget時,下面的事請會在processWidget開始前執行:

表達式“new Widget”會被求值,即,一個Widget對象必須在堆上被創建。 std::shared_ptr的接收原生指針的構造函數一定要執行。 computePriority一定要運行。

編譯器在生成代碼時不會保證上面的執行順序,“new Widget”一定會在std::shared_ptr構造函數之前執行,因為構造函數需要new的結果,但是computePriority可能在它們之前就被調用了,可能在它們之後,可能在它們之間。所以,編譯器生成代碼的執行順序有可能是這樣的:

執行“new Widget”。 執行computePriority。 執行std::shared_ptr的構造函數。

如果生成的代碼真的是這樣,那麼在運行時,computePriority產生了異常,步驟1中動態分配的Widget就洩漏了,因為它沒有被步驟3中的std::shared_ptr保存。

使用std::make_shared_ptr可以避免這問題。這樣調用代碼:
processWidget(std::make_shared(), computePriority())

在運行期間,std::make_sharedcomputePriority都有可能先被調用,如果先調用的是std::make_shared,那麼指向動態分配Widget對象的原生指針會安全地存儲在要返回的std::shared_ptr中,然後再調用computePriority。如果computePriority產出異常,std::shared_ptr的析構函數就會銷毀持有的Widget。而如果先調用的是computePriority,並且產生異常,std::make_shared就不會被執行,因此沒有動態分配的Widget對象讓你擔心。

如果我們把std::shared_ptrstd::make_shared替換成std::unique_ptrstd::make_unique,效果一樣。使用std::make_unique替代new的重要性就像使用std::make_shared那樣:寫異常安全的代碼。


std::make_shared的一個特點(相比於直接使用new)是提高效率。使用std::make_shared允許編譯器生成更小、更快的代碼。考慮當我們直接使用new時:
std::shared_ptr spw(new Widget);

很明顯這代碼涉及一次內存分配,不過,它實際上分配兩次。條款19說明每個std::shared_ptr內都含有一個指向控制塊的指針,這控制塊的內存是由std::shared_ptr的構造函數分配的,那麼直接使用new,需要為Widget分配一次內存,還需要為控制塊分配一次內存。

如果用std::make_shared呢,
auto spw = std::make_shared();

一次分配就夠了,因為std::make_shared會分配一大塊內存來同時持有Widget對象和控制塊。這種優化減少了程序的靜態尺寸,因為代碼只需要調用一次內存分配函數,然後它增加了代碼執行的速度,因為只需要分配一次內存(說明是分配內存這個函數開銷略大)。而且,使用std::make_shared能避免了一些控制塊的簿記信息,潛在地減少了程序占用的內存空間。

std::allocate_shared的性能分析和std::make_shared一樣,所以std::make_shared的性能優勢也可以延伸到std::allocate_shared


比起直接使用new,更偏愛使用make函數,這個爭論是很熱烈的。雖有軟件工程、異常安全、性能優勢,不過,本條款的指導方針是更偏愛使用make函數,而不是單獨依賴它們,這是因為在某些狀況下它們不適用。

例如,沒有一個make函數可以指定自定義刪除器(看條款18和19),但是std::unique_ptrstd::shared_ptr都有這樣的構造函數。給定一個Widget的自定義刪除器,
auto widgetDeleter = [](Widget* pw) {...}

我們可以直接使用new創建智能指針:
std::unique_ptr
upw(new Widget, widgetDeleter);

std::shared_ptr spw(new Widget, widgetDeleter);

make函數就做不來這種事情。


make函數的第二個限制是來源於它們實現的句法細節。條款7說明當創建一個對象時,如果該對象的重載構造函數帶有std::initializer_list參數,那麼使用大括號創建對象會偏向於使用帶std::initializer_list構造,要使用圓括號創建對象才能使用到非std::initializer_list構造。make函數把它們的參數完美轉發給對象的構造函數,那麼它們用的是大括號還是圓括號呢?對於某些類型,這問題的答案的不同會導致結果有很大差異。例如,在這些調用中,
auto upv = std::make_unique>(10, 20);

auto spv = std::make_shared>(10, 20);

指針指向的是帶10個元素、每個值為20的std::vector呢,還是指向兩個元素、一個10、一個20的std::vector呢?還是說結果不能確定嗎?

好消息是結果是能確定的:上面兩個都創建內含10個值為20的std::vector。那意味著在make函數內,完美轉發使用的是圓括號,而不是大括號。壞消息是如果你想用大括號初始化來構造指向的對象,你只能直接使用new,如果你想使用make函數,就要求完美轉發的能力支持大括號初始化,但是條款30說明,大括號初始化不能被完美轉發。不過條款30也講了一種能工作的方法:用auto推斷大括號,從而創建一個std::initializer_list對象(條款2),然後把auto變量傳遞給make函數:
// 創建 std::initializer_list
auto initList = {10, 20};

// 使用std::initializer_list構造函數創建std::vector,容器中只有兩個元素
auto spv = std::make_shared>(initList);

對於std::unique_ptr,只有兩種情況(自定義刪除器和大括號初始化)會讓它的make函數出問題。對於std::shared_ptr和它的make函數,就多兩種情況,這兩種情況都是邊緣情況,不過一些開發者就喜歡住在邊緣,你可能就是他們中第一個。

一些類定義了自己的operator newoperator delete函數,這些函數的出現暗示著常規的全局內存分配和回收不適合這種類型的對象。通常情況下,設計這些函數只有為了精確分配和銷毀對象,例如,Widget對象的operator newoperator delete只有為了精確分配和回收大小為sizeof(Widget)的內存塊才會設計。這兩個函數不適合std::shared_ptr的自定義分配(借助std::allocate_shared)和回收(借助自定義刪除器),因為std::allocate_shared請求內存的大小不是對象的尺寸,而是對象尺寸加上控制塊尺寸。結果就是,使用make函數為那些——定義自己版本的operator newoperator delete的——類創建對象是個糟糕的想法。


比起直接使用newstd::make_shared的占用內存大小和速度優勢來源於:std::shared_ptr的控制塊與它管理的對象放在同一塊內存。當引用計數為0時,對象被銷毀(即調用了析構函數),但是,它使用的內存不會釋放,除非控制塊也被銷毀,因為對象和控制塊在同一塊動態分配的內存上。

就像我提起那樣,控制塊上除了引用計數還有別的薄記信息。引用計數記錄的是有多少std::shared_ptr指向控制塊,但是控制塊還有第二種引用計數,記錄有多少std::weak_ptr指向控制塊。這種引用計數稱為weak count。當std::weak_ptr檢查它是否過期時(expired,看條款20),它通過檢查控制塊中的引用計數(不是weak count)來實現。如果引用計數為0(即沒有std::shared_ptr指向這個對象,因此被銷毀),std::weak_ptr就過期,否則就沒有過期。

但是,只要有std::weak_ptr指向控制塊(weak count大於0),控制塊就必須繼續存在,而只要控制塊存在,容納它的內存塊也依舊存在。那麼,通過make函數創建對象分配的內存,要直到最後一個指向它的std::shared_ptrstd::weak_ptr對象銷毀,才能被回收。

如果對象的類型非常大,並且最後一個std::shared_ptr銷毀和最後一個std::weak_ptr銷毀之間的時間間隔很大,那麼是對象銷毀和內存被回收之間的會有延遲:
class ReallyBigType { ... };

auto pBigObj = // 借助std::make_shared
std::make_shared(); // 創建類型非常大的對象
... // 創建std::shared_ptr和std::weak_ptr指向對象
... // 最後一個std::shared_ptr被銷毀,那仍有std::weak_ptr存在
...// 在這個期間,之前類型非常大的對象使用的內存仍然被占用
... // 最後一個std::weak被銷毀,控制塊和對象共占的內存被釋放

如果直接使用new,ReallyBigType對象的內存只要在最後一個std::shared_ptr被銷毀就能被釋放:
class ReallyBigType { ... }; // 如前

std::shared_ptr pBigObj(new ReallyBigType); //借助new創建
... // 如前,創建std::shared_ptr和std::weak_ptr指向對象
... // 最後一個std::shared_ptr被銷毀,仍有std::weak_ptr存在
// 對象的內存被回收
... // 在這期間,只有控制塊的內存被占用
... // 最後一個指向對象的std::weak_ptr被銷毀,控制塊的內存被釋放


當你發現某些情況不能使用或者不適合使用std::make_shared,卻又想要防止容易發生的異常安全問題。最好的辦法就是確保當你直接使用new時,用一條語句執行——把new的結果馬上傳遞給智能指針的構造函數,並且該語句就做這一件事。這防止編譯器生成newstd::shared_ptr構造之間發出異常。

作為例子,我們修改之前的異常不安全processWidget,並指定自定義刪除器:
void processWidget(std::shared_ptr spw, int priority); // 如前

void cusDel(Widget *ptr); // 自定義刪除器

這裡是異常不安全的調用:
processWidget( // 如前,可能資源洩漏
std::shared_ptr(new Widget, cusDel),
computePriority()
);

回憶:如果computePriority調用在“new Widget”之前,std::shared_ptr構造之後,然後computePriority產生異常,那麼動態分配的Widget就會洩漏。

這裡要使用自定義刪除器,不能使用std::make_shared,所以避免洩漏的方法就是把分配Widget和std::shared_ptr構造放在只屬於它們的語句,然後再用std::shared_ptr的結果調用processWidget。這是這項技術的本質部分,等下我們可見到,我們可以修改它從而提高性能:
std::shared_ptr spw(new Widget, cusDel);

processWidget(spw, computeWidget); // 正確,但沒有優化,看下面

這代碼是可行的,因為std::shared_ptr得到了原生指針的所有權,盡管構造函數可能發出異常。在這個例子中,如果spw的構造期間拋出異常(例如,由於不能為控制塊動態分配內存),也能保證cusDel被調用(以“new Widget”的結果為參數)。

有個小小的性能問題,在異常不安全的調用中,我們傳給processWidget的是一個右值,
processWidget(
std::shared_ptr(new Widget, cusDel), // 參數是右值
computePriority()
);

但是在異常安全的調用中,我們傳遞的是個左值:
processWidget(spw, computePriority()); // 參數是左值

因為processWidget的std::shared_ptr參數是值傳遞,從一個右值構造使用的是移動,從一個左值構造使用的是拷貝。對於std::shared_ptr,這差別挺大的,因為拷貝一個std::shared_ptr需要增加它的引用計數,這是原子操作,而移動操作完全不用操作引用計數。針對於異常安全代碼想要達到異常不安全代碼的性能水平,我們需要使用std::move來把spw轉化為右值(看條款23):
processWidget(std::move(spw), computePriority()); // 現在也一樣高效

這是有趣的而且值得知道,但是通常也是不相干的,因為你很少有理由不用make函數,除非你有迫不得已的理由,否則,你應該使用make函數。

總結

需要記住的3點:

相比於直接使用new,make函數可以消除代碼重復,提高異常安全,而且std::make_sharedstd::allocate_shared生成的代碼更小更快。 不適合使用make函數的場合包括需要指定自定義刪除器和想要傳遞大括號初始值。 對於std::shared_ptr,使用make函數可能是不明智的額外場合包括(1)自定義內存管理函數的類和(2)內存緊張的系統中,有非常大的對象,然後std::weak_ptrstd::shared_ptr長壽。
  1. 上一頁:
  2. 下一頁:
Copyright © 程式師世界 All Rights Reserved