物件導向程式設計(C++篇4)——RAII

charlee44發表於2022-03-27

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++

上一篇
目錄
下一篇

相關文章