【轉】C++智能指針的正確使用方式


對象所有權

  首先需要理清楚的概念就是對象所有權的概念。所有權在 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的一個最簡單的使用場景是用於類屬性。代碼如下:

 1 class Box{
 2 public:
 3     Box() : w(new Widget())
 4     {}
 5 
 6     ~Box()
 7     {
 8         // 忘記 delete w
 9     }
10 private:
11     Widget* w;
12 };

  因為一些原因,w必須建立在堆上,如果用裸指針管理w,那么需要在析構函數中 delete w;

  這種寫法雖然沒什么問題,但是容易漏寫delete語句,造成內存泄漏。

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

使用場景2:異常安全

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

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

  在正常流程下,我們會在函數末尾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是支持復制的。如下:

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

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

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

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

  我們將w對象move給w2,意味着w放棄了對內存的所有權和管理,此時w對象等於nullptr。

  而w2獲得了對象所有權,但因為此時w已不再持有對象,因此w2的引用計數為1。

性能

  1、內存占用高

  shared_ptr的內存占用是裸指針的兩倍。因為除了要管理一個裸指針外,還要維護一個引用計數。

  因此相比於unique_ptr,shared_ptr的內存占用更高。

  2、原子操作性能低

  考慮到線程安全問題,引用計數的增減必須是原子操作。而原子操作一般情況下都比非原子操作慢。

  3、使用移動優化性能

  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<void>> 將對象轉移到另外一個線程中釋放,從而解放關鍵線程。

為什么要用shared_from_this

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

1 class Widget
2 {
3 public:
4     void do_something(A& a)
5     {
6         a.widget = 該對象的 shared_ptr;
7     }
8 }

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

  1)直接使用a.widget = this

  這樣並沒有增加當前shared_ptr的引用計數。shared_ptr還是有可能早於對象a釋放。

  2)直接傳遞shared_ptr<Widget>(this):  a.widget = shared_ptr<Widget>(this)

  不可以,因為從原生指針(this)構建shared_ptr會開辟一塊新的內存用於引用計數,不同於通過賦值構建的是共享引用計數內存的。所以會造成2個非共享的shared_ptr指向同一個對象,未增加引用計數導致對象被析構兩次。

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

1 class Widget : public std::enable_shared_from_this<Widget>
2 {
3 public:
4     void do_something(A& a)
5     {
6         a.widget = shared_from_this();
7     }
8 }

  這樣才是合法的。

為何會出現這種使用場合

  因為在異步調用中,存在一個保活機制,異步函數執行的時間點我們是無法確定的,然后異步函數可能會使用異步調用之前就存在的類對象。為了保證該類對象在異步函數執行期間一直有效,我們可以傳遞一個指向自身的shared_ptr給異步函數,這樣在異步函數執行期間shared_ptr所管理的對象就不會析構,所使用的類對象也會一直有效(保活)。

weak_ptr

1、weak_ptr是為了解決shared_ptr雙向引用的問題。

  定義兩個類,每個類中又包含一個指向對方類型的智能指針作為成員變量,然后創建對象,設置完成后查看引用計數后退出,看一下測試結果:

 1 class CB;
 2 class CA
 3 {
 4 public:
 5     CA() { cout << "CA() called! " << endl; }
 6     ~CA() { cout << "~CA() called! " << endl; }
 7     void set_ptr(shared_ptr<CB>& ptr) { m_ptr_b = ptr; }
 8     void b_use_count() { cout << "b use count : " << m_ptr_b.use_count() << endl; }
 9     void show() { cout << "this is class CA!" << endl; }
10 private:
11     shared_ptr<CB> m_ptr_b;
12 };
13 
14 class CB
15 {
16 public:
17     CB() { cout << "CB() called! " << endl; }
18     ~CB() { cout << "~CB() called! " << endl; }
19     void set_ptr(shared_ptr<CA>& ptr) { m_ptr_a = ptr; }
20     void a_use_count() { cout << "a use count : " << m_ptr_a.use_count() << endl; }
21     void show() { cout << "this is class CB!" << endl; }
22 private:
23     shared_ptr<CA> m_ptr_a;
24 };
25 
26 void test_refer_to_each_other()
27 {
28     shared_ptr<CA> ptr_a(new CA());
29     shared_ptr<CB> ptr_b(new CB());
30 
31     cout << "a use count : " << ptr_a.use_count() << endl;
32     cout << "b use count : " << ptr_b.use_count() << endl;
33 
34     ptr_a->set_ptr(ptr_b);
35     ptr_b->set_ptr(ptr_a);
36 
37     cout << "a use count : " << ptr_a.use_count() << endl;
38     cout << "b use count : " << ptr_b.use_count() << endl;
39 }

  測試結果如下:

CA() called!
CB() called!
a use count : 1
b use count : 1
a use count : 2
b use count : 2

  通過結果可以看到,最后CACB的對象並沒有被析構,其中的引用效果如下圖所示,起初定義完ptr_aptr_b時,只有①③兩條引用,然后調用函數set_ptr后又增加了②④兩條引用,當test_refer_to_each_other這個函數返回時,對象ptr_aptr_b被銷毀,也就是①③兩條引用會被斷開,但是②④兩條引用依然存在,每一個的引用計數都不為0,結果就導致其指向的內部對象無法析構,造成內存泄漏。

 

 

   解決這種狀況的辦法就是將兩個類中的一個成員變量改為weak_ptr對象,因為weak_ptr不會增加引用計數,使得引用形不成環,最后就可以正常的釋放內部的對象,不會造成內存泄漏,比如將CB中的成員變量改為weak_ptr對象,代碼如下:

 1 class CB
 2 {
 3 public:
 4     CB() { cout << "CB() called! " << endl; }
 5     ~CB() { cout << "~CB() called! " << endl; }
 6     void set_ptr(shared_ptr<CA>& ptr) { m_ptr_a = ptr; }
 7     void a_use_count() { cout << "a use count : " << m_ptr_a.use_count() << endl; }
 8     void show() { cout << "this is class CB!" << endl; }
 9 private:
10     weak_ptr<CA> m_ptr_a;
11 };

  測試結果如下:

CA() called!
CB() called!
a use count : 1
b use count : 1
a use count : 1
b use count : 2
~CA() called!
~CB() called!

  通過這次結果可以看到,CACB的對象都被正常的析構了,引用關系如下圖所示,流程與上一例子相似,但是不同的是④這條引用是通過weak_ptr建立的,並不會增加引用計數,也就是說CA的對象只有一個引用計數,而CB的對象只有2個引用計數,當test_refer_to_each_other這個函數返回時,對象ptr_aptr_b被銷毀,也就是①③兩條引用會被斷開,此時CA對象的引用計數會減為0,對象被銷毀,其內部的m_ptr_b成員變量也會被析構,導致CB對象的引用計數會減為0,對象被銷毀,進而解決了引用成環的問題。

2. 測試weak_ptr對引用計數的影響

  其實weak_ptr本身設計的很簡單,就是為了輔助shared_ptr的,它本身不能直接定義指向原始指針的對象,只能指向shared_ptr對象,同時也不能將weak_ptr對象直接賦值給shared_ptr類型的變量,最重要的一點是賦值給它不會增加引用計數:

 1 void test1()
 2 {
 3     // 編譯錯誤 // error C2665: “std::weak_ptr<CA>::weak_ptr”: 3 個重載中沒有一個可以轉換所有參數類型
 4     // weak_ptr<CA> ptr_1(new CA());
 5 
 6     shared_ptr<CA> ptr_1(new CA());
 7 
 8     cout << "ptr_1 use count : " << ptr_1.use_count() << endl; // 輸出:ptr_1 use count : 1
 9 
10     shared_ptr<CA> ptr_2 = ptr_1;
11 
12     cout << "ptr_1 use count : " << ptr_1.use_count() << endl; // 輸出:ptr_1 use count : 2
13     cout << "ptr_2 use count : " << ptr_2.use_count() << endl; // 輸出:ptr_1 use count : 2
14 
15     weak_ptr<CA> wk_ptr = ptr_1;
16 
17     cout << "ptr_1 use count : " << ptr_1.use_count() << endl; // 輸出:ptr_1 use count : 2
18     cout << "ptr_2 use count : " << ptr_2.use_count() << endl; // 輸出:ptr_1 use count : 2
19 
20     // 編譯錯誤
21     // error C2440 : “初始化”: 無法從“std::weak_ptr<CA>”轉換為“std::shared_ptr<CA>”
22     // shared_ptr<CA> ptr_3 = wk_ptr;
23 }

3、測試weak_ptr常用函數的用法

  weak_ptr中只有函數lockexpired兩個函數比較重要,因為它本身不會增加引用計數,所以它指向的對象可能在它用的時候已經被釋放了,所以在用之前需要使用expired函數來檢測是否過期,然后使用lock函數來獲取其對應的shared_ptr對象,然后進行后續操作:

 1 void test2()
 2 {
 3     shared_ptr<CA> ptr_a(new CA());     // 輸出:CA() called!
 4     shared_ptr<CB> ptr_b(new CB());     // 輸出:CB() called!
 5 
 6     cout << "ptr_a use count : " << ptr_a.use_count() << endl; // 輸出:ptr_a use count : 1
 7     cout << "ptr_b use count : " << ptr_b.use_count() << endl; // 輸出:ptr_b use count : 1
 8     
 9     weak_ptr<CA> wk_ptr_a = ptr_a;
10     weak_ptr<CB> wk_ptr_b = ptr_b;
11 
12     if (!wk_ptr_a.expired())
13     {
14         wk_ptr_a.lock()->show();        // 輸出:this is class CA!
15     }
16 
17     if (!wk_ptr_b.expired())
18     {
19         wk_ptr_b.lock()->show();        // 輸出:this is class CB!
20     }
21 
22     // 編譯錯誤
23     // 編譯必須作用於相同的指針類型之間
24     // wk_ptr_a.swap(wk_ptr_b);         // 調用交換函數
25 
26     wk_ptr_b.reset();                   // 將wk_ptr_b的指向清空
27     if (wk_ptr_b.expired())
28     {
29         cout << "wk_ptr_b is invalid" << endl;  // 輸出:wk_ptr_b is invalid 說明改指針已經無效
30     }
31 
32     wk_ptr_b = ptr_b;
33     if (!wk_ptr_b.expired())
34     {
35         wk_ptr_b.lock()->show();        // 輸出:this is class CB! 調用賦值操作后,wk_ptr_b恢復有效
36     }
37 
38     // 編譯錯誤
39     // 編譯必須作用於相同的指針類型之間
40     // wk_ptr_b = wk_ptr_a;
41 
42 
43     // 最后輸出的引用計數還是1,說明之前使用weak_ptr類型賦值,不會影響引用計數
44     cout << "ptr_a use count : " << ptr_a.use_count() << endl; // 輸出:ptr_a use count : 1
45     cout << "ptr_b use count : " << ptr_b.use_count() << endl; // 輸出:ptr_b use count : 1
46 }

  引用計數的出現,解決了對象獨占的問題,但是也帶來了循環引用的困擾,使用weak_ptr可以打破這種循環,當你理不清引用關系的時候,不妨采用文中畫圖的方式來理一理頭緒,或許就會有眼前一亮的感覺。

  通常做法是 parent 類持有 child 的 shared_ptr, child 持有指向 parent 的 weak_ptr。這樣也更符合語義。

選擇哪種指針作為函數的參數

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

1、只在函數使用指針,但並不保存對象內容

  假如我們只需要在函數中,用這個對象處理一些事情,但不打算涉及其生命周期的管理,也不打算通過函數傳參延長 shared_ptr 的生命周期。
對於這種情況,可以使用 raw pointer 或者 const shared_ptr&。
即:

1 void func(Widget*);
2 void func(const shared_ptr<Widget>&)

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

2、在函數中保存智能指針

  假如我們需要在函數中把這個智能指針保存起來,這個時候建議直接傳值。

void func(std::shared_ptr<Widget> ptr);

  這樣的話,外部傳過來值的時候,可以選擇 move 或者賦值。函數內部直接把這個對象通過 move 的方式保存起來。
  這樣性能更好,而且外部調用也有多種選擇。

總結

  對於智能指針的使用,實際上是對所有權和生命周期的思考,一旦想明白了這兩點,那對智能指針的使用也就得心應手了。

  同時理解了每種智能指針背后的性能消耗,使用場景,那智能指針也不再是黑盒子和洪水猛獸。

 

轉載自《C++智能指針的正確使用方式


免責聲明!

本站轉載的文章為個人學習借鑒使用,本站對版權不負任何法律責任。如果侵犯了您的隱私權益,請聯系本站郵箱yoyou2525@163.com刪除。



 
粵ICP備18138465號   © 2018-2025 CODEPRJ.COM