注:來自陳榕濤 投稿
從 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相較於原始指標的方便之處:
1 2 3 4 5 6 7 8 9 10 11 12 |
#include <iostream> #include <string> #include <memory> using namespace std; int main() { auto_ptr<string> ps1(new string("Hello, auto_ptr!")); cout << "The content is: " << *ps1 << endl; return 0; } |
是不是實現了上面所說的“自動管理動態記憶體”的目標呢?
不需要手動釋放,自動析構!!!
想要了解更多操作的,可以看一下下圖所示的auto_ptr的介面說明:
但是,在C++11標準中,auto_ptr已經被棄用了(雖然為了向前相容,還原封不動保留著,但是有了更好的工具,為何不用呢?)。
問題來了,為什麼它這麼好,還會被拋棄呢?
wiki說了,auto_ptr是“複製語義”,這個名詞不懂無所謂,舉個例子就知道了:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 |
#include <iostream> #include <string> #include <memory> using namespace std; int main() { auto_ptr<string> ps1(new string("Hello, auto_ptr!")); auto_ptr<string> ps2; ps2 = ps1; //【E1】下面這行註釋掉才可正確執行,原因見下文分析 //cout << "ps1: " << *ps1 << endl; cout << "ps2: " << *ps2 << endl; return 0; } |
簡單理解的話,可以認為是,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的部落格。意思大概是,把某個物件的內容直接給另一個物件使用(,而自己失效了)。
給個例子:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 |
#include <iostream> #include <string> #include <memory> using namespace std; int main() { unique_ptr<string> ps1(new string("Hello, unique_ptr!")); cout << "ps1 is: " << *ps1 << ", ptr value is: " << ps1.get() << endl; // unique_ptr<string> ps2(ps1);// 編譯將會出錯!因為禁止複製 // unique_ptr<string> ps2 = ps1;// 編譯將會出錯!因為禁止賦值 unique_ptr<string> ps2 = move(ps1); cout << "ps1 is: " << ps1.get() << endl; cout << "ps2 is: " << *ps2 << ", ptr value is: " << ps2.get() << endl; return 0; } |
輸出結果為:
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那樣嚴格地控制複製、賦值來維護析構操作的時機。
用法示例如下:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 |
#include <iostream> #include <string> #include <memory> using namespace std; int main() { shared_ptr<string> ps1(new string("Hello, shared_ptr!")); shared_ptr<string> ps3(ps1); // 允許複製 shared_ptr<string> ps2 = ps1; // 允許賦值 cout << "Count is: " << ps1.use_count() << ", " << ps2.use_count() << ", " << ps3.use_count() << endl; cout << "ps1 is: " << *ps1 << ", ptr value is: " << ps1.get() << endl; cout << "ps2 is: " << *ps2 << ", ptr value is: " << ps2.get() << endl; cout << "ps3 is: " << *ps3 << ", ptr value is: " << ps3.get() << endl; shared_ptr<string> ps4 = move(ps1); // 注意ps1在move之後,就“失效”了,什麼都是“0” cout << "Count is: " << ps1.use_count() << ", " << ps2.use_count() << ", " << ps3.use_count() << ", " << ps4.use_count() << endl; cout << "ps1 is: " << ps1.get() << endl; cout << "ps4 is: " << *ps4 << ", ptr value is: " << ps4.get() << endl; return 0; } |
輸出結果為:
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. 何時需要用智慧指標?
如果是在棧上建立的物件,一旦出了作用域,自動會析構,殺雞不需要牛刀。
正如一開始所說的應用場景,管理動態建立的記憶體時才需要智慧指標登場!
所以不要亂搞,寫出下面的錯誤程式碼來:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 |
#include <iostream> #include <string> #include <memory> using namespace std; int main() { // 注意這是一個分配在棧上的物件,而不是在堆上的 string hello("Hello, auto_ptr!"); auto_ptr<string> ps1(&hello); cout << "ps1: " << *ps1 << endl; // 讓程式析構auto_ptr物件之前暫停一下,以分辨程式崩潰是誰的鍋 string str; cin >> str; return 0; } |
2. 示例程式
注意,本文為了程式碼簡單,所以直接用了string類來演示。
不過,為了更好地觀察物件析構的時機,建議讀者使用自定義的且過載了解構函式的類,在解構函式裡輸出一些提示資訊以便觀察。
智慧指標的介面不多,有興趣的讀者可以自行找更多的例項來學習。
3. 效能
本文只是簡單介紹怎麼使用智慧指標,以及它們是怎麼起作用的。效能方面也是很值得考慮的一個問題,不過等日後再分析清楚再來寫部落格了。
4. 多執行緒安全
本文沒有分析,但是這是值得考慮的一個問題,推薦看陳碩的《Linux 多執行緒服務端程式設計:使用 muduo C++ 網路庫》。