- C++服務器開發(fā)精髓
- 張遠龍
- 5439字
- 2021-07-23 18:22:13
1.11 stl中的智能指針類詳解
C/C++最為人詬病的是內存泄露問題,后來的大多數(shù)語言都內置了內存分配與釋放功能,有的甚至對語言的使用者屏蔽了內存指針這一概念。這里對此不置褒貶,手動分配與釋放內存有利有弊,自動分配與釋放內存亦如此,這是兩種不同的設計哲學。有人認為,內存如此重要,怎能放心將其交給用戶去管理呢?另外一些人則認為,內存如此重要,怎能放心將其交給系統(tǒng)去管理呢?在C/C++中,內存泄露的問題一直困擾著廣大開發(fā)者,因此各類庫和工具也一直在努力嘗試各種方法去檢測和避免內存泄露,例如 boost,因此智能指針技術應運而生。
1.11.1 C++98/03的嘗試——std::auto_ptr
現(xiàn)在討論std::auto_ptr不免讓人懷疑是不是有點過時了,確實如此,C++11標準廢棄了std::auto_ptr(在C++17標準中被移除),取而代之的是std::unique_ptr。這里之所以介紹std::auto_ptr的用法及它在設計上的不足之處,是想讓讀者了解C++中智能指針的發(fā)展歷程。我們在了解一項技術過去的樣子和發(fā)展軌跡后,就能更好地掌握它。
std::auto_ptr的基本用法如下:


智能指針對象 sp1 和 sp2 均持有一個在堆上分配的 int 對象,值都是 8,這兩塊堆內存都在sp1和sp2釋放時得到釋放。這是std::auto_ptr的基本用法。
sp是smart pointer(智能指針)的簡寫。
std::auto_ptr容易讓人誤用的地方是其不常用的復制語義,即當復制一個std::auto_ptr對象時(拷貝復制或 operator=復制),原 std::auto_ptr 對象所持有的堆內存對象也會被轉移給復制出來的新std::auto_ptr對象。示例代碼如下:


在以上代碼中分別利用了拷貝構造(sp1=>sp2)和賦值構造(sp3=>sp4)來創(chuàng)建新的std::auto_ptr對象,因此sp1持有的堆對象被轉移給sp2,sp3持有的堆對象被轉移給sp4。示意圖如下。

程序執(zhí)行結果如下:

因為 std::auto_ptr 是不常用的復制語義,所以我們應該避免在 stl 容器中使用std::auto_ptr,例如不應該寫出如下代碼:

當用算法對容器進行操作時(如最常見的容器元素遍歷),很難避免不對容器中的元素進行賦值傳遞,這樣便會使容器中的多個元素被置為空指針,這不是我們希望看到的,可能會造成一些意想不到的錯誤。
作為std::auto_ptr的替代者,std::unique_ptr吸取了這個教訓,下文會詳細介紹。
正因為std::auto_ptr的設計存在缺陷,所以C++11標準充分借鑒和吸收了boost庫中智能指針的設計思想,引入了三種新類型的智能指針,即 std::unique_ptr、std::shared_ptr和std::weak_ptr。
C++11沒有全部照搬boost智能指針類型,而是選擇了其中三個最實用的類型。boost還有 scoped_ptr,在 C++11中可以通過 std::unique_ptr達到與 boost::scoped_ptr一樣的效果。
所有智能指針類(包括std::unique_ptr)均被定義于頭文件<memory>中。
在C++11及后續(xù)語言規(guī)范中,std::auto_ptr已被廢棄,在我們的代碼中不應再使用它。
1.11.2 std::unique_ptr
std::unique_ptr 對其持有的堆內存具有唯一擁有權,也就是說該智能指針對資源(即其管理的堆內存)的引用計數(shù)永遠是 1,std::unique_ptr 對象在銷毀時會釋放其持有的堆內存。可以采用以下方式初始化一個std::unique_ptr對象:

我們應該盡量采用初始化方式 3去創(chuàng)建一個 std::unique_ptr,而不是采用初始化方式1和2,因為初始化方式3更安全。
讓很多人對C++11規(guī)范吐槽的地方之一是,C++11新增了std::make_shared方法創(chuàng)建一個 std::shared_ptr 對象,卻沒有提供相應的 std::make_unique 方法創(chuàng)建一個std::unique_ptr對象,該方法直到C++14時才被添加進來。當然,在C++11中很容易實現(xiàn)一個這樣的方法:

鑒于 std::auto_ptr 的前車之鑒,std::unique_ptr 禁止復制語義,為了達到這個效果,std::unique_ptr類的拷貝構造函數(shù)和賦值運算符(operator=)均被標記為=delete。

因此,以下代碼是無法通過編譯的:

不過禁止復制語義也存在特例,例如可以通過一個函數(shù)返回一個std::unique_ptr:

以上代碼從func函數(shù)中得到一個std::unique_ptr對象,然后返回給sp1。
既然 std::unique_ptr 不能被復制,那么如何將一個 std::unique_ptr 對象持有的堆內存轉移給另外一個呢?答案是使用移動構造,示例代碼如下:

以上代碼利用了std::move將sp1持有的堆內存(值為123)轉移給sp2,再將sp2轉移給 sp3。最后,sp1 和 sp2 不再持有堆內存的引用,變成一個空的智能指針對象。并不是所有對象的std::move操作都有意義,只有實現(xiàn)了移動構造函數(shù)(Move Constructor)或移動賦值運算符(operator=)的類才行,而std::unique_ptr正好實現(xiàn)了二者。以下是該實現(xiàn)的偽代碼:


這就是std::unique_ptr具有移動語義的原因。
std::unique_ptr不僅可以持有一個堆對象,也可以持有一組堆對象,示例如下:

程序執(zhí)行結果如下:


std::shared_ptr和std::weak_ptr也可以持有一組堆對象,用法與 std::unique_ptr相同,下文不再贅述。
在默認情況下,智能指針對象在析構時只會釋放其持有的堆內存(調用 delete或者delete[]),但是假設這塊堆內存代表的對象還對應一種需要回收的資源(如操作系統(tǒng)的套接字句柄、文件句柄等),我們則可以通過給智能指針自定義資源回收函數(shù)來實現(xiàn)資源回收。假設現(xiàn)在有一個Socket類,對應操作系統(tǒng)的套接字句柄,在回收時需要關閉該對象,我們則可以這樣自定義智能指針對象的資源釋放函數(shù),以std::unique_ptr為例:


自定義std::unique_ptr的資源釋放函數(shù)的語法規(guī)則如下:

其中,T 是我們要釋放的對象類型,DeletorFuncPtr 是一個自定義函數(shù)指針。以上加粗代碼行表示DeletorFuncPtr的寫法有點復雜,我們可以使用decltype(deletor)讓編譯器自己推導deletor的類型,因此可以將加粗代碼行修改如下:

1.11.3 std::shared_ptr
std::unique_ptr對其持有的資源具有獨占性,而 std::shared_ptr持有的資源可以在多個std::shared_ptr之間共享,每多一個std::shared_ptr對資源的引用,資源引用計數(shù)就會增加1,在每一個指向該資源的std::shared_ptr對象析構時,資源引用計數(shù)都會減少1,最后一個 std::shared_ptr 對象析構時,若發(fā)現(xiàn)資源計數(shù)為 0,則將釋放其持有的資源。多個線程之間遞增和減少資源的引用計數(shù)都是安全的(注意:這不意味著多個線程同時操作std::shared_ptr管理的資源是安全的)。std::shared_ptr提供了一個use_count方法來獲取當前管理的資源的引用計數(shù)。除了上面描述的內容,std::shared_ptr的用法和std::unique_ptr基本相同。
下面是一個初始化std::shared_ptr的示例:

和std::unique_ptr一樣,我們應該優(yōu)先使用std::make_shared初始化一個 std::shared_ptr對象。
再來看另外一段代碼:


整個程序的執(zhí)行結果如下:

1.11.4 std::enable_shared_from_this
在實際開發(fā)中有時需要在類中返回包裹當前對象(this)的一個std::shared_ptr對象給外部使用,C++新標準也為我們考慮到了這一點,有如此需求的類只要繼承自std::enable_shared_from_this<T>模板對象即可。用法如下:

在以上代碼中,類A繼承自std::enable_shared_from_this<A>并提供了一個getSelf方法返回自身的std::shared_ptr對象,在getSelf方法中調用了shared_from_this方法。
std::enable_shared_from_this使用起來比較方便,但也存在很多注意事項。
注意事項一:不應該共享棧對象的this指針給智能指針對象。
假設我們將上面代碼中main函數(shù)的第1行生成A對象的方式改成一個棧變量:


則運行修改后的代碼,會發(fā)現(xiàn)程序在“std::shared_ptr<A> sp2=a.getSelf();”處崩潰。這是因為,智能指針管理的是堆對象,棧對象會在函數(shù)調用結束后自行銷毀,因此不能通過shared_from_this()將該對象交由智能指針對象管理。切記:智能指針最初設計的目的就是管理堆對象。
注意事項二:std::enable_shared_from_this的循環(huán)引用問題。
再來看另外一段代碼:


乍一看上面的代碼好像沒有問題,讓我們實際運行一下看看輸出結果:

我們會發(fā)現(xiàn),在程序的整個生命周期內,只有 A類構造函數(shù)的調用輸出,沒有 A類析構函數(shù)的調用輸出,這意味著新建的A對象產生內存泄漏了!
我們來分析一下新建的A對象為什么得不到釋放。程序在執(zhí)行完加粗代碼行后,spa出了其作用域準備析構,在析構時其發(fā)現(xiàn)仍然有另一個std::shared_ptr對象即A::m_SelfPtr引用了A,因此spa只會將對A的引用計數(shù)遞減為1,然后銷毀自身。現(xiàn)在很矛盾:必須銷毀 A 才能銷毀其成員變量 m_SelfPtr,而銷毀 A 前必須先銷毀 m_SelfPtr。這就是 std::enable_shared_from_this 的循環(huán)引用問題。我們在實際開發(fā)中應該避免做出這樣的邏輯設計,在這種情形下即使使用了智能指針也會造成內存泄漏。也就是說,一個資源的生命周期可以交給一個智能指針對象來管理,但是該智能指針的生命周期不可以再交給該資源來管理。
1.11.5 std::weak_ptr
std::weak_ptr是一個不控制資源生命周期的智能指針,是對對象的一種弱引用,只提供了對其管理的資源的一個訪問手段,引入它的目的是協(xié)助std::shared_ptr工作。
std::weak_ptr 可以從一個 std::shared_ptr 或另一個 std::weak_ptr 對象構造,std::shared_ptr可以直接賦值給std::weak_ptr,也可以通過std::weak_ptr的lock函數(shù)來獲得std::shared_ptr。它的構造和析構不會引起引用計數(shù)的增加或減少。std::weak_ptr 可用來解決 std::shared_ptr 相互引用時的死鎖問題,即兩個 std::shared_ptr 相互引用,這兩個智能指針的資源引用計數(shù)就永遠不可能減少為0,資源永遠不會被釋放。
示例代碼如下:


程序執(zhí)行結果如下:

無論通過何種方式創(chuàng)建std::weak_ptr,都不會增加資源的引用計數(shù),因此每次輸出的sp1的引用計數(shù)值都是1。
既然std::weak_ptr不管理所引用資源的生命周期,其引用的資源就可能在某個時刻失效。我們需要使用std::weak_ptr引用該資源時,如何得知該資源是否有效呢?std::weak_ptr提供了一個 expired 方法來做這項檢測,該方法返回 true,說明其引用的資源已失效;返回 false,說明該資源仍然有效,這時可以使用 std::weak_ptr 的 lock 方法得到一個std::shared_ptr對象后繼續(xù)操作資源。以下代碼演示了該用法:

有讀者可能對以上代碼產生疑問,既然使用了std::weak_ptr的expired方法判斷了對象是否有效,那么為什么不直接使用std::weak_ptr對象對引用資源進行操作呢?這實際上是行不通的,std::weak_ptr 類沒有重寫 operator->和 operator*方法,因此不能像std::shared_ptr 或 std::unique_ptr 一樣直接操作對象,std::weak_ptr 類也沒有重寫 operator bool()操作,因此不能通過std::weak_ptr對象直接判斷其引用的資源是否存在:


在多線程場景下,即使剛剛使用了std::weak_ptr的expired方法判斷其引用的資源是否有效,但若不加一些多線程保護策略,卻接著調用 std::weak_ptr 的 lock 方法嘗試得到一個std::shared_ptr對象去操作資源,則仍然可能存在安全隱患。其原因是,在調用lock方法期間,引用的資源可能恰好被銷毀,這可能會造成比較棘手的問題。示例代碼如下:


在以上代碼中,m_tmpConn 是 TcpSession 的成員變量,其類型是 std::weak_ptr<TcpConnection>。由于兩個線程同時操作 m_tmpConn,所以即使線程 1 在希望使用m_tmpConn引用的資源時,先用 expired方法判斷對應的 TcpConnection是否有效,再用lock 方法嘗試得到一個 std::shared_ptr 對象,但如果在 lock 方法調用前,線程 2 釋放了m_tmpConn引用的資源,那么線程1接下來的邏輯仍然存在安全隱患。在多線程場景下,這是一種常見的錯誤,需要避免。
因此,std::weak_ptr的正確使用場景是引用的資源如果可用就使用,不可用就不使用,不參與資源的生命周期管理。例如在網(wǎng)絡分層結構中,Session 對象(會話對象)利用Connection對象(連接對象)提供的服務進行工作,但是 Session 對象不管理 Connection對象的生命周期,Session管理Connection的生命周期是不合理的,因為網(wǎng)絡底層出錯時會導致Connection對象被銷毀,此時Session對象如果強行持有Connection對象,則與事實矛盾。
std::weak_ptr應用場景中的經(jīng)典例子是訂閱者模式或者觀察者模式。這里以訂閱者為例來說明,消息發(fā)布器只有在某個訂閱者存在的情況下才會向其發(fā)布消息,不能管理訂閱者的生命周期。


1.11.6 智能指針對象的大小
一個std::unique_ptr對象的大小與裸指針的大小相同(即sizeof(std::unique_ptr<T>)==sizeof(void*)),而 std::shared_ptr 的大小是 std::unique_ptr 的兩倍。以下是分別在 Visual Studio 2019和gcc/g++4.8上(將二者都編譯成x64程序)測試的結果。
測試代碼如下:

Visual Studio 2019的運行結果如下圖所示。

gcc/g++的運行結果如下圖所示。

在 32位機器上,std::unique_ptr占 4字節(jié),std::shared_ptr和 std::weak_ptr均占 8字節(jié);在64位機器上,std_unique_ptr占8字節(jié),std::shared_ptr和std::weak_ptr均占16字節(jié)。也就是說,std_unique_ptr 的大小總是和原始指針的大小一樣,std::shared_ptr 和std::weak_ptr的大小是原始指針的大小的兩倍。
1.11.7 使用智能指針時的注意事項
C++新標準提倡的理念之一是不再手動調用delete或者free函數(shù)去釋放堆內存,而是把它們交給新標準提供的各種智能指針對象。C++新標準中的各種智能指針是如此實用與強大,在現(xiàn)在的 C++項目開發(fā)中,我們應該盡量使用它們。智能指針雖然好用,但稍不注意,也可能存在許多難以發(fā)現(xiàn)的bug,下面總結了一些實用經(jīng)驗。
1.一旦使用了智能指針管理一個對象,就不該再使用原始裸指針去操作它
看一段代碼:

這段代碼創(chuàng)建了一個堆對象 pSubscriber,然后利用智能指針spSubscriber去管理它,私下卻利用原始指針pSubscriber銷毀了該對象,這讓智能指針對象spSubscriber“情何以堪”!
注意,一旦智能指針對象接管了我們的資源,對資源的所有操作就都應該通過智能指針對象進行,不建議再通過原始指針進行。當然,除了 std::weak_ptr,std::unique_ptr 和std::shared_ptr都提供了用于獲取原始指針的get函數(shù)。

2.知道在哪些場合使用哪種類型的智能指針
在通常情況下,如果我們的資源不需要在其他地方共享,就應該優(yōu)先使用std::unique_ptr,反之使用 std::shared_ptr。當然,這是在該智能指針需要管理資源的生命周期的情況下進行的;如果不需要管理對象的生命周期,則請使用std::weak_ptr。
3.認真考慮,避免操作某個引用資源已經(jīng)釋放的智能指針
通過前面的例子,我們很容易知道一個智能指針持有的資源是否有效,但還是建議在不同的場景下謹慎一些,因為在某些場景下很容易誤判。例如下面的代碼:

在以上代碼中,sp2 是 sp1 的引用,sp1 被置空后,sp2 也一同為空。這時調用sp2->doSomething(),sp2->(即operator->)在內部會調用get方法獲取原始指針對象,這時得到一個空指針(地址為0),繼續(xù)調用doSomething()會導致程序崩潰。
有些讀者可能覺得以上代碼片段存在的問題是顯而易見的,讓我們把這個例子放到實際項目中再看一下:


以上代碼來自實際的商業(yè)項目,其崩潰的原因是調用了 conn->peerAddress 方法。為什么這個方法的調用可能會引發(fā)程序崩潰呢?現(xiàn)在還可以顯而易見地看出問題嗎?
其崩潰的原因是傳入的 conn 對象和上一個例子中的 sp2 一樣都是另一個std::shared_ptr 的引用,當連接斷開時,對應的 TcpConnection 對象可能早已被銷毀,而conn 引用會變成空指針(嚴格來說,是不再持有一個 TcpConnection 對象的引用),此時調用TcpConnection的peerAddress方法就會產生和上一個示例一樣的錯誤。
4.作為類成員變量,應該優(yōu)先使用前置聲明(forward declarations)
我們知道,為了減少編譯依賴、加快編譯速度和減少生成的二進制文件的大小,C/C++項目一般在*.h文件中對指針類型盡量使用前置聲明,而不是直接包含對應類的頭文件。例如:

同樣的道理,在頭文件中使用智能指針對象作為類成員變量時,也應該優(yōu)先使用前置聲明去引用智能指針對象的包裹類,而不是直接包含包裹類的頭文件。

Modern C/C++已經(jīng)成為 C/C++的開發(fā)趨勢,建議讀者善用和熟練使用本節(jié)介紹的后三種智能指針對象。
- What's New in TensorFlow 2.0
- 精通Scrapy網(wǎng)絡爬蟲
- Hands-On C++ Game Animation Programming
- 深度學習:算法入門與Keras編程實踐
- C語言程序設計
- ASP.NET開發(fā)與應用教程
- Kubernetes源碼剖析
- Mudbox 2013 Cookbook
- 分布式架構原理與實踐
- Hands-On Robotics Programming with C++
- Java程序設計教程
- Java EE架構設計與開發(fā)實踐
- Enterprise Application Architecture with .NET Core
- Android開發(fā)權威指南(第二版)
- Mastering Web Application Development with Express