話說智慧指標發展之路

伯樂線上讀者發表於2016-12-16

注:來自陳榕濤 投稿

從 RAII 說起

教科書裡關於“動態建立記憶體”經常會提醒你,new一定要搭配delete來使用,並且delete掉一個指標之後,最好馬上將其賦值為NULL(避免使用懸垂指標)。

這麼麻煩,於是乎,這個世界變成11派人:
一派人勤勤懇懇按照教科書的說法做,時刻小心翼翼,苦逼連連;
一派人忘記教科書的教導,隨便亂來,搞得程式碼處處bug,後期維護罵聲連連;
最後一派人想了更輕鬆的辦法來管理動態申請的記憶體,然後悠閒唱著小曲喝著茶~

(注:應該沒人看不懂11是怎麼來的……就是十進位制的3的二進位制形式)

正式介紹開始,RAII全稱為Resource Acquisition Is Initialization,引用維基百科的解釋:

RAII要求,資源的有效期與持有資源的物件的生命期嚴格繫結,即由物件的建構函式完成資源的分配(獲取),同時由解構函式完成資源的釋放。在這種要求下,只要物件能正確地析構,就不會出現資源洩露問題。

更詳細的闡釋可以參考《Effective C++》(第三版,條款13:以物件管理資源)。

眾所周知,分配在棧上的物件在退出作用域時會自動銷燬,所以需要關注的是動態申請記憶體的做法。用原始指標,正如一開始所說的,很麻煩,最後一派的人就根據RAII思想的指引,創造了智慧指標

隨著編譯器對智慧指標的支援,對於C++程式猿來說應該是一件很值得高興的事,引用一句話:

智慧指標的出現,給不支援垃圾回收機制的C++帶來了一絲曙光。

宣告

本文為了突出對比表現各種智慧智慧的優劣,所以虛構了一個“發展之路”,原創者的想法不一定真的如我所說~

auto_ptr

先看一個例項感受一下auto_ptr相較於原始指標的方便之處:

是不是實現了上面所說的“自動管理動態記憶體”的目標呢?
不需要手動釋放,自動析構!!!

想要了解更多操作的,可以看一下下圖所示的auto_ptr的介面說明
auto_ptr

但是,在C++11標準中,auto_ptr已經被棄用了(雖然為了向前相容,還原封不動保留著,但是有了更好的工具,為何不用呢?)。
問題來了,為什麼它這麼好,還會被拋棄呢?

wiki說了,auto_ptr是“複製語義”,這個名詞不懂無所謂,舉個例子就知道了:

簡單理解的話,可以認為是,auto_ptr是支援複製的(不信回頭去看上面的auto_ptr介面說明裡的constructor項),一旦允許複製,就很容易發現一個問題——若兩個auto_ptr物件都包含了同一個指標(即指向同個物件,同一塊記憶體),那麼當它們都析構的時候,同一個物件就會被析構兩次!!!執行出錯了吧?

解決方法是很簡單了,auto_ptr的做法是:當發生複製/賦值時,把被複制/賦值的物件內部的指標值賦值為NULL,這樣始終只有一個auto_ptr物件指向同一個物件,所以保證了不會多次析構!

由此,關於上述程式碼【E1】處的原因解析是:
執行到這裡會出錯,因為ps1內部的指標值經過賦值運算後,已經變為NULL,解引用就會導致執行錯誤!

這是auto_ptr一個很重大的缺陷,因為使用auto_ptr的程式設計師需要時刻警惕這樣的用法。

於是乎,不滿於繁瑣的人們又創造了unique_ptr等一系列新一代的智慧指標!

unique_ptr

可以簡單認為,unique_ptr是為了解決上述auto_ptr的問題而誕生的(但事實上是不是呢,就得問當初的設計者了)。
它是怎麼解決的呢?
很簡單粗暴的,它禁止了賦值操作和複製構造。

那麼問題來了,如果我想把控制權從一個unique_ptr物件轉移給另一個unique_ptr物件,該怎麼辦呢?
答案是:用移動構造!
這是C++11的一個概念,可以參考這篇IBM的部落格。意思大概是,把某個物件的內容直接給另一個物件使用(,而自己失效了)。

給個例子:

輸出結果為:

ps1 is: Hello, unique_ptr!, ptr value is: 0x1d8dc20
ps1 is: 0
ps2 is: Hello, unique_ptr!, ptr value is: 0x1d8dc20

值得注意的是,在不同機器上,指標的輸出值不一定一樣,但是第二行一定是一樣的,因為ps1經過move之後失效了,其內部的指標值被賦值為NULL!

unique_ptr就是用它這種強硬的方式來消除auto_ptr帶來的(程式設計師需時刻注意不能解引用已經被複制/賦值過的物件的)問題。

shared_ptr

是不是有了unique_ptr就萬事大吉了呢?

(我會這樣問,)答案(肯定)是否定的。
比如,如果程式需要在多個不同的地方(比如多執行緒)用到同一份內容,而unique_ptr只允許最多隻有一個地方持有對原始物件的指標,這就麻煩了……

於是創造力旺盛的程式設計師們又發明了shared_ptr,一個滿足你上述需求的智慧指標,它的思想很簡潔:

引用計數!每次有一個shared_ptr關聯到某個物件上時,計數值就加上1;相反,每次有一個shared_ptr析構時,相應的計數值就減去1。當計數值減為0的時候,就執行物件的解構函式,此時該物件才真正被析構!

由此,shared_ptr很明顯是支援複製構造和賦值操作的,因為它有了計數機制之後,就不需要unique_ptr那樣嚴格地控制複製、賦值來維護析構操作的時機。

用法示例如下:

輸出結果為:

Count is: 3, 3, 3
ps1 is: Hello, shared_ptr!, ptr value is: 0x1210c20
ps2 is: Hello, shared_ptr!, ptr value is: 0x1210c20
ps3 is: Hello, shared_ptr!, ptr value is: 0x1210c20
Count is: 0, 3, 3, 3
ps1 is: 0
ps4 is: Hello, shared_ptr!, ptr value is: 0x1210c20

注意它們輸出的指標值都是一樣的,也表明它們引用的是同個物件。

weak_ptr

哎,問題到shared_ptr不都完全解決了嗎?怎麼又蹦出一個weak_ptr來?這個又是幹什麼的?

咳咳,這是因為人們在使用shared_ptr的過程中,發現了一個問題——迴圈引用,具體的例子和說明可以參考我的另一篇部落格shared_ptr迴圈引用的例子及解決方法示例

因為內容比較多,就獨立成為一篇新的部落格了,其中有實際的例子以及很多解釋,這裡就不贅述了。

最後再引用一段話來分辨強引用(比如shared_ptr)和弱引用(比如weak_ptr)(來自部落格Boost智慧指標——weak_ptr):

一個強引用當被引用的物件活著的話,這個引用也存在(就是說,當至少有一個強引用,那麼這個物件就不能被釋放)。boost::share_ptr就是強引用。
相對而言,弱引用當引用的物件活著的時候不一定存在。僅僅是當它存在的時候的一個引用。弱引用並不修改該物件的引用計數,這意味這弱引用它並不對物件的記憶體進行管理,在功能上類似於普通指標,然而一個比較大的區別是,弱引用能檢測到所管理的物件是否已經被釋放,從而避免訪問非法記憶體。

最後的注意事項

1. 何時需要用智慧指標?

如果是在棧上建立的物件,一旦出了作用域,自動會析構,殺雞不需要牛刀。
正如一開始所說的應用場景,管理動態建立的記憶體時才需要智慧指標登場!

所以不要亂搞,寫出下面的錯誤程式碼來:

2. 示例程式

注意,本文為了程式碼簡單,所以直接用了string類來演示。
不過,為了更好地觀察物件析構的時機,建議讀者使用自定義的且過載了解構函式的類,在解構函式裡輸出一些提示資訊以便觀察。

智慧指標的介面不多,有興趣的讀者可以自行找更多的例項來學習。

3. 效能

本文只是簡單介紹怎麼使用智慧指標,以及它們是怎麼起作用的。效能方面也是很值得考慮的一個問題,不過等日後再分析清楚再來寫部落格了。

4. 多執行緒安全

本文沒有分析,但是這是值得考慮的一個問題,推薦看陳碩的《Linux 多執行緒服務端程式設計:使用 muduo C++ 網路庫》。

其它參考資料

  1. C++智慧指標簡單剖析

相關文章