五点讲述C++智能指针的点点滴滴
(在學習C/C++或者想要學習C/C++可以加我們的學習交流QQ群:712263501群內有相關學習資料)
0、摘要
本文先講了智能指針存在之前C++面臨的窘境,并順理成章地引出利用RAII技術封裝普通指針從而誕生了智能指針,然后以示例代碼的形式講解了三種智能指針的基本用法。為了更好地理解引用計數形式實現的智能指針,本文提供了實現一個簡單版本的智能指針的方法,并討論了引用計數形式的缺點。最后,本文討論了使用智能指針應當注意的事項,包括shared_ptr 的循環引用問題等三個事項。
1、智能指針的前世今生
在智能指針出現以前,我們通常使用 new 和 delete 來管理動態分配的內存,但這種方式存在幾個常見的問題:
忘記 delete 內存:會導致內存泄漏問題,且除非是內存耗盡否則很難檢測到這種錯誤。
使用已經釋放掉的對象:如果能夠記得在釋放掉內存后將指針置空并在下次使用前判空,尚可避免這種錯誤。
同一塊內存釋放兩次:如果有兩個指針指向相同的動態分配對象,則很容易發生這種錯誤。
發生異常時的內存泄漏:若在 new 和 delete 之間發生異常,則會導致內存泄漏。
制造出這些錯誤很容易,但查找和修正這些錯誤就困難的多。于是,我們就要考慮如何從根本上克服這種弊端,不制造出這些錯誤。動態分配的內存是 C++ 中最常使用的資源,所謂資源就是,一旦用了它,將來必須還給系統,否則就會發生糟糕的事情。所以,我們就要考慮如何更好地進行資源管理,來保證資源的有借必有還。
不難想到,資源管理技術的關鍵在于:要保證資源的釋放順序與獲取順序嚴格相反。這自然使我們聯想到局部對象的創建和銷毀過程。在C++中,定義在棧空間上的局部對象稱為自動存儲對象。管理局部對象的任務非常簡單,因為它們的創建和銷毀工作是由系統自動完成的。我們只需在某個作用域中定義局部對象(這時系統自動調用構造函數以創建對象),然后就可以放心大膽地使用之,而不必擔心有關善后工作;當控制流程超出這個作用域的范圍時,系統會自動調用析構函數,從而銷毀該對象。
如果系統中的資源也具有如同局部對象一樣的特性,自動獲取,自動釋放,那該多么美妙啊!既然類是C++中的主要抽象工具,那么就將資源抽象為類,用局部對象來表示資源,把管理資源的任務轉化為管理局部對象的任務。把資源放進對象內,用資源來管理對象,便是 C++ 編程中最重要的編程技法之一,即 RAII ,它是 “Resource Acquisition Is Initialization” 的首字母縮寫。智能指針便是利用 RAII 的技術對普通的指針進行封裝,這使得智能指針實質是一個對象,行為表現的卻像一個指針。
綜上所述,RAII的本質內容是用對象代表資源,把管理資源的任務轉化為管理對象的任務,將資源的獲取和釋放與對象的構造和析構對應起來,從而確保在對象的生存期內資源始終有效,對象銷毀時資源必被釋放。換句話說,擁有對象就等于擁有資源,對象存在則資源必定存在。由此可見,RAII是進行資源管理的有力武器。C++程序員依靠RAII寫出的代碼不僅簡潔優雅,而且做到了異常安全。難怪微軟的MSDN雜志在最近的一篇文章中承認:“若論資源管理,誰也比不過標準C++”。
說到這里,順便 diss 一下 Java 的 GC 機制,表面來看,Java 似乎更優秀,因為從一開始你就不用考慮什么特殊的機制,大膽地往前 new ,自有 GC 替你收拾殘局。 Java 的 GC 實際上是 JVM 中的一個獨立線程,采用不同的算法策略來收集堆中那些不再有引用指向的垃圾對象所占用的內存。但是,通常情況下,GC 線程的優先級比較低,只有在當前程序空閑的時候才會被調度,收集垃圾。當然,如果 JVM 感到內存緊張了,JVM 會主動調用 GC 來收集垃圾,獲取更多的內存。請注意,Java 的 GC 工作的時機是:1. 當前程序不忙,有空閑時間。2. 空閑內存不足。現在我們考慮一種常見的情況,程序在緊張運行之中,沒有空閑時間給 GC 來運行,同時機器內存很大,JVM 也沒有感到內存不足,結果是什么?對了 ,GC 形同虛設,得不到調用。于是,內存被不斷吞噬,而那些早已經用不著的垃圾對象仍在在寶貴的內存里睡大覺。
反過來看看 C++ 利用智能指針達成的效果,一旦某對象不再被引用,系統刻不容緩,立刻回收內存。這通常發生在關鍵任務完成后的清理時期,不會影響關鍵任務的實時性,同時,內存里所有的對象都是有用的,絕對沒有垃圾空占內存。
既然智能指針有如此多的好處,那我們還等什么,趕緊來學學它的用法吧!
2、智能指針的基本語法
C++11 中提供了三種智能指針,分別是 shared_ptr , unique_ptr 和 weak_ptr 。shared_ptr 允許多個指針指向同一個對象,unique_ptr 則“獨占”所指向的對象,weak_ptr 則是和share_ptr 相輔相成的伴隨類,具體用法后文細說。
這三種類型都定義在頭文件memory中。類似vector,智能指針也是模板,需要在尖括號內給出類型信息。shared_ptr 和 unique_ptr 的使用方式和普通指針類似,都可以使用和->等運算符。
關于基本語法,讀完下面這段代碼,你一定會了然于胸的。
以上代碼的輸出結果為:
為了更進一步透徹地理解智能指針的基本原理,我們有必要實現一個簡單版本的智能指針(shared_ptr)來輔助理解。
3、自己實現一個簡單的智能指針
智能指針(shared_ptr)能夠自動釋放所指向的對象,其實現原理卻并不復雜。簡單一說:
智能指針將一個計數器與類指向的對象相關聯,引用計數跟蹤共有多少個類對象共享同一指針。
每次創建類的新對象時,初始化指針并將引用計數置為1。
當對象作為另一對象的副本而創建時,拷貝構造函數拷貝指針并增加與之相應的引用計數。
對一個對象進行賦值時,賦值操作符減少左操作數所指對象的引用計數(如果引用計數為減至0,則刪除對象),并增加右操作數所指對象的引用計數;這是因為左側的指針指向了右側指針所指向的對象,因此右指針所指向的對象的引用計數加1。
調用析構函數時,構造函數減少引用計數(如果引用計數減至0,則刪除基礎對象)。
下面是一個基于引用計數的智能指針的實現,需要實現構造,析構,拷貝構造,=操作符重載,重載和->操作符。
這個智能指針的簡單實現模仿的是 share_ptr 的行為,不難發現,引用計數的存在會帶來一些性能影響:
shared_ptr 的尺寸是裸指針的兩倍:因為內部既包含一個指向該資源的裸指針,也包含一個指向該資源的引用計數的裸指針。
引用計數的內存必須動態分配
引用計數的遞增和遞減必須是原子操作:原子操作一般比非原子操作慢。我們的實現版本里為了簡單起見沒有實現原子操作。
4、使用智能指針的一些注意事項
4.1 shared_ptr 的循環引用問題
shared_ptr 意味著你的引用和原對象是一個強聯系。你的引用不解開,原對象就不能銷毀。濫用強聯系,這在一個運行時間長、規模比較大,或者是資源較為緊缺的系統中,極易造成隱性的內存泄漏,這會成為一個災難性的問題。
更糟的是,濫用強聯系可能造成循環引用的災難。即:B持有指向A內成員的一個shared_ptr,A也持有指向B內成員的一個 shared_ptr,此時A和B的生命周期互相由對方決定,事實上都無法從內存中銷毀。 更進一步,循環引用不只是兩方的情況,只要引用鏈成環都會出現問題。
舉個循環引用的簡單例子。
如此一來,A和B都互相指著對方吼,“放開我的引用!“,“你先發我的我就放你的!”,于是悲劇發生了,內存泄漏了。當然循環引用本身就說明設計上可能存在一些問題,如果特殊原因不得不使用循環引用,那可以讓引用鏈上的一方持用普通指針(或弱智能指針weak_ptr)即可。
這就是 weak_ptr 的用處。weak_ptr 提供一個(1)能夠確定對方生存與否(2)互相之間生命周期無干擾(3)可以臨時借用一個強引用(在你需要引用對方的短時間內保證對方存活)的智能指針。而 weak_ptr 要求程序員在運行時確定生存并加鎖,這也是邏輯上必須的本征復雜度——如果別人活的比你短,你當然要:(1)先確定別人的死活(2)如果還活著,就給他續個命續到你用完了為止。
4.2 切記:讓所有的智能指針都有名字
智能指針為解決資源泄漏、編寫異常安全代碼提供了一種解決方案,那么他是萬能的良藥嗎?使用智能指針,就不會再有資源泄漏了嗎?請看下面的代碼:
上面的函數調用,看起來是安全的,但在現實世界中,其實不然:由于C++并未定義一個表達式的求值順序,因此上述函數調用除了func在最后得到調用之外是可以確定,其他的執行序列則很可能被拆分成如下步驟:
1、分配內存給T1
2、分配內存給T2
3、構造T1對象
4、構造T2對象
5、構造T1的智能指針對象
6、構造T2的智能指針對象
7、調用func
此時,如果程序在第3步失敗,那T1和T2對象所分配內存必然泄漏。而解決這個問題的方案也很簡單,就是不要在函數實參中創建shared_ptr,拋棄臨時對象,讓所有的智能指針都有名字,就可以避免此類問題的發生。比如以下代碼:
4.3 優先選用make_unique(shared)而非直接使用new
簡單說來,相比于直接使用new表達式,make系列函數有三個優點:
消除了重復代碼
改進了異常安全性
生成的目標代碼尺寸更小速度更快
總結
以上是生活随笔為你收集整理的五点讲述C++智能指针的点点滴滴的全部內容,希望文章能夠幫你解決所遇到的問題。
- 上一篇: “不觉别时红泪尽”下一句是什么
- 下一篇: C 语言编程利器 之CLion