1. 程式人生 > >[C++]資源管理

[C++]資源管理

資源管理

所謂資源就是,一旦使用了它,將來必須歸還給系統!C++最常見的資源就是動態分配記憶體,如果不歸還就會記憶體洩露。

1. 以物件管理資源

我們通常希望有一個物件來幫助我們解決資源管理的問題(自動呼叫解構函式),於是此章我們討論auto_ptr和shared_ptr。

問題產生

假設我們希望使用一個工廠方法如:

class investment {...};  // 代表一個root class
investment* creatinvestment() {  //  返回一個指標指向繼承體系內動態分配的物件。
    ...
}
void f() {
    investment* pInv = createinvestment();
    ...
delete pInv; }

乍看起來這個函式並沒有什麼問題,正常的動態分配了物件,同時也刪除了該物件。但問題在於…中可能出現異常情況,例如,提前的return,異常丟擲,某個迴圈的continue等等,使得控制流無法讀到delete語句,如此便出現了記憶體洩露。所以“單純地以來f總是會執行其delete語句”是行不通的。

解決問題

為了解決這個問題,我們希望能把資源放入物件中,使得物件在離開其作用域時自動呼叫解構函式。於是,我們使用了auto_ptr。

void f() {
    std::auto_ptr<investment> pInv(createinvestment());
    ...
} // 經由auto_ptr的解構函式自動刪除pInv。

這個簡單的例子示範了“以物件管理資源”的兩個關鍵想法:
* 獲得資源後立刻放進管理物件內。此觀念常被稱為“資源取得時機便是初始化時機”(RAII準則),因為我們幾乎總是在取得一筆資源後同一語句內以它初始化某個物件。
* 管理物件運用解構函式確保資源被釋放。不管控制流如何離開作用域,一旦物件被銷燬,其解構函式自然會被自動呼叫,於是資源被釋放。

然而,使用auto_ptr也需要注意,一定不能讓多個auto_ptr指向同一個物件!因為每一個auto_ptr都會使用解構函式,他並不像智慧指標一樣會管理有多少指標指向同一物件。並且與copy相關的操作都會使原來的指標指向null,如此使物件只有一個auto_ptr指向。

基於auto_ptr的特性,我們便不能子啊STL容器中使用auto_ptr。auto_ptr

void f() {
    std::shared_ptr<investment> pInv(createinvestment());
    ...
}
//  經由shared_ptr的解構函式自動刪除pInv。

幾乎和auto_ptr一樣,但是相應的複製行為會正常了許多。

補充auto_ptr相關知識

C++的auto_ptr所做的事情,就是動態分配物件以及當物件不再需要時自動執行清理。
使用std::auto_ptr,要#include 。
以下是原始碼:

template<class T>
class auto_ptr
{
private:
    T*ap;
public:
    //constructor & destructor-----------------------------------(1)
    explicit auto_ptr(T*ptr=0)throw():ap(ptr)
    {
    }

    ~auto_ptr()throw()
    {
        delete ap;
    }
    //Copy & assignment--------------------------------------------(2)
    auto_ptr(auto_ptr& rhs)throw():ap(rhs.release())
    {
    }
    template<class Y>
    auto_ptr(auto_ptr<Y>&rhs)throw():ap(rhs.release())
    {
    }
    auto_ptr& operator=(auto_ptr&rhs)throw()
    {
        reset(rhs.release());
        return *this;
    }
    template<class Y>
    auto_ptr& operator=(auto_ptr<Y>&rhs)throw()
    {
        reset(rhs.release());
        return *this;
    }
    //Dereference----------------------------------------------------(3)
    T& operator*()const throw()
    {
        return *ap;
    }
    T* operator->()const throw()
    {
        return ap;
    }
    //Helper functions------------------------------------------------(4)
    //value access
    T* get()const throw()
    {
        return ap;
    }
    //release owner ship
    T* release()throw()
    {
        T* tmp(ap);
        ap = 0;
        return tmp;
    }
    //reset value
    void reset(T* ptr = 0)throw()
    {
        if(ap != ptr)
        {
            delete ap;
            ap = ptr;
        }
    }
    //Special conversions-----------------------------------------------(5)
    template<class Y>
    struct auto_ptr_ref
    {
        Y*yp;
        auto_ptr_ref(Y*rhs):yp(rhs){}
    };
    auto_ptr(auto_ptr_ref<T>rhs)throw():ap(rhs.yp)
    {
    }

    auto_ptr& operator=(auto_ptr_ref<T>rhs)throw()
    {
        reset(rhs.yp);
        return*this;
    }

    template<class Y>
    operator auto_ptr_ref<Y>()throw()
    {
        return auto_ptr_ref<Y>(release());
    }

    template<class Y>
    operator auto_ptr<Y>()throw()
    {
        return auto_ptr<Y>(release());
    }
};

shared_ptr

在資源管理類中小心copying行為

前面我們提到了RAII觀念,並以此作為“資源管理類”的記住,也描述了auto_ptr和shared_ptr如何將這個觀念表現在heap-based資源上。然而並非所有資源都是heap-based,對那種資源而言,我們就需要建立自己的資源管理類。(棧,由編譯器自動管理,無需程式設計師手工控制;堆:產生和釋放由程式設計師控制。)

問題產生

我們處理型別為Mutex的互斥器物件,同時使用一個class來管理它。

void lock(Mutex* pm);
void unlock(Mutex* pm);

class Lock {
public:
    explicit Lock(Mutex* pm) : mutexPtr(pm) {
        lock(mutexPtr);
    }
    ~Lock() { unlock(mutexPtr); }
private:
    Mutex *mutexPtr;
};

main () {
    Mutex m;
    ...
    {
    Lock m1(&m);
    ...
    Lock m11(&m);
    Lock m12(m11);  // 將m11複製到m12身上,會發生事?
    }
}

當一個RAII對被複制,會發生什麼事?以下是你的兩種選擇:
* 靜止複製。許多時候允許RAII物件被複制並不合理。對一個像Lock這樣的class卻是可能的,因為很少能夠合理擁有“同步化期初器物”的復件。如果複製對RAII class並不合理,就應該讓該class繼承uncopyable(見前文:)。
* 對底層資源使用“引用計數法”。有時候,我們希望保有資源,直到它的最後一個使用者被銷燬,這種情況下複製RAII物件,該資源的引用計數遞增。

問題解決

通常只要內含shared_ptr成員變數,就可以實現reference-counting copying行為。並且shared_ptr允許指定所謂的“刪除器”(deleter)(一個函式或函式物件),當引用次數為0時便被呼叫,而不是執行解構函式。刪除器對shared_ptr建構函式來說是可有可無的第二引數。

class Lock {
public:
    explicit Lock(Mutex* pm) : mutexPtr(pm, unlock) {
        lock(mutexPtr.get()); // 隨後會討論get
    }
private:
    shared_ptr<Mutex> mutexPtr;
}

此處不再需要宣告解構函式,因為沒有必要。因為class解構函式會自動呼叫其non-static成員變數的解構函式,也就是此處的刪除器函式。

資源複製的原則

  • 複製底部資源。有時候,只要你喜歡,可以針對一份資源擁有其任意數量的複製(副本)。而你需要“資源管理類”的唯一理由是,當你不再需要某個復件時確保它被釋放。在此情況下複製資源管理物件,應該同時也複製其所包括的資源,也就是進行“深拷貝”。
  • 轉移底部資源的擁有權。某些罕見場合下,你可以希望確保永遠只有一個RAII物件指向一個未加工資源,即使是RAII物件被複制依然如此,此時,資源的擁有權會從被複制物轉移到目標物。就像auto_ptr的複製意義。

3. 在資源管理類中提供對原始資源的訪問

API往往要求訪問原始資源,所以每一個RAII class應該提供一個“取得其所管理之資源”的方法。

問題產生

在#1中我們提到了以下factory函式

std::shared_ptr<investment> pInv = createinvestment();

假設我們希望以某個函式處理investment物件:

int daysHeld(const investment* pi); //  返回投資天數

int days = daysHeld(pInv);  
// error!!! 型別不匹配!函式需要investment指標,而你傳給它型別為shared_ptr的物件。

問題解決

這時候我們需要一個函式可將RAII class物件轉換為其所內含之原始資源。以下提供兩種方法:

顯示轉換

shared_ptr和auto_ptr都提供了一個get函式用來執行顯示轉換。

int days = daysHeld(pInv.get());  

同樣的,就像幾乎所有智慧指標一樣,shared_ptr和auto_ptr都過載了指標取值的操作符operator -> and operator*。

隱式轉換

提供一種過載操作符,完成隱式轉換。類似於以下程式碼:

class type {
public:
    type(int i) : a(i) {
    }
    operator int() {
        return a;
    }
private:
    int a;
};
int main(int argc, const char * argv[]) {
    type a(10);
    std::cout << a << std::endl;
    return 0;
}

總結

是否應該提供一個顯式轉換函式將RAII class轉換為其底部資源,或者應該提供隱式轉換,取決於RAII class被設計執行的特定工作,以及它被使用的情況。最佳的設計很可能是堅持“讓介面容易被正確使用,不易被誤用”。通常顯式轉換函式如get是比較好的,因為它將“非故意之型別轉換”的可能性最小化了。

4. 成對使用new和delete時要採取相同形式

當你使用new時,有兩件事情發生:1)記憶體被分配出來。2)針對此記憶體會有一個或多個建構函式被呼叫。同樣的,當你使用delete時,也有兩件事情發生:1)針對此記憶體會有一個或多個解構函式被呼叫。2)記憶體被釋放。但問題出來了,即將被刪除的記憶體之內究竟有多少個物件?這決定了有多少個解構函式被呼叫。

因為單一物件的記憶體分配和陣列的記憶體分配是不一樣的,所以每當我們使用delete的時候要告訴編譯器,我們使用的是單一物件還是陣列。方法就是加上[]!

此規則對於喜歡使用typedef的人十分重要,這意味著作者必須說清楚typedef是什麼,要用什麼型別的delete。

typedef std::string AddressLines[4];

std::string* pal = new AddressLines;

delete [] pal; // that is right! But user may misunderstand.

為了避免諸如此類的錯誤,最好不要對陣列形式採用typedef動作,而是使用vector,string等template來替換陣列。

5. 以獨立語句將new物件直接放入智慧指標

我們無法知道編譯器會按照什麼樣的順序來執行一條程式碼語句,而這其中可能隱含著某些異常。

問題產生

假設我們有個函式來揭示處理程式的優先權,另一個函式用來在某動態分配所得的Widget上進行某些帶有優先權的處理。

// function definition:
int priority();
void processWidget(std::shared_prt<Widget> pw, int priority);

// function reference:
processWidget(std::shared_ptr<Widget> (new Widget), priority());
// be cautious!
// shared_ptr建構函式需要一個原始指標,但該建構函式是個explicit建構函式
// 無法進行隱式轉換,所以需要顯示的寫出轉換。

問題發生得很隱蔽!

我們本以為把一個物件放入”物件資源管理器“就可以有效的解決資源洩露的問題,但其實在這裡隱含危機。問題出在編譯器執行順序上,如果編譯器按照以下順序:
1. 執行new Widget
2. 呼叫priority
3. 呼叫shared_ptr建構函式
而此時,priority的呼叫導致異常,那麼new Widget返回的指標就會遺失,導致記憶體洩露。(幸福來得太突然)

問題解決

此問題的解決非常的簡單。既然編譯器有機會因為程式碼執行順序不同而出錯,那麼我們就強行讓他按照我們預想的順序執行就可以了~

std::shared_ptr<Widget> pw(new Widget);
processWidget(pw, priority());

因為編譯器對於“跨越語句的各項操作”沒有重新排列的自由!所以此程式碼不會出現問題!

所以,儘可能單獨地把new物件直接放入智慧指標中總是有道理的。

6. 區別堆和棧

  1. 管理方式不同
    棧,由編譯器自動管理,無需程式設計師手工控制;堆:產生和釋放由程式設計師控制。
  2. 空間大小不同
    棧的空間有限;堆記憶體可以達到4G。
  3. 能否產生碎片不同
    棧不會產生碎片,因為棧是種先進後出的佇列。堆則容易產生碎片,多次的new/delete
    會造成記憶體的不連續,從而造成大量的碎片。
  4. 生長方向不同
    堆的生長方式是向上的,棧是向下的。
  5. 分配方式不同
    堆是動態分配的。棧可以是靜態分配和動態分配兩種,但是棧的動態分配由編譯器釋放。
  6. 分配效率不同
    棧是機器系統提供的資料結構,計算機底層對棧提供支援:分配專門的暫存器存放棧的地址,壓棧出棧都有專門的指令。堆則是由C/C++函式庫提供,庫函式會按照一定的演算法在堆記憶體中搜索可用的足夠大小的空間,如果沒有足夠大小的空間(可能是由於記憶體碎片太多),就有可能呼叫系統功能去增加程式資料段的記憶體空間,這樣就有機會分到足夠大小的記憶體,然後進行返回。顯然,堆的效率比棧要低得多。

堆和棧相比,由於大量new/delete的使用,容易造成大量的記憶體碎片;由於沒有專門的系統支援,效率很低;由於可能引發使用者態和核心態的切換,記憶體的申請,代價變得更加昂貴。所以棧在程式中是應用最廣泛的,就算是函式的呼叫也利用棧去完成,函式呼叫過程中的引數,返回地址,EBP和區域性變數都採用棧的方式存放。所以,我們推薦大家 儘量用棧,而不是用堆。

棧和堆相比不是那麼靈活,有時候分配大量的記憶體空間,還是用堆好一些。

無論是堆還是棧,都要防止越界現象的發生。

總結

對於C++這種技巧較高的語言來說,資源管理從來都不會是一個簡單的事情,所以掌握一定的資源管理方法總是有用的!此部落格根據《effective c++》總結了資源管理的幾條準則,附加少量自己的補充。