大家好,今天借助本文,從實踐、避坑和實現原理三個角度分析下C++中的智能指針。
本文主要內容如下圖所示:
智能指針的由來
auto_ptr為什么被廢棄
unique_ptr的使用、特點以及實現
shared_ptr的使用、特點以及實現
weak_ptr的使用、特點以及實現
介紹筆者在工作中遇到的一些職能指針相關的坑,并給出一些建議
背景
內存的分配與回收都是由開發人員在編寫代碼時主動完成的,好處是內存管理的開銷較小,程序擁有更高的執行效率;弊端是依賴于開發者的水平,隨著代碼規模的擴大,極容易遺漏釋放內存的步驟,或者一些不規范的編程可能會使程序具有安全隱患。如果對內存管理不當,可能導致程序中存在內存缺陷,甚至會在運行時產生內存故障錯誤。換句話說,開發者自己管理內存,最容易發生下面兩種情況:
申請了內存卻沒有釋放,造成內存泄漏
使用已經釋放的內存,造成segment fault
所以,為了在保證性能的前提下,又能使得開發者不需要關心內存的釋放,進而使得開發者能夠將更多的精力投入到業務上,自C++11開始,STL正式引入了智能指針。
所有權
智能指針一個很關鍵的一個點就是是否擁有一個對象的所有權,當我們通過std::make_xxx或者new一個對象,那么就擁有了這個對象的所有權。
所有權分為獨占所有權、共享所有權以及弱共享所有權三種。
獨占所有權
顧名思義,獨占該對象。獨占的意思就是不共享,所有權可以轉移,但是轉移之后,所有權也是獨占。auto_ptr和unique_ptr就是一種獨占所有權方式的智能指針。
假設有個Object對象,如果A擁有該對象的話,就需要保證其在不使用該對象的時候,將該對象釋放;而此時如果B也想擁有Object對象,那么就必須先讓A放棄該對象所有權,然后B獨享該對象,那么該對象的使用和釋放就只歸B所有,跟A沒有關系了。
獨占所有權具有以下幾個特點:
如果創建或者復制了某個對象,就擁有了該對象
如果沒有創建對象,而是將對象保留使用,同樣擁有該對象的所有權
如果你擁有了某個對象的所有權,在不需要某一個對象時,需要釋放它們
共享所有權
共享所有權,與獨占所有權正好相反,對某個對象的所有權可以共享。shared_ptr就是一種共享所有權方式的智能指針。
假設此時A擁有對象Object,在沒有其它擁有該對對象的情況下,對象的釋放由A來負責;如果此時B也想擁有該對象,那么對象的釋放由最后一個擁有它的來負責。
舉一個我們經常遇到的例子,socket連接,多個發送端(sender)可以使用其發送和接收數據。
弱共享所有權
弱共享所有權,指的是可以使用該對象,但是沒有所有權,由真正擁有其所有權的來負責釋放。weak_ptr就是一種弱共享所有權方式的智能指針。
分類
在C++11中,有unique_ptr、shared_ptr以及weak_ptr三種,auto_ptr因為自身轉移所有權的原因,在C++11中被廢棄(本節最后,將簡單說下被廢棄的原因)。
unique_ptr
使用上限制最多的一種智能指針,被用來取代之前的auto_ptr,一個對象只能被一個unique_ptr所擁有,而不能被共享,如果需要將其所擁有的對象轉移給其他unique_ptr,則需要使用move語義
shared_ptr
與unique_ptr不同的是,unique_ptr是獨占管理權,而shared_ptr則是共享管理權,即多個shared_ptr可以共用同一塊關聯對象,其內部采用的是引用計數,在拷貝的時候,引用計數+1,而在某個對象退出作用域或者釋放的時候,引用計數-1,當引用計數為0的時候,會自動釋放其管理的對象。
weak_ptr
weak_ptr的出現,主要是為了解決shared_ptr的循環引用,其主要是與shared_ptr一起來使用。和shared_ptr不同的地方在于,其并不會擁有資源,也就是說不能訪問對象所提供的成員函數,不過,可以通過weak_ptr.lock()來產生一個擁有訪問權限的shared_ptr。
auto_ptr
auto_ptr自C++98被引入,因為其存在較多問題,所以在c++11中被廢棄,自C++17開始正式從STL中移除。
首先我們看下auto_ptr的簡單實現(為了方便閱讀,進行了修改,基本功能類似于std::auto_ptr):
templateclassauto_ptr { T*p; public: auto_ptr(T*s):p(s){} ~auto_ptr(){deletep;} auto_ptr(auto_ptr&a){ p=a.p; a.p=NULL; } auto_ptr&operator=(auto_ptr&a){ deletep; p=a.p; a.p=NULL; return*this; } T&operator*()const{return*p;} T*operator->()const{returnp;} };
從上面代碼可以看出,auto_ptr采用copy語義來轉移所有權,轉移之后,其關聯的資源指針設置為NULL,而這跟我們理解上copy行為不一致。
在<< Effective STL >>第8條,作者提出永不建立auto_ptr的容器,并以一個例子來說明原因,感興趣的可以去看看這本書,還是不錯的。
實際上,auto_ptr被廢棄的直接原因是拷貝造成所有權轉移,如下代碼:
auto_ptra(newClassA); auto_ptr b=a; a->Method();
在上述代碼中,因為b = a導致所有權被轉移,即a關聯的對象為NULL,如果再調用a的成員函數,顯然會造成coredump。
正是因為拷貝導致所有權被轉移,所以auto_ptr使用上有很多限制:
不能在STL容器中使用,因為復制將導致數據無效
一些STL算法也可能導致auto_ptr失效,比如std::sort算法
不能作為函數參數,因為這會導致復制,并且在調用后,導致原數據無效
如果作為類的成員變量,需要注意在類拷貝時候導致的數據無效
正是因為auto_ptr的諸多限制,所以自C++11起,廢棄了auto_ptr,引入unique_ptr。
unique_ptr
unique_ptr是C++11提供的用于防止內存泄漏的智能指針中的一種實現(用來替代auto_ptr),獨享被管理對象指針所有權的智能指針。
unique_ptr對象包裝一個原始指針,并負責其生命周期。當該對象被銷毀時,會在其析構函數中刪除關聯的原始指針。具有->和*運算符重載符,因此它可以像普通指針一樣使用。
分類
unique_ptr分為以下兩種:
指向單個對象
std::unique_ptrp1;//p1關聯Type對象
指向一個數組
unique_ptrp2;//p2關聯Type對象數組
特點
在前面的內容中,我們已經提到了unique_ptr的特點,主要具有以下:
獨享所有權,在作用域結束時候,自動釋放所關聯的對象
voidfun(){ unique_ptra(newint(1)); }
無法進行拷貝與賦值操作
unique_ptrptr(newint(1)); unique_ptr ptr1(ptr);//error unique_ptr ptr2=ptr;//error
顯示的所有權轉移(通過move語義)
unique_ptrptr(newint(1)); unique_ptr ptr1=std::move(ptr);//ok
作為容器元素存儲在容器中
unique_ptrptr(newint(1)); std::vector >v; v.push_back(ptr);//error v.push_back(std::move(ptr));//ok std::cout<*ptr?<
需要注意的是,自c++14起,可以使用下面的方式對unique_ptr進行初始化:
autop1=std::make_unique(3.14); autop2=std::make_unique (n);
如果在c++11中使用上述方法進行初始化,會得到下面的錯誤提示:
error:‘make_unique’isnotamemberof‘std’
因此,如果為了使得c++11也可以使用std::make_unique,我們可以自己進行封裝,如下:
namespacedetails{ #if__cplusplus>=201402L//C++14及以后使用STL實現的 usingstd::make_unique; #else templatestd::unique_ptr make_unique(Args&&...args) { returnstd::unique_ptr (newT(std::forward (args)...)); } #endif }//namespacedetails
使用
為了盡可能了解unique_ptr的使用姿勢,我們使用以下代碼為例:
#include#include //std::move voidfun1(double*); voidfun2(std::unique *); voidfun3(std::unique &); voidfun4(std::unique ); intmain(){ std::unique_ptr p(newdouble(3.14)); fun1(p.get()); fun2(&p); fun3(p); if(p){ std::cout<"is?valid"?<
上述代碼,基本覆蓋了常見的unique_ptr用法:
第10行,通過new創建一個unique_ptr對象
第11行,通過get()函數獲取其關聯的原生指針
第12行,通過unique_ptr對象的指針進行訪問
第13行,通過unique_ptr對象的引用進行訪問
第16行,通過if(p)來判斷其是否有效
第18行,通過release函數釋放所有權,并將所有權進行轉移
第19行,通過reset釋放之前的原生指針,并重新關聯一個新的指針
第20行,通過std::move轉移所有權
簡單實現
本部分只是基于源碼的一些思路,便于理解,實現的一個簡單方案,如果想要閱讀源碼,請點擊unique_ptr查看。
基本代碼如下:
templateclassunique_ptr { T*p; public: unique_ptr():p(){} unique_ptr(T*s):p(s){} ~unique_ptr(){deletep;} unique_ptr(constunique_ptr&)=delete; unique_ptr&operator=(constunique_ptr&)=delete; unique_ptr(unique_ptr&&s):p(s.p){s.p=nullptr} unique_ptr&operator=(unique_ptrs) {deletep;p=s.p;s.p=nullptr;return*this;} T*operator->()const{returnp;} T&operator*()const{return*p;} };
從上面代碼基本可以看出,unique_ptr的控制權轉移是通過move語義來實現的,相比于auto_ptr的拷貝語義轉移所有權,更為合理。
shared_ptr
unique_ptr因為其局限性(獨享所有權),一般很少用于多線程操作。在多線程操作的時候,既可以共享資源,又可以自動釋放資源,這就引入了shared_ptr。
shared_ptr為了支持跨線程訪問,其內部有一個引用計數(線程安全),用來記錄當前使用該資源的shared_ptr個數,在結束使用的時候,引用計數為-1,當引用計數為0時,會自動釋放其關聯的資源。
特點
相對于unique_ptr的獨享所有權,shared_ptr可以共享所有權。其內部有一個引用計數,用來記錄共享該資源的shared_ptr個數,當共享數為0的時候,會自動釋放其關聯的資源。
shared_ptr不支持數組,所以,如果用shared_ptr指向一個數組的話,需要自己手動實現deleter,如下所示:
std::shared_ptrp(newint[8],[](int*ptr){delete[]ptr;});
使用
仍然以一段代碼來說明,畢竟代碼更有說服力。
#include#include intmain(){ //創建shared_ptr對象 std::shared_ptr p1=std::make_shared (); *p1=78; std::cout<"p1?=?"?<*p1?<p2(p1); //打印引用計數 std::cout<"p2?Reference?count?=?"?<
輸出如下:
p1=78 p1Referencecount=1 p2Referencecount=2 p1Referencecount=2 p1andp2arepointingtosamepointer Resetp1 p1ReferenceCount=0 p1ReferenceCount=1 p1ReferenceCount=0 p1isNULL
上面代碼基本羅列了shared_ptr的常用方法,對于其他方法,可以參考源碼或者官網。
線程安全
可能很多人都對shared_ptr是否線程安全存在疑惑,借助本節,對線程安全方面的問題進行分析和解釋。
shared_ptr的線程安全問題主要有以下兩種:
引用計數的加減操作是否線程安全
shared_ptr修改指向時是否線程安全
引用計數
shared_ptr中有兩個指針,一個指向所管理數據的地址,另一個一個指向執行控制塊的地址。
執行控制塊包括對關聯資源的引用計數以及弱引用計數等。在前面我們提到shared_ptr支持跨線程操作,引用計數變量是存儲在堆上的,那么在多線程的情況下,指向同一數據的多個shared_ptr在進行計數的++或--時是否線程安全呢?
引用計數在STL中的定義如下:
_Atomic_word_M_use_count;//#shared _Atomic_word_M_weak_count;//#weak+(#shared!=0)
當對shared_ptr進行拷貝時,引入計數增加,實現如下:
template<> inlinevoid _Sp_counted_base<_S_atomic>:: _M_add_ref_lock(){ //Performlock-freeadd-if-not-zerooperation. _Atomic_word__count; do { __count=_M_use_count; if(__count==0) __throw_bad_weak_ptr(); } while(!__sync_bool_compare_and_swap(&_M_use_count,__count, __count+1)); }
最終,計數的增加,是調用__sync_bool_compare_and_swap實現的,而該函數是線程安全的,因此我們可以得出結論:在多線程環境下,管理同一個數據的shared_ptr在進行計數的增加或減少的時候是線程安全的,這是一波原子操作。
修改指向
修改指向分為操作同一個對象和操作不同對象兩種。
同一對象
以下面代碼為例:
voidfun(shared_ptr&p){ if(...){ p=p1; }else{ p=p2; } }
當在多線程場景下調用該函數時候,p之前的引用計數要進行-1操作,而p1對象的引用計數要進行+1操作,雖然這倆的引用計數操作都是線程安全的,但是對這倆對象的引用計數的操作在一起時候卻不是線程安全的。這是因為當對p1的引用計數進行+1時候,恰恰前一時刻,p1的對象被釋放,后面再進行+1操作,會導致segment fault。
不同對象
代碼如下:
voidfun1(std::shared_ptr&p){ p=p1; } voidfun2(std::shared_ptr &p){ p=p2; } intmain(){ std::shared_ptr p=std::make_shared (); autop1=p; autop2=p; std::threadt1(fun1,p1); std::threadt2(fun2,p2); t1.join(); t2.join(); return0; }
在上述代碼中,p、p1、p2指向同一個資源,分別有兩個線程操作不同的shared_ptr對象(雖然關聯的底層資源是同一個),這樣在多線程下,只對p1和p2的引用計數進行操作,不會引起segment fault,所以是線程安全的。
?
同一個shared_ptr被多個線程同時讀是安全的
同一個shared_ptr被多個線程同時讀寫是不安全的
?
簡單實現
本部分只是基于源碼的一些思路,便于理解,實現的一個簡單方案,如果想要閱讀源碼,請點擊shared_ptr查看。
記得之前看過一個問題為什么引用計數要new,這個問題我在面試的時候也問過,很少有人能夠回答出來,其實,很簡單,因為要支持多線程訪問,所以只能要new呀。
代碼如下:
templateclassweak_ptr; classCounter{ public: Counter()=default; ints_=0;//shared_ptr的計數 intw_=0;//weak_ptr的計數 }; template classshared_ptr{ public: shared_ptr(T*p=0):ptr_(p){ cnt_=newCounter(); if(p){ cnt_->s_=1; } } ~shared_ptr(){ release(); } shared_ptr(shared_ptr const&s){ ptr_=s.ptr_; (s.cnt)->s_++; cnt_=s.cnt_; } shared_ptr(weakptr_ const&w){ ptr_=w.ptr_; (w.cnt_)->s_++; cnt_=w.cnt_; } shared_ptr &operator=(shared_ptr &s){ if(this!=&s){ release(); (s.cnt_)->s_++; cnt_=s.cnt_; ptr_=s.ptr_; } return*this; } T&operator*(){ return*ptr_; } T*operator->(){ returnptr_; } friendclassweak_ptr ; protected: voidrelease(){ cnt_->s_--; if(cnt_->s_1) ????{ ??????delete?ptr_; ??????if?(cnt_->w_1) ??????{ ??????????delete?cnt_; ??????????cnt_?=?NULL; ??????} ????} ??} private: ??T?*ptr_; ??Counter?*cnt_; };
weak_ptr
在三個智能指針中,weak_ptr是存在感最低的一個,也是最容易被大家忽略的一個智能指針。它的引入是為了解決shared_ptr存在的一個問題循環引用。
特點
不具有普通指針的行為,沒有重載operator*和operator->
沒有共享資源,它的構造不會引起引用計數增加
用于協助shared_ptr來解決循環引用問題
可以從一個shared_ptr或者另外一個weak_ptr對象構造,進而可以間接獲取資源的弱共享權。
使用
intmain(){ std::shared_ptrp1=std::make_shared (14); { std::weak_ptr weak=p1; std::shared_ptr new_shared=weak.lock(); shared_e1=nullptr; new_shared=nullptr; if(weak.expired()){ std::cout<"weak?pointer?is?expired"?<
上述代碼輸出如下:
weakpointerisexpired 0
使用成員函數use_count()和expired()來獲取資源的引用計數,如果返回為0或者false,則表示關聯的資源不存在
使用lock()成員函數獲得一個可用的shared_ptr對象,進而操作資源
當expired()為true的時候,lock()函數將返回一個空的shared_ptr
簡單實現
templateclassweak_ptr { public: weak_ptr()=default; weak_ptr(shared_ptr &s):ptr_(s.ptr_),cnt(s.cnt_){ cnt_->w_++; } weak_ptr(weak_ptr &w):ptr_(w.ptr_),cnt_(w.cnt_){ cnt_->w_++; } ~weak_ptr(){ release(); } weak_ptr &operator=(weak_ptr &w){ if(this!=&w){ release(); cnt_=w.cnt_; cnt_->w_++; ptr_=w.ptr_; } return*this; } weak_ptr &operator=(shared_ptr &s) { release(); cnt_=s.cnt_; cnt_->w_++; ptr_=s.ptr_; return*this; } shared_ptr lock(){ returnshared_ptr (*this); } boolexpired(){ if(cnt){ if(cnt->s_>0){ returnfalse; } } returntrue; } friendclassshared_ptr ; protected: voidrelease(){ if(cnt_){ cnt_->w_--; if(cnt_->w_1?&&?cnt_->s_1)?{ ????????cnt_?=?nullptr; ??????} ????} ??} private: ????T?*ptr_?=?nullptr; ????Counter?*cnt_?=?nullptr; };
循環引用
在之前的文章內存泄漏-原因、避免以及定位中,我們講到使用weak_ptr來配合shared_ptr使用來解決循環引用的問題,借助本文,我們深入說明下如何來解決循環引用的問題。
代碼如下:
classController{ public: Controller()=default; ~Controller(){ std::cout<"in?~Controller"?<controller_; }; std::shared_ptrsub_controller_; };
在上述代碼中,因為controller和sub_controller之間都有一個指向對方的shared_ptr,這樣就導致任意一個都因為對方有一個指向自己的對象,進而引用計數不能為0。
為了解決std::shared_ptr循環引用導致的內存泄漏,我們可以使用std::weak_ptr來單面去除上圖中的循環。
classController{ public: Controller()=default; ~Controller(){ std::cout<"in?~Controller"?<controller_; }; std::shared_ptrsub_controller_; };
在上述代碼中,我們將SubController類中controller_的類型從std::shared_ptr變成std::weak_ptr。
那么,為什么將SubController中的shared_ptr換成weak_ptr就能解決這個問題呢?我們看下源碼:
template__weak_ptr& operator=(const__shared_ptr<_Tp1,?_Lp>&__r)//neverthrows { _M_ptr=__r._M_ptr; _M_refcount=__r._M_refcount; return*this; }
在上面代碼中,我們可以看到,將一個shared_ptr賦值給weak_ptr的時候,其引用計數并沒有+1,所以也就解決了循環引用的問題。
那么,如果我們想要使用shared_ptr關聯的對象進行操作時候,該怎么做呢?使用weak_ptr::lock()函數來實現,源碼如下:
__shared_ptr<_Tp,?_Lp> lock()const{ returnexpired()?__shared_ptr():__shared_ptr (*this); }
從上面代碼可看出,使用lock()函數生成一個shared_ptr供使用,如果之前的shared_ptr已經被釋放,那么就返回一個空shared_ptr對象,否則生成shared_ptr對象的拷貝(這樣即使之前的釋放也不會存在問題)。
經驗之談
不要混用
指針之間的混用,有時候會造成不可預知的錯誤,所以建議盡量不要混用。包括裸指針和智能指針以及智能指針之間的混用
裸指針和智能指針混用
代碼如下:
voidfun(){ autoptr=newType; std::shared_ptrt(ptr); deleteptr; }
在上述代碼中,將ptr所有權歸shared_ptr所擁有,所以在出fun()函數作用域的時候,會自動釋放ptr指針,而在函數末尾又主動調用delete來釋放,這就會造成double delete,會造成segment fault。
智能指針混用
代碼如下:
voidfun(){ std::unique_ptrt(newType); std::shared_ptr t1(t.get()); }
在上述代碼中,將t關聯的對象又給了t1,也就是說同一個對象被兩個智能指針所擁有,所以在出fun()函數作用域的時候,二者都會釋放其關聯的對象,這就會造成double delete,會造成segment fault。
需要注意的是,下面代碼在STL中是支持的:
voidfun(){ std::unique_ptrt(newType); std::shared_ptr t1(std::move(t)); }
不要管理同一個裸指針
代碼如下:
voidfun(){ autoptr=newType; std::unique_ptrt(ptr); std::shared_ptr t1(ptr); }
在上述代碼中,ptr所有權同時給了t和t1,也就是說同一個對象被兩個智能指針所擁有,所以在出fun()函數作用域的時候,二者都會釋放其關聯的對象,這就會造成double delete,會造成segment fault。
避免使用get()獲取原生指針
voidfun(){ autoptr=std::make_shared(); autoa=ptr.get(); std::shared_ptr t(a); deletea; }
一般情況下,生成的指針都要顯式調用delete來進行釋放,而上述這種,很容易稍不注意就調用delete;非必要不要使用get()獲取原生指針。
不要管理this指針
classType{ private: voidfun(){ std::shared_ptrt(this); } };
在上述代碼中,如果Type在棧上,則會導致segment fault,堆上視實際情況(如果在對象在堆上生成,那么使用合理的話,是允許的)。
只管理堆上的對象
voidfun(){ Typet; std::shared_ptrptr(&t); };
在上述代碼中,t在棧上進行分配,在出作用域的時候,會自動釋放。而ptr在出作用域的時候,也會調用delete釋放t,而t本身在棧上,delete一個棧上的地址,會造成segment fault。
優先使用unique_ptr
根據業務場景,如果需要資源獨占,那么建議使用unique_ptr而不是shared_ptr,原因如下:
性能優于shared_ptr
因為shared_ptr在拷貝或者釋放時候,都需要操作引用計數
內存占用上小于shared_ptr
shared_ptr需要維護它指向的對象的線程安全引用計數和一個控制塊,這使得它比unique_ptr更重量級
使用make_shared初始化
我們看下常用的初始化shared_ptr兩種方式,代碼如下:
std::shared_ptrp1=newType; std::shared_ptr p2=std::make_shared ();
那么,上述兩種方法孰優孰劣呢?我們且從源碼的角度進行分析。
第一種初始化方法,有兩次內存分配:
new Type分配對象
為p1分配控制塊(control block),控制塊用于存放引用計數等信息
我們再看下make_shared源碼:
templateinline shared_ptr<_Ty>make_shared(_Types&&..._Args) {//makeashared_ptr _Ref_count_obj<_Ty>*_Rx= new_Ref_count_obj<_Ty>(_STDforward<_Types>(_Args)...); shared_ptr<_Ty>_Ret; _Ret._Resetp0(_Rx->_Getptr(),_Rx); return(_Ret); }
這里的_Ref_count_obj類包含成員變量:
控制塊
一個內存塊,用于存放智能指針管理的資源對象
再看看_Ref_count_obj的構造函數:
template_Ref_count_obj(_Types&&..._Args) :_Ref_count_base() {//constructfromargumentlist ::new((void*)&_Storage)_Ty(_STDforward<_Types>(_Args)...); }
此處雖然也有一個new操作,但是此處是placement new,所以不存在內存申請。
從上面分析我們可以看出,第一種初始化方式(new方式)共有兩次內存分配操作,而第二種初始化方式(make_shared)只有一次內存申請,所以建議使用make_shared方式進行初始化。
結語
智能指針的出現,能夠使得開發者不需要關心內存的釋放,進而使得開發者能夠將更多的精力投入到業務上。但是,因為智能指針本身也有其局限性,如果使用不當,會造成意想不到的后果,所以,在使用之前,需要做一些必要的檢查,為了更好地用好智能指針,建議看下源碼實現,還是比較簡單的。
審核編輯:彭靜
-
函數
+關注
關注
3文章
4331瀏覽量
62622 -
C++
+關注
關注
22文章
2108瀏覽量
73654 -
代碼
+關注
關注
30文章
4788瀏覽量
68617
原文標題:智能指針-使用、避坑和實現
文章出處:【微信號:C語言與CPP編程,微信公眾號:C語言與CPP編程】歡迎添加關注!文章轉載請注明出處。
發布評論請先 登錄
相關推薦
評論