shared_ptr的原理與應用


new與賦值的坑

賦值(assignment)和new運算符在C++與Java(或C#)中的行為有本質的區別。在Java中,new是對象的構造,而賦值運算是引用的傳遞;而在C++中,賦值運算符意味着"構造",或者"值的拷貝",new運算符意味着在堆上分配內存空間,並將這塊內存的管理權(責任)交給用戶。C++中的不少坑,就是由new和賦值引起的。

在C++中使用new的原因除了堆上能定義體積更大的數據結構之外,就是能使用C++中的dynamic dispatch(也叫多態)了:只有指針(和引用)才能使用虛函數來展現多態性。在這時,new出來的指針變得很像Java中的普通對象,賦值意味着引用的傳遞,方法調用會呈現出多態性,我們進入了面向對象的世界,一切十分美好,除了"要手動釋放內存"。

在簡單的程序中,我們不大可能忘記釋放new出來的內存,隨着程序規模的增大,我們忘了delete的概率也隨之增大,這是因為C++是如此一個精神分裂的語言,賦值運算符竟然同時展現出"值拷貝"和"引用傳遞"兩種截然不同的語義,這種不一致性導致"內存泄漏"成為C++新手最常犯的錯誤之一。當然你可以說,只要細心一點,一定能把所有內存泄漏從代碼中清除。但手動管理內存更嚴重的問題在於,內存究竟要由誰來分配和釋放呢?指針的賦值將同一對象的引用散播到程序每個角落,但是該對象的刪除卻只能發生一次,當你在代碼中用完這么一個資源指針:resourcePtr,你敢delete它嗎?它極有可能同時被多個對象擁有着,而這些對象中的任何一個都有可能在之后使用該資源,而這些對象中的另外一個,可能在它的析構函數中釋放該資源。"那我不delete不就行了嗎?",你可能這么問,當然行, 這時候你要面對另外一種可能性:也許你是這個指針的唯一使用者,如果你用完不delete,內存就泄漏了。

開發者日常需要在工作中使用不同的庫,而以上兩種情況可能會在這些庫中出現,假設庫作者們的性格截然不同,導致這兩個庫在資源釋放上采取了不同的風格,在這個時候,你面對一個用完了的資源指針,是刪還是不刪呢?這個問題從根本上來說,是因為C++的語言特性讓人容易搞錯"資源的擁有者"這個概念,資源的擁有者,從來都只能是系統,當我們需要時便向系統請求,當我們不需要時就讓系統自己撿回去(Garbage Collector),當我們試圖自己當資源的主人時,一系列坑爹的問題就會接踵而來。

異常安全的類

我們再來看另外一個與new運算符緊密相關的問題:如何寫一個異常安全(exception safe)的類。

異常安全簡單而言就是:當你的類拋出異常后,你的程序會不會爆掉。爆掉的情況主要包括:內存泄漏,以及不一致的類狀態(例如一個字符串類,它的size()方法返回的字符串大小與實際的字符串大小不同),這里僅討論內存泄漏的情況。

為了讓用戶免去手動delete資源的煩惱,不少類庫采用了RAII風格,即Resource Acquisition Is Initialization,這種風格采用類來封裝資源,在類的構造函數中獲取資源,在類的析構函數中釋放資源,這個資源可以是內存,可以是一個網絡連接,也可以是mutex這樣的線程同步量。在RAII的感召下,我們來寫這么一個人畜無害的類:

class TooSimple {

private:

    Resource *a;

    Resource *b;

public

    TooSimple() {

        a = new Resource();

        b = new Resource(); //在這里拋出異常

    }

    ~TooSimple() {

        delete a;

        delete b;

    }

};

這個看似簡單的類,是有內存泄漏危險的喲!為了理解這一點,首先簡單介紹一下C++在拋出異常時所做的事吧:

  1. 如果一個new操作(及其調用的構造函數)中拋出了異常,那么它分配的內存空間將自動被釋放。
  2. 一個函數(或方法)拋出異常,那么它首先將當前棧上的變量全部清空(unwinding),如果變量是類對象的話,將調用其析構函數,接着,異常來到call stack的上一層,做相同操作,直到遇到catch語句。
  3. 指針是一個普通的變量,不是類對象,所以在清空call stack時,指針指向資源的析構函數將不會調用。

根據這三條規則,我們很容易發現,如果b = new Resource()句拋出異常,那么構造函數將被強行終止,根據規則1,b分配的資源將被釋放(假設Resource類本身是異常安全的),指針a,b從call stack上清除,由於此時構造函數還未完成,所以TooSimple的析構函數也不會被調用(都沒構造完呢,現在只是一個"部分初始化"的對象,析構函數自然沒理由被調用),a已經被分配了資源,但是call stack被清空,地址已經找不到了,於是delete永遠無法執行,於是內存泄漏發生了。

這個問題有一個很直接的"解決"方案,那就是把b = new Resource()包裹在一個try-catch塊中,並在catch里將執行delete a,這樣做當然沒問題,但我們的代碼邏輯變得復雜了,且當類需要分配的資源種類增多的時候,這種處理辦法會讓程序的可讀性急劇下降。這時候我們不禁想:要是指針變量能像類對象一樣地"析構"就好了,一旦指針具有類似析構的行為,那么在call stack被清空時,指針會在"析構"時實現自動的delete。懷着這種想法,我們寫了這么一個類模版:

template <typename T>

class StupidPointer {

public:

    T *ptr;

    StupidPointer(T *p) : ptr(p) {}

    ~StupidPointer() { delete ptr; }

};

有了這個"酷炫"的類,現在我們的構造函數可以這么寫:

TooSimple() {

    a = StupidPointer<Resource>(new Resource());

    b = StupidPointer<Resource>(new Resource());

};

由於此時的a,已經不再是指針,而是StupidPointer<Resource>類,在清空call stack時,它的析構函數被調用,於是a指向的資源被釋放了。但是,StupidPointer類有一個嚴重的問題:當多個StupidPointer對象管理同一個指針時,一個對象析構后,剩下對象中保存的指針將變成指向無效內存地址的"野指針"(因為已經被delete過了啊),如果delete一個野指針,電腦就會爆炸(嚴肅)。

C++11的標准庫提供了兩種解決問題的思路:1、不允許多個對象管理一個指針(unique_ptr);2、允許多個對象管理同一個指針,但僅當管理這個指針的最后一個對象析構時才調用delete(shared_ptr)。這兩個思路的共同點是:只!允!許!delete一次!

本篇文章里,我們僅討論shared_ptr。

shared_ptr

在將shared_ptr的使用之前,我們首先來看看它的基本實現原理。

剛才說到,當多個shared_ptr管理同一個指針,僅當最后一個shared_ptr析構時,指針才被delete。這是怎么實現的呢?答案是:引用計數(reference counting)。引用計數指的是,所有管理同一個裸指針(raw pointer)的shared_ptr,都共享一個引用計數器,每當一個shared_ptr被賦值(或拷貝構造)給其它shared_ptr時,這個共享的引用計數器就加1,當一個shared_ptr析構或者被用於管理其它裸指針時,這個引用計數器就減1,如果此時發現引用計數器為0,那么說明它是管理這個指針的最后一個shared_ptr了,於是我們釋放指針指向的資源。

在底層實現中,這個引用計數器保存在某個內部類型里(這個類型中還包含了deleter,它控制了指針的釋放策略,默認情況下就是普通的delete操作),而這個內部類型對象在shared_ptr第一次構造時以指針的形式保存在shared_ptr中。shared_ptr重載了賦值運算符,在賦值和拷貝構造另一個shared_ptr時,這個指針被另一個shared_ptr共享。在引用計數歸零時,這個內部類型指針與shared_ptr管理的資源一起被釋放。此外,為了保證線程安全性,引用計數器的加1,減1操作都是原子操作,它保證shared_ptr由多個線程共享時不會爆掉。

這就是shared_ptr的實現原理,現在我們來看看怎么用它吧!(超簡單)

std::shared_ptr位於頭文件<memory>中(這里只講C++11,boost的shared_ptr當然是放在boost的頭文件中),下面我以代碼示例的形式展現它的用法,具體文檔可以看這里

// 初始化

shared_ptr<int> x = shared_ptr<int>(new int); // 這個方法有缺陷,下面我會說

shared_ptr<int> y = make_shared<int>();

shared_ptr<Resource> obj = make_shared<Resource>(arg1, arg2); // arg1, arg2是Resource構造函數的參數

// 賦值

shared_ptr<int> z = x; // 此時z和x共享同一個引用計數器

// 像普通指針一樣使用

int val = *x;

assert (x == z);

assert (y != z);

assert (x != nullptr);

obj->someMethod();

// 其它輔助操作

x.swap(z); // 交換兩個shared_ptr管理的裸指針(當然,包含它們的引用計數)

obj.reset(); // 重置該shared_ptr(引用計數減1)

太好用了!

錯誤用法1:循環引用

shared_ptr的一個最大的缺點,或者說,引用計數策略最大的缺點,就是循環引用(cyclic reference),下面是一個典型的事故現場:

class Observer; // 前向聲明

class Subject {

private:

    std::vector<shared_ptr<Observer>> observers;

public:

    Subject() {}

    addObserver(shared_ptr<Observer> ob) {

        observers.push_back(ob);

    }

    // 其它代碼

    ..........

};

class Observer {

private:

    shared_ptr<Subject> object;

public:

    Observer(shared_ptr<Object> obj) : object(obj) {}

    // 其它代碼

    ...........

};

目標(Subject)類連接着多個觀察者(Observer)類,當某個事件發生時,目標類可以遍歷觀察者數組observers,對每個觀察者進行"通知",而觀察者類中,也保存着目標類的shared_ptr,這樣多個觀察者之間可以以目標類為橋梁進行溝通,除了會發生內存泄漏以外,這是很不錯的設計模式嘛!等等,不是說用了shared_ptr管理資源后就不會內存泄漏了嗎?怎么又漏了?

這就是引用計數模型失效的唯一的情況:循環引用。循環引用指的是,一個引用通過一系列的引用鏈,竟然引用回自身,上面的例子中,Subject->Observer->Subject就是這么一條環形的引用鏈。假設我們的程序中只有一個變量shared_ptr<Subject> p,此時,p指向的對象不僅通過該shared_ptr引用自己,還通過它包含的Observer中的object成員變量引用回自己,於是它的引用計數是2,每個Observer的引用計數都是1。當p析構時,它的引用計數減1,變成2-1=1(大於0!),p指向對象的析構函數將不會被調用,於是p和它包含的每個Observer對象在程序結束時依然駐留在內存中沒被delete,形成內存泄漏。

weak_ptr

為了解決這一問題,標准庫提供了std::weak_ptr(弱引用),它也位於<memory>中。

weak_ptr是shared_ptr的"觀察者",它與一個shared_ptr綁定,但卻不參與引用計數的計算,在需要時,它還能搖身一變,生成一個與它所"觀察"的shared_ptr共享引用計數器的新shared_ptr。總而言之,weak_ptr的作用就是:在需要時變出一個shared_ptr,在其它時候不干擾shared_ptr的引用計數。

在上面的例子中,我們只需簡單地將Observer中object成員的類型換成std::weak_ptr<Subject>即可解決內存泄漏的問題,此刻(接着上面的例子),p指向對象的引用計數為1,所以在p析構時,Subject指針將被delete,其中包含的observers數組在析構時,內部的Observer對象的引用計數也將變為0,故它們也被delete了,資源釋放得干干凈凈。

下面,是weak_ptr的使用方法:

std::shared_ptr<int> sh = std::make_shared<int>();

// 用一個shared_ptr初始化

std::weak_ptr<int> w(sh);

// 變出shared_ptr

std::shared_ptr<int> another = w.lock();

// 判斷weak_ptr所觀察的shared_ptr的資源是否已經釋放

bool isDeleted = w.expired();

錯誤用法2:多個無關的shared_ptr管理同一裸指針

考慮下面這個情況:

int *a = new int;

std::shared_ptr<int> p1(a);

std::shared_ptr<int> p2(a);

p1和p2同時管理同一裸指針a,與之前的例子不同的是,此時的p1和p2有着完全獨立的兩個引用計數器(初始化p2時,用的是裸指針a,於是我們沒有任何辦法獲取p1的引用計數!),於是,上面的代碼會導致a被delete兩次,分別由p1和p2的析構導致,電腦再一次爆炸了。

為了避免這種情況的發生,我們永遠不要將new用在shared_ptr構造函數參數列表以外的地方,或者干脆不用new,改用make_shared。

即便我們的程序嚴格采取上述做法,C++還提供另外一種繞過shared_ptr,直接獲取裸指針的方式,那就是this指針。請看下面的事故現場:

class A {

public:

    std::shared_ptr<A> getShared() {

        return std::shared_ptr<A>(this);

    }

};

int main() {

    std::shared_ptr<A> pa = std::make_shared<A>();

    std::shared_ptr<A> pbad = pa->getShared();

    return 0;

}

在此次事故中,pa和pbad擁有各自獨立的引用計數器,所以程序將發生相同的"delete野指針"錯誤。總而言之,管理同一資源的shared_ptr,只能由同一個初始shared_ptr通過一系列賦值或者拷貝構造途徑得來。更抽象的說,管理同一資源的shared_ptr的構造順序,必須是一個無環有向的連通圖,無環能夠保證沒有循環引用,連通性能夠保證每個shared_ptr都來自於相同的源。

另外,標准庫提供了一種特殊的接口,來解決"生成this指針的shared_ptr"的問題。

enable_shared_from_this

enable_shared_from_this是標准庫中提供的接口(一個基類啦):

template<typename T>

class enable_shared_from_this {

public:

    shared_ptr<T> shared_from_this();

}

如果想要一個由shared_ptr管理的類A對象能夠在方法內部得到this指針的shared_ptr,且返回的shared_ptr和管理這個類的shared_ptr共享引用計數,只需讓這個類派生自enable_shared_from_this<A>即可,之后調用shared_from_this()即可獲得正確的shared_ptr。

一般來說,這個接口是通過weak_ptr實現的:enable_shared_from_this中包含一個weak_ptr,在初始化shared_ptr時,構造函數會檢測到這個該類派生於enable_shared_from_this(通過模版黑魔法很容易就能實現這個功能啦),於是將這個weak_ptr指向初始化的shared_ptr。調用shared_from_this,本質上就是weak_ptr的一個lock操作:

class A : enable_shared_from_this<A> {

    // ......

};

int main() {

    std::shared_ptr<A> pa = std::make_shared<A>();

    std::shared_ptr<A> pgood = pa->shared_from_this();

    return 0;

}

錯誤用法3:直接用new構造多個shared_ptr作為實參

之前提到的C++異常處理機制,讓我們可以很容易發現下面的代碼有內存泄漏的危險:

// 聲明

void f(A *p1, B *p2);

// 使用

f(new A, new B);

假如new A先於new B發生(我說"假如",是因為C++的函數參數的計算順序是不確定的),那么如果new B拋出異常,那么new A分配的內存將會發生泄漏。作為一個剛學會shared_ptr的優秀程序員,我們可以如此"解決"該問題:

// 聲明

void f(shared_ptr<A> p1, shared_ptr<B> p2);

// 使用

f(shared_ptr<A>(new A), shared_ptr<B>(new B));

可惜,這么寫依然有可能發生內存泄漏,因為兩個shared_ptr的構造有可能發生在new A與new B之后,這涉及到C++里稱作sequence after,或sequence point的性質,該性質保證:

  1. new A在shared_ptr<A>構造之前發生
  2. new B在shared_ptr<B>構造之前發生
  3. 兩個shared_ptr的構造在f調用之前發生

在滿足以上三條性質的前提下,各操作可以以任意順序執行。詳情請見Herb Shutter的文章:Exception-Safe Function Calls

make_shared

若我們這么調用f:

f(make_shared<A>(), make_shared<B>());

那么就不可能發生內存泄漏了,原因依然是sequence after性質。sequence after性質保證,如果兩個函數的執行順序不確定(如本例,作為另一個函數的兩個參數),那么在一個函數執行時,另一個不會執行(倘若參數是1+1和3 + 3*6這種表達式,那么加法和乘法甚至允許交錯執行,sequence after性質真是有夠復雜),於是,如果make_shared<A>構造完成了,make_shared<B>中拋出異常,那么A的資源能被正確釋放。與上面用new來初始化的情形對比,make_shared保證了第二new發生的時候,第一個new所分配的資源已經被shared_ptr管理起來了,故在異常發生時,能正確釋放資源。

一句話建議:總是使用make_shared來生成shared_ptr!

結論

  1. 用shared_ptr,不用new
  2. 使用weak_ptr來打破循環引用
  3. 用make_shared來生成shared_ptr
  4. 用enable_shared_from_this來使一個類能獲取自身的shared_ptr


免責聲明!

本站轉載的文章為個人學習借鑒使用,本站對版權不負任何法律責任。如果侵犯了您的隱私權益,請聯系本站郵箱yoyou2525@163.com刪除。



 
粵ICP備18138465號   © 2018-2025 CODEPRJ.COM