【轉】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++在拋出異常時所做的事吧:
    如果一個new操作(及其調用的構造函數)中拋出了異常,那麼它分配的內存空間將自動被釋放。
    一個函數(或方法)拋出異常,那麼它首先將當前棧上的變量全部清空(unwinding),如果變量是類對象的話,將調用其析構函數,接着,異常來到call stack的上一層,做相同操作,直到遇到catch語句。
    指針是一個普通的變量,不是類對象,所以在清空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
    class StupidPointer {
    public:
    T *ptr;
    StupidPointer(T *p) : ptr(p) {}
    ~StupidPointer() { delete ptr; }
    };
    有了這個"酷炫"的類,現在我們的構造函數可以這麼寫:
    TooSimple() {
    a = StupidPointer(new Resource());
    b = StupidPointer(new Resource());
    };
    由於此時的a,已經不再是指針,而是StupidPointer類,在清空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位於頭文件中(這裏只講C++11,boost的shared_ptr當然是放在boost的頭文件中),下面我以代碼示例的形式展現它的用法,具體文檔可以看這裏。
    // 初始化
    shared_ptr x = shared_ptr(new int); // 這個方法有缺陷,下面我會說
    shared_ptr y = make_shared();
    shared_ptr bj = make_shared(arg1, arg2); // arg1, arg2是Resource構造函數的參數
    // 賦值
    shared_ptr 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> observers;
    public:
    Subject() {}
    addObserver(shared_ptr ob) {
    observers.push_back(ob);
    }
    // 其它代碼
    ..........
    };
    class Observer {
    private:
    shared_ptr object;
    public:
    Observer(shared_ptr obj) : object(obj) {}
    // 其它代碼
    ...........
    };
    目標(Subject)類連接着多個觀察者(Observer)類,當某個事件發生時,目標類可以遍歷觀察者數組observers,對每個觀察者進行"通知",而觀察者類中,也保存着目標類的shared_ptr,這樣多個觀察者之間可以以目標類爲橋樑進行溝通,除了會發生內存泄漏以外,這是很不錯的設計模式嘛!等等,不是說用了shared_ptr管理資源後就不會內存泄漏了嗎?怎麼又漏了?
    這就是引用計數模型失效的唯一的情況:循環引用。循環引用指的是,一個引用通過一系列的引用鏈,竟然引用回自身,上面的例子中,Subject->Observer->Subject就是這麼一條環形的引用鏈。假設我們的程序中只有一個變量shared_ptr p,此時,p指向的對象不僅通過該shared_ptr引用自己,還通過它包含的Observer中的object成員變量引用回自己,於是它的引用計數是2,每個Observer的引用計數都是1。當p析構時,它的引用計數減1,變成2-1=1(大於0!),p指向對象的析構函數將不會被調用,於是p和它包含的每個Observer對象在程序結束時依然駐留在內存中沒被delete,形成內存泄漏。
    weak_ptr
    爲了解決這一問題,標準庫提供了std::weak_ptr(弱引用),它也位於中。
    weak_ptr是shared_ptr的"觀察者",它與一個shared_ptr綁定,但卻不參與引用計數的計算,在需要時,它還能搖身一變,生成一個與它所"觀察"的shared_ptr共享引用計數器的新shared_ptr。總而言之,weak_ptr的作用就是:在需要時變出一個shared_ptr,在其它時候不干擾shared_ptr的引用計數。
    在上面的例子中,我們只需簡單地將Observer中object成員的類型換成std::weak_ptr即可解決內存泄漏的問題,此刻(接着上面的例子),p指向對象的引用計數爲1,所以在p析構時,Subject指針將被delete,其中包含的observers數組在析構時,內部的Observer對象的引用計數也將變爲0,故它們也被delete了,資源釋放得乾乾淨淨。
    下面,是weak_ptr的使用方法:
    std::shared_ptr sh = std::make_shared();
    // 用一個shared_ptr初始化
    std::weak_ptr w(sh);
    // 變出shared_ptr
    std::shared_ptr another = w.lock();
    // 判斷weak_ptr所觀察的shared_ptr的資源是否已經釋放
    bool isDeleted = w.expired();
    錯誤用法2:多個無關的shared_ptr管理同一裸指針
    考慮下面這個情況:
    int *a = new int;
    std::shared_ptr p1(a);
    std::shared_ptr 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 getShared() {
    return std::shared_ptr(this);
    }
    };
    int main() {
    std::shared_ptr pa = std::make_shared();
    std::shared_ptr 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
    class enable_shared_from_this {
    public:
    shared_ptr shared_from_this();
    }
    如果想要一個由shared_ptr管理的類A對象能夠在方法內部得到this指針的shared_ptr,且返回的shared_ptr和管理這個類的shared_ptr共享引用計數,只需讓這個類派生自enable_shared_from_this即可,之後調用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 {
    // ......
    };
    int main() {
    std::shared_ptr pa = std::make_shared();
    std::shared_ptr 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 p1, shared_ptr p2);
    // 使用
    f(shared_ptr(new A), shared_ptr(new B));
    可惜,這麼寫依然有可能發生內存泄漏,因爲兩個shared_ptr的構造有可能發生在new A與new B之後,這涉及到C++裏稱作sequence after,或sequence point的性質,該性質保證:
    new A在shared_ptr構造之前發生
    new B在shared_ptr構造之前發生
    兩個shared_ptr的構造在f調用之前發生
    在滿足以上三條性質的前提下,各操作可以以任意順序執行。詳情請見Herb Shutter的文章:Exception-Safe Function Calls 。
    make_shared
    若我們這麼調用f:
    f(make_shared(), make_shared());
    那麼就不可能發生內存泄漏了,原因依然是sequence after性質。sequence after性質保證,如果兩個函數的執行順序不確定(如本例,作爲另一個函數的兩個參數),那麼在一個函數執行時,另一個不會執行(倘若參數是1+1和3 + 3*6這種表達式,那麼加法和乘法甚至允許交錯執行,sequence after性質真是有夠複雜),於是,如果make_shared構造完成了,make_shared中拋出異常,那麼A的資源能被正確釋放。與上面用new來初始化的情形對比,make_shared保證了第二new發生的時候,第一個new所分配的資源已經被shared_ptr管理起來了,故在異常發生時,能正確釋放資源。
    一句話建議:總是使用make_shared來生成shared_ptr!
發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章