本文翻譯自modern effective C++,由於水平有限,故無法保證翻譯完全正確,歡迎指出錯誤。謝謝!
博客已經遷移到這里啦
讓我們先從std::make_unique和std::make_shared的對比開始吧。std::make_shared是C++11的部分,但是,不幸的是,std::make_unique不是。它是在C++14中才被加入到標准庫的。如果你使用的是C++11,不要怕,因為一個std::make_unique的基礎版本很容易寫。看這里:
template<typename T, typename... Ts>
std::unique_ptr<T> make)unique(Ts&&... params)
{
return std::unique_ptr<T>(new T(std::forward<T>(params)...));
}
就像你看到的那樣,make_unique只是完美轉發了它的參數給要創建的對象的構造函數,然后用new產生的原始指針來構造一個std::unique_ptr,最后返回一個創建的std::unique_ptr。這種形式的函數不支持數組或自定義deleter(看Item 18),但是它示范了:如果需要的話,只要做一點努力,你就能創造出make_unique。記住不要把你自己的版本放在命名空間std中,因為你當你升級到C++14版本的標准庫實現時,你肯定不想讓你的版本和自帶的版本起沖突。
std::make_unique和std::make_shared是三個make函數(這種函數能傳入任意集合的參數,然后完美轉發它們給構造函數,並動態創建一個對象,然后返回指向這個對象的智能指針)中的兩個。第三個make函數是std::allocate_shared。它的行為和std::make_shared一樣,唯一的不同就是它的第一個參數是一個分配器(allocator)對象,這個對象是用來動態申請內存的。
對比使用和不使用make函數來創建智能指針,即使是對看起來最不重要的地方進行比較,還是能看出為什么要選擇make函數的第一個原因。考慮一下:
auto upw1(std::make_unique<Widget>()); //使用make函數
std::unique_ptr<Widget> upw2(new Widget); //不使用make函數
auto spw1(std::make_shared<Widget>()); //使用make函數
std::shared_ptr<Widget> spw2(new Widget); //不使用make函數
我已經把本質上不同的地方高亮顯示了(譯注:就是Widget):使用new 的版本要重復寫兩次將創造的類型。重復寫與軟件工程的一個關鍵原則沖突了:避免代碼的重復。重復的源代碼增加了編譯時間,會導致目標代碼變得腫脹,並且通常使得代碼變得更難和別的代碼一起工作。它常常會變成前后不一致的代碼,然后代碼中的前后不一致會造成bug。另外,寫兩次代碼比寫一次要更累,沒有人會想要增加自己的負擔。
優先使用make函數的第二個理由和異常安全有關。假設我們有一個函數來處理Widget,並且需要和優先級關聯起來:
void processWidget(std::shared_ptr<Widget> spw, int priority);
傳入一個std::shared_ptr(以傳值的方式)看起來可能有點奇怪,但是Item 41會解釋,如果processWidget總是要復制一個std::shared_ptr(比如,通過在一個數據結構中存儲它來記錄處理過的Widget),那這就是一個合理的設計選擇。
現在假設我們有一個函數來計算相關的優先級,
int computePriority();
然后我們在調用processWidget的時候使用它,並且用new而不是std::make_shared:
processWidget(std::shared_ptr<Widget>(new Widget), //潛在的資源泄露
computePriority());
就像注釋指示的那樣,上面的代碼會導致new創造出來的Widget發生泄露。那么到底是怎么泄露的呢?調用代碼和被調用函數都用到了std::shared_ptr,並且std::shared_ptr就是被設計來阻止資源泄露的。當最后一個指向這兒的std::shared_ptr消失時,它們會自動銷毀它們指向的資源。如果每個人在每個地方都使用std::shared_ptr,那么這段代碼是怎么導致資源泄露的呢?
答案和編譯器的翻譯有關,編譯器把源代碼翻譯到目標代碼,在運行期,函數的參數必須在函數被調用前被估值,所以在調用processWidget時,下面的事情肯定發生在processWidget能開始執行之前:
- 表達式“new Widget”必須被估值,也就是,一個Widget必須被創建在堆上。
- std::shared_ptr
(負責管理由new創建的指針)的構造函數必須被執行。 - computePriority必須跑完
編譯器不需要必須產生這樣順序的代碼。但“new Widget”必須在std::shared_ptr的構造函數被調用前執行,因為new的結構被用為構造函數的參數,但是computePriority可能在這兩個調用前(后,或很奇怪地,中間)被執行。也就是,編譯器可能產生出這樣順序的代碼:
- 執行“new Widget”。
- 執行computePriority。
- 執行std::shared_ptr的構造函數。
如果這樣的代碼被產生出來,並且在運行期,computePriority產生了一個異常,則在第一步動態分配的Widget就會泄露了,因為它永遠不會被存放到在第三步才開始管理它的std::shared_ptr中。
使用std::make_shared可以避免這樣的問題。調用代碼將看起來像這樣:
processWidget(std::make_shared<Widget>(), //沒有資源泄露
computePriority());
在運行期,不管std::make_shared或computePriority哪一個先被調用。如果std::make_shared先被調用,則在computePriority調用前,指向動態分配出來的Widget的原始指針能安全地被存放到被返回的std::shared_ptr中。如果computePriority之后產生一個異常,std::shared_ptr的析構函數將發現它持有的Widget需要被銷毀。並且如果computePriority先被調用並產生一個異常,std::make_shared就不會被調用,因此這里就不需要考慮動態分配的Widget了。
如果使用std::unique_ptr和std::make_unique來替換std::shared_ptr和std::make_shared,事實上,會用到同樣的理由。因此,使用std::make_unique代替new就和“使用std::make_shared來寫出異常安全的代碼”一樣重要。
std::make_shared(比起直接使用new)的一個特性是能提升效率。使用std::make_shared允許編譯器產生更小,更快的代碼,產生的代碼使用更簡潔的數據結構。考慮下面直接使用new的代碼:
std::shared_ptr<Widget> spw(new Widget);
很明顯這段代碼需要分配內存,但是它實際上要分配兩次。Item 19解釋了每個std::shared_ptr都指向一個控制塊,控制塊包含被指向對象的引用計數以及其他東西。這個控制塊的內存是在std::shared_ptr的構造函數中分配的。因此直接使用new,需要一塊內存分配給Widget,還要一塊內存分配給控制塊。
如果使用std::make_shared來替換,
auto spw = std::make_shared<Widget>();
一次分配就足夠了。這是因為std::make_shared申請一個單獨的內存塊來同時存放Widget對象和控制塊。這個優化減少了程序的靜態大小,因為代碼只包含一次內存分配的調用,並且這會加快代碼的執行速度,因為內存只分配了一次。另外,使用std::make_shared消除了一些控制塊需要記錄的信息,這樣潛在地減少了程序的總內存占用。
對std::make_shared的效率分析可以同樣地應用在std::allocate_shared上,所以std::make_shared的性能優點也可以擴展到這個函數上。
在參數中,比起直接使用new優先使用make函數。盡管它們符合軟件工程,異常安全,以及效率提升。然而,這個Item的引導說的是優先使用make函數,而不是只使用它們。這是因為這里有些它們無法或不該使用的情況。
舉個例子,沒有一個make函數允許自定義deleter(看Item 18和Item 19),但是std::unique_ptr和std::shared_ptr都有允許自定義deleter的構造函數。給出一個Widget自定義deleter,
auto widgetDeleter = [](Widget* pw) {...};
直接使用new來創建一個智能指針:
std::unique_ptr<Widget, decltype(widgetDeleter)>
upw(new Widget, widgetDeleter);
std::shared_ptr<Widget> spw(new Widget, widgetDeleter);
用make函數沒有辦法做到同樣的事情。
make函數的第二個限制源自它們實現的語法細節。Item 7解釋了當創建一個對象時,這個對象的類型同時重載了包含和不包含std::initializer_list參數的構造函數,那么使用花括號創建一個對象會優先使用std::initializer_list版本的構造函數,使用圓括號創建的對象會調用non-std::initializer_list版本的構造函數。make函數完美轉發它的參數給一個對象的構造函數,但是它們應該使用圓括號還是花括號呢?對於一些類型,不同的答案會影響很大。舉個例子,在這些調用中,
auto upv = std::make_unique<std::vector<int>>(10, 20);
auto spv = std::make_shared<std::vector<int>>(10, 20);
結果是一個智能指針指向一個std::vector,這個std::vector應該帶有10個元素,每個元素的值是20,還是說這個std::vector應該帶2個元素,一個值是10,一個值是20?還是說結果應該是不確定的?
一個好消息是,它不是不確定的:兩個調用創建的std::vector都帶10個元素,每個元素的值被設置為20.這意味着使用make函數,完美轉發代碼使用圓括號,而不是花括號。壞消息是如果你想使用花括號來構造你要指向的對象,你必須直接使用new。使用make函數就要求對初始化列表的完美轉發,但是就像Item 30解釋的那樣,初始化列表不能完美轉發。但是,Item 30描述了一個變通方案:使用auto類型推導來從初始化列表(看Item 2)創建一個std::initializer_list對象,然后傳入“通過auto創建的”對象給make函數:
//創建std::initializer_list
auto initList = { 10, 20 };
//使用std::initializer_list構造函數來創建std::vector
auto spv = std::make_shared<std::vector<int>>(initList);
對於std::unique_ptr,這兩個情況(自定義deleter和初始化列表)就是make函數可能有問題的全部情況了。對於std::shared_ptr和它的make函數來說,還有兩個問題。兩個都是邊緣情況,但是一些開發者是會遇到的,而且你可能就是其中一個。
有些類定義了它們自己的operator new和operator delete。這些函數的存在暗示了全局的內存分配和回收規則對這些類型不適用。常常,特定的類通常只被設計來分配和回收和這個類的對象大小完全一樣的內存塊,比如,Widget類的operator new和operator delete常被設計來分配和回收sizeof(Widget)大小的內存。這樣的分配規則不適合std::shared_ptr對自定義分配(通過std::allocate_shared)和回收(deallocation)(通過自定義deleter)的支持,因為std::allocate_shared要求的總內存大小不是動態分配的對象大小,而是這個對象的大小加上控制塊的大小。總的來說,如果一個對象的類型有特定版本的operator new和operator delete,那么使用make函數來創建這個對象常常是一個糟糕的想法。
std::make_shared比起直接使用new在大小和速度方面上的提升源自於std::shared_ptr的控制塊被放在和對象一起的同一塊內存中。當對象的引用計數變成0的時候,對象被銷毀了(也就是它的析構函數被調用了)。但是,直到控制塊被銷毀前,它占據的內存都不能被釋放,因為動態分配的內存塊同時包含了它們兩者。
就像我說的,控制塊除了包含引用計數以外,還包含了一些記錄信息。引用計數記錄了有多少std::shared_ptr引用了控制塊,但是控制塊包含第二個引用計數,這個引用計數記錄了有多少std::weak_ptr引用這個控制塊。第二個引用計數被稱為weak count。當一個std::weak_ptr檢查自己是否失效(看Item 19)時,它是通過檢查它引用的控制塊中的引用計數(不是weak ount)來做到的。如果引用計數是0(也就是如果它指向的對象沒有std::shared_ptr引用它,這個對象因此已經被銷毀了),那么std::weak_ptr就失效了,不然就沒失效。
只要std::weak_ptr引用一個控制塊(也就是weak count大於0),控制塊就必須繼續存在。並且只要控制塊存在,那么包含它的內存塊就必須不能釋放(remain allocated,保持分配狀態)。因此,直到引用這個控制塊的最后一個std::shared_ptr和最后一個std::weak_ptr銷毀前,由std::shared_ptr的make函數分配的內存都不能被回收。
如果對象類型很大,並且最后一個std::shared_ptr和最后一個std::weak_ptr銷毀的間隔很大,那么一個對象銷毀和它所占內存的釋放之間,將會產生一定的延遲:
class ReallyBigType { ... };
auto pBigObj = //通過std::make_shared
std::make_shared<ReallyBigType>(); //創建一個很大的對象
... //創建std::shared_ptr和std::weak_ptr指向這個大對象,
//並且使用它們做一些事情。
... //最后一個指向對象的std::shared_ptr在這里銷毀,但是
//指向它的std::weak_ptr還存在
... //在這段時間,原先由大對象占據的內存還是沒有被釋放
... //最后一個指向對象的std::weak_ptr在這里銷毀,控制塊
//和對象的內存在這里釋放。
當直接使用new時,只要最后一個指向ReallyBigType對象的std::shared_ptr銷毀了,這個對象的內存就能被釋放:
class ReallyBigType { ... };
std::shared_ptr<ReallyBigType> pBigObj(new ReallyBigType);
//使用new來創建這個大對象
... //和之前一樣,創建std::shared_ptr和std::weak_ptr指向
//這個大對象,並且使用它們做一些事情。
... //最后一個指向對象的std::shared_ptr在這里銷毀,但是
//指向它的std::weak_ptr還存在
//但是!對象的內存被釋放了
... //在這段時間,只有控制塊的內存沒有被釋放
... //最后一個指向對象的std::weak_ptr在這里銷毀,控制塊
//的內存被釋放。
如果你發現,當std::make_shared不可能或不適合使用時,你就會注意到我們之前看過的異常安全的問題。解決它的最好的辦法就是確保當你直接使用new的時候,你是在一條語句中直接(沒有做其他事)傳入結果給一個智能指針的構造函數。這能阻止編譯器在new和調用智能指針(之后會管理new出來的對象)的構造函數之間,產生會造成異常的代碼。
作為一個例子,對於之前我們看過的非異常安全的processWidget調用,考慮一下對它進行一個最簡單的修改。這次,我們將指定一個自定義deleter:
void processWidget(std::shared_ptr<Widget> spw,
int priority);
void cusDel(Widget *ptr); //自定義deleter
這里給出一個非異常安全的調用:
processWidget(
std::shared_ptr<Widget>(new Widget, cusDel),
computePriority()
);
回憶一下:如果computePriority在new Widget之后std::shared_ptr構造函數之前調用,並且如果computePriority產生了一個異常,那么動態分配的Widget將會泄露。
這里自定義deleter的使用阻礙了std::make_shared的使用,所以避免這個問題的方法就是把Widget的分配和std::shared_ptr的構造放在單獨的語句中,然后用產生的std::shared_ptr調用processWidget。雖然,和之后看到的一樣,我們能提升它的性能,但是這里先給出這個技術的本質部分:
std::shared_ptr<Widget> spw(new Widget, cusDel);
processWidget(spw, computePriority()); //對的,但是不是最優的,看下面
這能工作,因為一個std::shared_ptr承擔傳給構造函數的原始指針的所有權,即使是這個構造函數會產生一個異常。在這個例子中,如果spw的構造函數拋出一個異常(比如,由於不能動態分控制塊的內存),它還是能保證用new Widget產生的指針來調用cusDel。
最小的性能障礙是,在非異常安全調用中,我們傳一個右值給processWidget,
processWidget(
std::shared_ptr<Widget>(new Widget, cusDel),
computePriority());
但是在異常安全的調用中,我們傳入了一個左值:
processWidget(spw, computePriority());
因為processWidget的std::shared_ptr參數是以傳值(by value)方式傳遞的,所以從右值構造對象只需要move,從左值來構造對象需要copy。對於std::shared_ptr,它們的不同會造成很大的影響,因為拷貝一個std::shared_ptr要求對它的引用計數進行一個原子的自增操作,但是move一個std::shared_ptr不需要維護引用計數。要讓異常安全的代碼實現同非異常安全的代碼一樣級別的性能,我們需要把std::move應用到spw中,把它變成右值(看Item23):
processWidget(std::move(spw), //性能和異常安全都有保證
computePriority());
這很有趣並且值得知道,但是它常常是不重要的,因為你很少有原因不使用make函數。並且除非你有迫不得已的理由不使用make函數,不然你應該多使用make函數。
你要記住的事
- 對比直接使用new,make函數消除了源代碼的重復,提升了異常安全性,並且對於std::make_shared和std::allocate_shared,產生的代碼更小更快。
- 不適合使用make函數的情況包括:需要指定自定義deleter,需要傳入初始化列表。
- 對於std::shared_ptr,額外使用make函數的欠考慮的情況包括(1)有自定義內存管理的類和(2)需要關心內存,對象很大,std::weak_ptr比對應的std::shared_ptr存在得久的系統。