C++如何正確使用智能指針?看完這4個點你就明白了

在這裏插入圖片描述
轉自:https://www.toutiao.com/i6744265558922887694/?tt_from=dingtalk&utm_campaign=client_share&timestamp=1570943692&app=news_article&utm_source=dingtalk&utm_medium=toutiao_ios&req_id=201910131314520100140470302F42B2DC&dtshare_count=1&group_id=6744265558922887694

C++11 中推出了三種智能指針,unique_ptr、shared_ptr 和 weak_ptr,同時也將 auto_ptr 置爲廢棄(deprecated)。

但是在實際的使用過程中,很多人都會有這樣的問題:

  1. 不知道三種智能指針的具體使用場景
  2. 無腦只使用 shared_ptr
  3. 認爲應該禁用 raw pointer(裸指針,即Widget*這種形式),全部使用智能指針

本文試圖理清楚三種智能指針的具體使用場景,並講解三種智能指針背後的性能消耗。
對象所有權
首先需要理清楚的概念就是對象所有權的概念。所有權在 rust 語言中非常嚴格,寫 rust 的時候必須要清楚自己創建的每個對象的所有權。

但是 C++比較自由,似乎我們不需要明白對象的所有權,寫的代碼也能正常運行。但是明白了對象所有權,我們纔可以正確管理好對象生命週期和內存問題。

C++引入了智能指針,也是爲了更好的描述對象所有權,簡化內存管理,從而大大減少我們 C++內存管理方面的犯錯機會。

unique_ptr:專屬所有權
我們大多數場景下用到的應該都是 unique_ptr。unique_ptr 代表的是專屬所有權,即由 unique_ptr 管理的內存,只能被一個對象持有。所以,unique_ptr 不支持複製和賦值,如下:

auto w = std::make_unique<Widget>();
auto w2 = w; // 編譯錯誤

如果想要把 w 複製給 w2, 是不可以的。因爲複製從語義上來說,兩個對象將共享同一塊內存。

因此,unique_ptr 只支持移動, 即如下:

auto w = std::make_unique<Widget>();
auto w2 = std::move(w); // w2獲得內存所有權,w此時等於nullptr

unique_ptr 代表的是專屬所有權,如果想要把一個 unique_ptr 的內存交給另外一個 unique_ptr 對象管理。只能使用 std::move 轉移當前對象的所有權。轉移之後,當前對象不再持有此內存,新的對象將獲得專屬所有權。

如上代碼中,將 w 對象的所有權轉移給 w2 後,w 此時等於 nullptr,而 w2 獲得了專屬所有權。

因爲 C++的 zero cost abstraction 的特點,unique_ptr 在默認情況下和裸指針的大小是一樣的。所以內存上沒有任何的額外消耗,性能是最優的。

使用場景 1:忘記 delete

unique_ptr 一個最簡單的使用場景是用於類屬性。代碼如下:

class Box{
public:
 Box() : w(new Widget())
 {}
 ~Box()
 {
 // 忘記delete w
 }
private:
 Widget* w;
};

如果因爲一些原因,w 必須建立在堆上。如果用裸指針管理 w,那麼需要在析構函數中delete w; 這種寫法雖然沒什麼問題,但是容易漏寫 delete 語句,造成內存泄漏。

如果按照 unique_ptr 的寫法,不用在析構函數手動 delete 屬性,當對象析構時,屬性w將會自動釋放內存。

使用場景 2:異常安全

假如我們在一段代碼中,需要創建一個對象,處理一些事情後返回,返回之前將對象銷燬,如下所示:

void process()
{
 Widget* w = new Widget();
 w->do_something(); // 可能會發生異常
 delete w;
}

在正常流程下,我們會在函數末尾 delete 創建的對象 w,正常調用析構函數,釋放內存。

但是如果 w->do_something()發生了異常,那麼delete w將不會被執行。此時就會發生內存泄漏。我們當然可以使用 try…catch 捕捉異常,在 catch 裏面執行 delete,但是這樣代碼上並不美觀,也容易漏寫。

如果我們用 std::unique_ptr,那麼這個問題就迎刃而解了。無論代碼怎麼拋異常,在 unique_ptr 離開函數作用域的時候,內存就將會自動釋放。

shared_ptr:共享所有權
在使用 shared_ptr 之前應該考慮,是否真的需要使用 shared_ptr, 而非 unique_ptr。

shared_ptr 代表的是共享所有權,即多個 shared_ptr 可以共享同一塊內存。因此,從語義上來看,shared_ptr 是支持複製的。如下:

auto w = std::make_shared<Widget>();
{
 auto w2 = w;
 cout << w.use_count() << endl; // 2
}
cout << w.use_count() << endl; // 1

shared_ptr 內部是利用引用計數來實現內存的自動管理,每當複製一個 shared_ptr,引用計數會+1。當一個 shared_ptr 離開作用域時,引用計數會-1。當引用計數爲 0 的時候,則 delete 內存。

同時,shared_ptr 也支持移動。從語義上來看,移動指的是所有權的傳遞。如下:

auto w = std::make_shared<Widget>();
auto w2 = std::move(w); // 此時w等於nullptr,w2.use_count()等於1

我們將 w 對象 move 給 w2,意味着 w 放棄了對內存的所有權和管理,此時 w 對象等於 nullptr。而 w2 獲得了對象所有權,但因爲此時 w 已不再持有對象,因此 w2 的引用計數爲 1。

shared_ptr性能

內存佔用高。 shared_ptr 的內存佔用是裸指針的兩倍。因爲除了要管理一個裸指針外,還要維護一個引用計數。因此相比於 unique_ptr, shared_ptr 的內存佔用更高
原子操作性能低。 考慮到線程安全問題,引用計數的增減必須是原子操作。而原子操作一般情況下都比非原子操作慢。
使用移動優化性能。shared_ptr 在性能上固然是低於 unique_ptr。而通常情況,我們也可以儘量避免 shared_ptr 複製。如果,一個 shared_ptr 需要將所有權共享給另外一個新的 shared_ptr,而我們確定在之後的代碼中都不再使用這個 shared_ptr,那麼這是一個非常鮮明的移動語義。對於此種場景,我們儘量使用 std::move,將 shared_ptr 轉移給新的對象。因爲移動不用增加引用計數,因此性能比複製更好。
使用場景

  1. shared_ptr 通常使用在共享權不明的場景。有可能多個對象同時管理同一個內存時。
  2. 對象的延遲銷燬。陳碩在《Linux 多線程服務器端編程》中提到,當一個對象的析構非常耗時,甚至影響到了關鍵線程的速度。可以使用BlockingQueue<std::shared_ptr>將對象轉移到另外一個線程中釋放,從而解放關鍵線程。

shared_from_this

我們往往會需要在類內部使用自身的 shared_ptr,例如:

class Widget
{
public:
 void do_something(A& a)
 {
 a.widget = 該對象的shared_ptr;
 }
}

我們需要把當前 shared_ptr 對象同時交由對象 a 進行管理。意味着,當前對象的生命週期的結束不能早於對象 a。因爲對象 a 在析構之前還是有可能會使用到a.widget。

如果我們直接a.widget = this;, 那肯定不行, 因爲這樣並沒有增加當前 shared_ptr 的引用計數。shared_ptr 還是有可能早於對象 a 釋放。

如果我們使用a.widget = std::make_shared<Widget>(this);,肯定也不行,因爲這個新創建的 shared_ptr,跟當前對象的 shared_ptr 毫無關係。當前對象的 shared_ptr 生命週期結束後,依然會釋放掉當前內存,那麼之後a.widget依然是不合法的。

對於這種,需要在對象內部獲取該對象自身的 shared_ptr, 那麼該類必須繼承std::enable_shared_from_this。代碼如下:

class Widget : public std::enable_shared_from_this<Widget>
{
public:
 void do_something(A& a)
 {
 a.widget = shared_from_this();
 }
}

這樣纔是合法的做法。
weak_ptr
weak_ptr 是爲了解決 shared_ptr 雙向引用的問題。即:

class B;
struct A{
 shared_ptr<B> b;
};
struct B{
 shared_ptr<A> a;
};
auto pa = make_shared<A>();
auto pb = make_shared<B>();
pa->b = pb;
pb->a = pa;

pa 和 pb 存在着循環引用,根據 shared_ptr 引用計數的原理,pa 和 pb 都無法被正常的釋放。對於這種情況, 我們可以使用 weak_ptr:

class B;
struct A{
 shared_ptr<B> b;
};
struct B{
 weak_ptr<A> a;
};
auto pa = make_shared<A>();
auto pb = make_shared<B>();
pa->b = pb;
pb->a = pa;

weak_ptr 不會增加引用計數,因此可以打破 shared_ptr 的循環引用。通常做法是 parent 類持有 child 的 shared_ptr, child 持有指向 parent 的 weak_ptr。這樣也更符合語義。

如何指針作爲函數傳參
很多時候,函數的參數是個指針。這個時候就會面臨選擇困難症,這個參數應該怎麼傳,應該是 shared_ptr,還是 const shared_ptr&,還是直接 raw pointer 更合適。

  1. 只在函數使用指針,但並不保存。假如我們只需要在函數中,用這個對象處理一些事情,但不打算涉及其生命週期的管理,不打算通過函數傳參延長 shared_ptr 的生命週期。對於這種情況,可以使用 raw pointer 或者 const shared_ptr&。即:
void func(Widget*);
void func(const shared_ptr<Widget>&)

實際上第一種裸指針的方式可能更好,從語義上更加清楚,函數也不用關心智能指針的類型。

在函數中保存智能指針。假如我們需要在函數中把這個智能指針保存起來,這個時候建議直接傳值。
void func(std::shared_ptr ptr);
這樣的話,外部傳過來值的時候,可以選擇 move 或者賦值。函數內部直接把這個對象通過 move 的方式保存起來。這樣性能更好,而且外部調用也有多種選擇。

總結
對於智能指針的使用,實際上是對所有權和生命週期的思考,一旦想明白了這兩點,那對智能指針的使用也就得心應手了。同時理解了每種智能指針背後的性能消耗、使用場景,那智能指針也不再是黑盒子和洪水猛獸。

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章