Effective Modern C++ 條款18 用std::unique_ptr管理獨佔所有權的資源
用std::unique_ptr管理獨佔所有權的資源
當你伸手觸碰智慧指標的時候,std::unique_ptr通常是最觸手可及的一個。這樣認定它是有道理的,預設情況下,std::unique_ptr的大小與原生指標相同,然後它的大部分操作(包括解引用),執行的指令與原生指標執行的指令相同。 這意味著在記憶體緊張和排程密集的情況下,你仍然可以使用它。如果你覺得原生指標又小又快,那麼std::unique_ptr幾乎也是這樣。
std::unique_ptr表示獨佔所有權 語義。一個非空的std::unique_ptr會一直擁有它指向的物件。移動一個std::unique_ptr,所有權會從源指標轉移到目的指標。(之後源指標會設定為空指標。)拷貝std::unique_ptr
std::unique_ptr的一個常見使用是作為工廠函式的返回型別(在分層中)。假如我們有一個投資(investment)型別的分層(例如,包含股票stock,債券bond,房地產real estate,等等),基類是Inverstment:
class Investment { ... };
class Stock : public Investment { ... };
class Bond : public Investment { ... };
class RealEstate : public Investment { ... };
這種分層的工廠返回通常從堆上分配一個物件,然後返回一個指向它的指標,當不再需要它的時候,呼叫者要負責delete這個物件。那真是完美匹配std::unique_ptr,因為呼叫者需要為工廠返回的資源負責(即獨佔資源的所有權),然後std::unique_ptr在它銷燬的時候可以自動delete
template <typename... Ts> // 返回一個由給定引數
std::unique_ptr<Investment> // 建立的物件的指標
makeInvestment(Ts&&... params);
呼叫者可以在一個區域性作用域使用返回的std::unique_ptr,就像這樣:
{
...
auto pInvestment = // pInvestment的型別是
makeInvestment(*arguments*); // std::unique_ptr<Investment>
...
} // 銷燬 *pInvestment
不過它們也可以使用遷移語義,例如,工廠返回的std::unique_ptr被移入容器中,隨後容器的元素移動到一個物件的成員變數中,最終物件會被銷燬。當發生這種事時,物件的std::unique_ptr成員變數也會被銷燬,然後它的銷燬會導致資源的銷燬。如果所有權傳遞鏈因為異常或非典型控制流(例如,函式過早的return或者迴圈過早的break)而中斷,那麼管理資源的std::unique_ptr最終還是會呼叫解構函式,隨後銷燬管理的資源。
預設情況下,std::unique_ptr是藉助delete來銷燬管理的資源,但是,在構造std::unique_ptr期間,你可以指定使用自定義的刪除器:當std::unique_ptr銷燬資源時呼叫的函式(或者函式物件,包括lambda表示式)。如果由makeInvestment建立的物件不應該直接delete,而是應該首先記錄日誌,那麼makeInvestment可以這樣實現(在程式碼後會有解釋,所以不用擔心看不懂程式碼):
auto delInvmt = [](Investment *pInvestment) // 自定義的刪除器
{ // 一個lambda表示式
makeLogEntry(pInvestment);
delete pInvestment;
};
template <typename... Ts>
std::unique_ptr<Investment, decltype(delInvmt)> // 修改返回型別
makeInvestment(Ts&&... params)
{`
std::unique_ptr<Investment, decltypedelInvmt)>
pInv(nullptr, delInvmt); // 將要返回的指標
if ( /* a Stock object should be created */ )
{
pInv.reset(new Stock(std::forward<Ts>(params)...));
}
else if (/* a Bond object should be created */)
{
pInv.reset(new Bond(std::forward<Ts>(params)...));
}
else if ( /* a RealEstate obejct should be created */ )
{
pInv.reset(new RealEstate(std::forward<Ts>(params)...));
}
return pInv;
}
等下我會講解它是怎樣工作的,不過先來考慮在呼叫者看來,事情是怎樣的。假定你用auto變數儲存makeInvestment的結果,你對於你申請的資源在釋放時的特殊處理毫不知情,卻傻傻地欣然使用。事實上呢,你是可以沉浸在歡喜之中,因為使用std::unique_ptr意味著你不用關心資源什麼時候銷燬,更不用說資源銷燬是一定會發生的。std::unique_ptr會自動處理好所有的事情,從使用者的角度看,makeInvestment這個介面太爽了。
一旦你理解了下面的內容,你會覺得makeInvestment的實現也非常漂亮:
delInvmt
是從makeInvestment返回物件的自定義刪除器。所有的自定義刪除函式都是接受一個指向需要銷燬的物件的原生指標作為引數,然後它做的事情是銷燬物件的必要工作。在這個例子中,刪除器的行為是呼叫makelogEntry,然後應用delete。使用lambda表示式建立delInvmt
是很方便的,不過我們很快就能看到,它還會比傳統函式高效。- 當我們使用自定義的刪除器的時候,它的型別要作為std::unique_ptr的第二個模板引數。在這個例子中,那是
delInvmt
的型別,這也是為什麼makeInvestment的返回型別是std::unique_ptr<Investment, decltype(delInvmt)>
。(關於decltype,看條款3。) - makeInvestment的基本策略是先建立一個空的std::unique_ptr,然後指向一個型別合適的物件,然後返回它。為了關聯刪除器
delInvmt
和pInvmt
,我們把刪除器作為第二個構造引數傳遞給std::unique_ptr。 - 試圖將原生指標(例如new出來的指標)賦值給std::unique_ptr是不會通過編譯的,因為這導致一個從原生指標到智慧指標的隱式轉換,這樣的隱式轉換是有問題的,所以C++11的智慧指標禁止這樣的轉換。那就是為什麼reset被用來——讓
pInvmt
得到new出來的物件的所有權。 - 對於每個new,我們都用std::forward來完美轉發makeInvestment的引數。這樣的話,建立物件的建構函式能得到呼叫者提供的所有的資訊。
- 自定義刪除器的引數型別是
Investment *
。不管makeInvestment函式實際建立的物件是什麼型別(例如,Stock,Bond,RealEstate),它最終都會在lambda表示式裡作為一個Investment *
物件被刪除。這意味著我們可以通過基類指標刪除派生類物件。為了實現這項工作,基類——Investment——必須要有一個虛解構函式:
class Investment {
public:
...
virtual ~Investment();` // 設計的基本組成成分
...
};
在C++14,函式返回型別推斷(條款3)意味著makeInvestment可以實現得更簡單和更具有封裝性:
template <typename... Ts>
auto makeInvestment(Ts&&... params) // C++14
{
auto delInvmt = [](Investment* pInvestment) // 自定義刪除器
{ // 內置於makeInvestment
makeLogEntry(pInvestment);
delete pInvestment;
};
std::unique_ptr<Investment, decltype(delInvmt)>
pInv(nullptr, delInvmt);
`
if (...)
{
pInv.reset(new Stock(std::forward<Ts>(params)...));
}
else if (...)
{
pInv.reset(new Bond(std::forward<Ts>(params)...));
}
else if (...)
{
pInv.reset(new RealEstate(std::forward<Ts>(params)...));
}
return pInv;
}
我在之前說過,當你使用預設刪除器時(即delete),你有理由假定std::unique_ptr物件的大小和原生指標一樣。當自定義刪除器出現後,就不是這樣了。如果刪除器是函式指標,它通常會讓std::unique_ptr的大小增加一到兩個字(word)。如果刪除器是函式物件,std::unique_ptr的大小改變取決於函式物件儲存了多少狀態。無狀態的函式物件(例如,不捕獲變數lambda表示式)不會受到一絲代價,這意味著當自定義刪除器即可用函式實現又可用不捕獲變數的lambda表示式實現時,lambda實現會更好:
auto delInvmt1 = [](Investment* pInvestment) // 自定義刪除器是
{ // 不捕獲變數的
makeLogEntry(pInvestment); // lambda表示式
delete pInvestment;
};
template <typename... Ts>
std::unique_ptr<Investment, decltype(delInvmt11)> // 返回型別的大小
makeInvestment(Ts&&... args); // 與Investment*相同
void delInvmt2(Investment* pInvestment) // 自定義刪除器是函式
{
makeLogEntry(pInvestment);
delete pInvestment;
}
`
template <typename... Ts> // 返回型別大小為
std::unique_ptr<Investment, void (*)(Investment*)> // Investment* 加上
makeInvestment(Ts&&... params); // 至少一個函式指標的尺寸
帶有大狀態的函式物件會造成很大的std::unique_ptr。如果你發現自定義刪除器讓你的std::unique_ptr大得不能接受,那麼你很可能需要改變你的設計了。
std::unique_ptr的常見使用不只是工廠函式,更普遍的是作為一種實現Pimpl Idiom的技術。這種程式碼不難實現,但是不夠直截了當,因此條款22我會專門講它。
std::unique_ptr有兩種形式,一種是單獨的物件(std::unique_ptr<T>),另一種是陣列(std::unique_ptr<T[]>)。這樣的結果是,std::unique_ptr指向的實體型別決不會是含糊的。std::unique_ptr的設計可以精確匹配你使用的形式。例如,單獨的物件形式是沒有下標引用操作(operator[]),而陣列形式沒有解引用操作(operator*和operator->)。
std::unique_ptr的陣列形式的知道就好啦,因為比起原生陣列,std::array,std::vetcor,std::string實際上更好的資料結構。我能想到的std::unique_ptr陣列唯一有意義場景就是,當你使用C-like風格的API,並且這API返回一個指像陣列的指標,陣列是從堆分配的,你需要對這個陣列負責。
在C++11中,std::unique_ptr是表達獨佔所有權的方式,但它最吸引人的一個特性是它能即簡單又高效地轉化為std::shared_ptr:
std::shared_ptr<Investment> sp = // 把 std::unique_ptr轉換為
makeInvestment(argument); // std::shared_ptr
這是為什麼std::unique_ptr如此適合做工廠函式的關鍵原因,工廠函式不會知道:獨佔所有權語義和共享所有權語義哪個更適合呼叫者。通過返回一個std::unique_ptr,工廠提供給呼叫者的是最高效的智慧指標,但它不妨礙呼叫者用std::shared_ptr來替換它(關於std::shared_ptr的資訊,看條款19)。
總結
需要記住的3點:
- std::unique_ptr是一個智慧,快速,只可移動的智慧指標,它以獨佔所有權語義管理資源。
- 預設情況下,通過delete來銷燬資源,但可以指定自定義刪除器。有狀態的刪除器和函式指標作為std::unique_ptr的刪除器會增加std::unique_ptr物件的大小。
- 將std::unique_ptr轉換為std::shared_ptr是容易的。