面向對象編程(C++篇4)——RAII

1. 概述

在前面兩篇文章《面向對象編程(C++篇2)——構造》《面向對象編程(C++篇3)——析構》中,我們論述了C++面向對象中一個比較好的實現,在構造函數中申請動態內存,在析構函數中進行釋放。通過這種方式,我們可以實現類對象如何內置數據類型對象一樣,自動實現對象的生命週期管理。

其實這個設計早就被c++之父Bjarne Stroustrup提出,叫做RAII(Resource Acquisition Is Initialization),中文的意思就是資源獲取即初始化。前文所述的動態內存只是資源的一種,比如說文件的打開與關閉、windows中句柄的獲取與釋放等等。RAII這個名字取得比較隨意,但是這個技術可以說是C++的基石,決定了C++資源管理的方方面面。

2. 詳論

2.1. 堆、棧、靜態區

更爲深入的講,RAII其實利用的其實程序中棧的特性,實現了對資源的自動管理。我們知道,一般程序中會分成三個內存區域:

  1. 靜態內存:用來保存局部static對象,類static數據成員以及任何定義在任何函數之外的變量。
  2. 棧內存:用來保存定義在函數內的非static對象。
  3. 堆內存:用來存儲動態分配的對象,例如通過new、malloc等申請的內存對象。

對於分配在靜態內存中的對象和棧內存中的對象,其生命週期由編譯器自動創建和銷燬。而對於堆內存,生存週期由程序顯式控制,使用完畢後需要使用delete來釋放。我們通過分配在棧中的類對象的RAII機制,來管理分配在堆空間中的內存:

class ImageEx
{
public:
    ImageEx()
    {
        cout << "Execute the constructor!" << endl;
        data = new unsigned char[10];
    }

    ~ImageEx()
    {
        Release();
        cout << "Execute the destructor!" << endl;
    }

private:
    unsigned char * data;

    void Release()
    {
        delete[] data;
        data = nullptr;
    }
};

int main()
{
    {
        ImageEx imageEx;       
    }

    return 0;
}

很顯然,根據程序棧內存的要求,一旦ImageEx對象離開作用域,就會自動調用析構函數,從而實現了對資源的自動管理。

2.2. 手動管理資源的弊端

遠古C/C++需要程序員自己遵循"誰申請,誰釋放"的原則細緻地管理資源。但實際上,這麼做並不是總是能避免內存泄漏的問題。一個很常見的例子如下(這是一個“對圖像中的像素進行處理”的函數ImageProcess()):

int doSomething(int* p) 
{
    return -1;
}

bool ImageProcess()
{
    int* data = new int[16];
    int error = doSomething(data);
    if (error) 
    {
        delete data; 
        data = nullptr;
        return false;
    }

    delete data;
    data = nullptr;
    return true;
}

爲了避免內存泄漏,我們必須在這個函數中任何可能出錯並返回之前的地方進行釋放內存的操作。這樣做無疑是低效的。而通過RAII技術改寫如下:

int doSomething(ImageEx& imageEx)
{
    return -1;
}

bool ImageProcess()
{    
    ImageEx imageEx; 
    if (doSomething(imageEx))
    {
        return false;
    }

    return true;
}

這時我們可以完全不用關心動態內存資源釋放的問題,因爲類對象在超出作用域之後,就調用析構函數自動把申請的動態內存釋放掉了。無論從代碼量還是編程效率來說,都得到了巨大的提高。

2.3. 間接使用

可以確定地是,無論使用何種的釋放內存資源的操作(delete、析構函數以及普通釋放資源的函數),都會給程序員帶來心智負擔,最好不要手動進行釋放內存資源的操作,如果能交給程序自動管理就好了。對此,現代C++給出地解決方案就是RAII。

在現代C++中,動態內存推薦使用智能指針類型(shared_ptr、unique_ptr、weak_ptr)來管理動態內存對象。智能指針採用了reference count(引用計數)的RAII,對其指向的內存資源做動態管理,當reference count爲0時,就會自動釋放其指向的內存對象。

而對於動態數組,現代C++更推薦使用stl容器尤其是std::vector容器。std::vector容器是一個模板類,也是基於RAII實現的,其申請的內存資源同樣也會在超出作用域後自動析構。

因此,使用智能指針和stl容器,也就是間接的使用了RAII,是我們可以不用再關心釋放資源的問題。

2.4. 自下而上的抽象

當然,實際的情況可能並不會那麼好。在程序的底層可能仍然有一些資源需要管理,或者需要接入第三方的庫(尤其是C庫),他們依然是手動管理內存,而且可能我們用不了智能指針或者stl容器。但是我們仍然可以使用RAII,逐級向上抽象封裝,例如:

class ImageEx
{
public:
    ImageEx()
    {
        cout << "Execute the constructor!" << endl;
        data = new unsigned char[10];
    }

    ~ImageEx()
    {
        Release();
        cout << "Execute the destructor!" << endl;
    }

private:
    unsigned char* data;

    void Release()
    {
        delete[] data;
        data = nullptr;
    }
};

class Texture
{
public:
    Texture() = default;

private:
    ImageEx imageEx;
};

int main()
{
    {
        Texture texture;
    }

    return 0;
}

可以認爲ImageEx是底層類,需要進行動態內存管理而無法使用std::vector,那麼我們對其採用RAII進行管理;Texture是高級類,內部有ImageEx數據成員。此時我們可以發現,Texture類已經無需再進行顯示析構了,Texture在離開作用域時會自動銷燬ImageEx數據成員,調用其析構函數。也就是說,Texture對象已經徹底無需關心內存資源釋放的問題。

那麼可以得出一個結論:對於底層無法使用智能指針或者stl容器自動管理資源的情況,最多隻要一層的底層類採用RAII設計,那麼其高層次的類就無需再進行顯示析構管理了。這樣一個完美無瑕的世界就出現了:程序員確實自己管理了資源,但無需任何代價,或者只付出了微小的代價(實在需要手動管理資源時採用RAII機制),使得這個管理是自動化的。程序員可以像有GC(垃圾回收)機制的編程語言那樣,任意的申請資源而無需關心資源釋放的問題。

3. 總結

無論對於哪一門編程語言來說,資源管理都是個很嚴肅的話題。對於資源管理,現代C++給出的答案就是RAII。通過該技術,減少了內存泄漏的可能行,以及手動管理資源的心智負擔。同時自動化管理資源,也保障了性能需求。當然,這也是C++"零成本抽象(zero overhead abstraction)"的設計哲學的體現。

4. 參考

  1. C++中的RAII介紹
  2. RAII:如何編寫沒有內存泄漏的代碼 with C++

上一篇
目錄
下一篇

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章