RAII慣用法:C++資源管理的利器

bzhxuexi發表於2013-11-26

RAII慣用法:C++資源管理的利器

        RAII是指C++語言中的一個慣用法(idiom),它是“ResourceAcquisitionIsInitialization”的首字母縮寫。中文可將其翻譯為“資源獲取就是初始化”。雖然從某種程度上說這個名稱並沒有體現出該慣性法的本質精神,但是作為標準C++資源管理的關鍵技術,RAII早已在C++社群中深入人心。

我記得第一次學到RAII慣用法是在Bjarne Stroustrup的《C++程式設計語言(第3版)》一書中。當講述C++資源管理時,Bjarne這樣寫道:

使用區域性物件管理資源的技術通常稱為“資源獲取就是初始化”。這種通用技術依賴於建構函式和解構函式的性質以及它們與異常處理的互動作用。

Bjarne這段話是什麼意思呢?

首先讓我們來明確資源的概念,在計算機系統中,資源是數量有限且對系統正常運轉具有一定作用的元素。比如,記憶體,檔案控制程式碼,網路套接字(network sockets),互斥鎖(mutex locks)等等,它們都屬於系統資源。由於資源的數量不是無限的,有的資源甚至在整個系統中僅有一份,因此我們在使用資源時必須嚴格遵循的步驟是:

1.         獲取資源

2.         使用資源

3.         釋放資源

例如在下面的UseFile函式中:

void UseFile(char const* fn)
{
    FILE* f = fopen(fn,
"r");       //獲取資源
    //
在此處使用檔案控制程式碼f...         //使用資源
    fclose(f);                       //
釋放資源
}

呼叫fopen()開啟檔案就是獲取檔案控制程式碼資源,操作完成之後,呼叫fclose()關閉檔案就是釋放該資源。資源的釋放工作至關重要,如果只獲取而不釋放,那麼資源最終會被耗盡。上面的程式碼是否能夠保證在任何情況下都呼叫fclose函式呢?請考慮如下情況:

void UseFile(char const* fn)
{
    FILE* f = fopen(fn, "r");        //
獲取資源
    //
使用資源
    if (!g()) return;                //
如果操作g失敗!
    // ...
    if (!h()) return;                //
如果操作h失敗!
    // ...
    fclose(f);                       //
釋放資源
}

在使用檔案f的過程中,因某些操作失敗而造成函式提前返回的現象經常出現。這時函式UseFile的執行流程將變為:


 

很明顯,這裡忘記了一個重要的步驟:在操作gh失敗之後,UseFile函式必須首先呼叫fclose()關閉檔案,然後才能返回其呼叫者,否則會造成資源洩漏。因此,需要將UseFile函式修改為:

void UseFile(char const* fn)
{
    FILE* f = fopen(fn, "r");        //
獲取資源
    //
使用資源
    if (!g()) { fclose(f); return; }
    // ...
    if (!h()) { fclose(f); return; }
    // ...
    fclose(f);                       //
釋放資源
}

現在的問題是:用於釋放資源的程式碼fclose(f)需要在不同的位置重複書寫多次。如果再加入異常處理,情況會變得更加複雜。例如,在檔案f的使用過程中,程式可能會丟擲異常:

void UseFile(char const* fn)
{
    FILE* f = fopen(fn, "r");        //
獲取資源
    //
使用資源
    try {
        if (!g()) { fclose(f); return; }
        // ...
        if (!h()) { fclose(f); return; }
        // ...
    }
    catch (...) {
        fclose(f);                   //
釋放資源
        throw;
    }
    fclose(f);                       //
釋放資源
}

我們必須依靠catch(...)來捕獲所有的異常,關閉檔案f,並重新丟擲該異常。隨著控制流程複雜度的增加,需要新增資源釋放程式碼的位置會越來越多。如果資源的數量還不止一個,那麼程式設計師就更加難於招架了。可以想象這種做法的後果是:程式碼臃腫,效率下降,更重要的是,程式的可理解性和可維護性明顯降低。是否存在一種方法可以實現資源管理的自動化呢?答案是肯定的。假設UseResources函式要用到n個資源,則進行資源管理的一般模式為:

void UseResources()
{
    //
獲取資源1
    // ...
    //
獲取資源n
   
    //
使用這些資源
   
    //
釋放資源n
    // ...
    //
釋放資源1
}

不難看出資源管理技術的關鍵在於:要保證資源的釋放順序與獲取順序嚴格相反。這自然使我們聯想到區域性物件的建立和銷燬過程。在C++中,定義在棧空間上的區域性物件稱為自動儲存(automatic memory)物件。管理區域性物件的任務非常簡單,因為它們的建立和銷燬工作是由系統自動完成的。我們只需在某個作用域(scope)中定義區域性物件(這時系統自動呼叫建構函式以建立物件),然後就可以放心大膽地使用之,而不必擔心有關善後工作;當控制流程超出這個作用域的範圍時,系統會自動呼叫解構函式,從而銷燬該物件。

讀者可能會說:如果系統中的資源也具有如同區域性物件一樣的特性,自動獲取,自動釋放,那該有多麼美妙啊!。事實上,您的想法已經與RAII不謀而合了。既然類是C++中的主要抽象工具,那麼就將資源抽象為類,用區域性物件來表示資源,把管理資源的任務轉化為管理區域性物件的任務。這就是RAII慣用法的真諦!可以毫不誇張地說,RAII有效地實現了C++資源管理的自動化。例如,我們可以將檔案控制程式碼FILE抽象為FileHandle類:

class FileHandle {
public:
    FileHandle(char const* n, char const* a) { p = fopen(n, a); }
    ~FileHandle() { fclose(p); }
private:
    //
禁止拷貝操作
    FileHandle(FileHandle const&);
    FileHandle& operator= (FileHandle const&);
    FILE *p;
};

FileHandle類的建構函式呼叫fopen()獲取資源;FileHandle類的解構函式呼叫fclose()釋放資源。請注意,考慮到FileHandle物件代表一種資源,它並不具有拷貝語義,因此我們將拷貝建構函式和賦值運算子宣告為私有成員。如果利用FileHandle類的區域性物件表示檔案控制程式碼資源,那麼前面的UseFile函式便可簡化為:

void UseFile(char const* fn)
{
    FileHandle file(fn,
"r");
    //
在此處使用檔案控制程式碼f...
    //
超出此作用域時,系統會自動呼叫file的解構函式,從而釋放資源
}

現在我們就不必擔心隱藏在程式碼之中的return語句了;不管函式是正常結束,還是提前返回,系統都必須“乖乖地”呼叫f的解構函式,資源一定能被釋放。Bjarne所謂“使用區域性物件管理資源的技術……依賴於建構函式和解構函式的性質”,說的正是這種情形。

且慢!如若使用檔案file的程式碼中有異常丟擲,難道解構函式還會被呼叫嗎?此時RAII還能如此奏效嗎?問得好。事實上,當一個異常丟擲之後,系統沿著函式呼叫棧,向上尋找catch子句的過程,稱為棧輾轉開解(stack unwinding)。C++標準規定,在輾轉開解函式呼叫棧的過程中,系統必須確保呼叫所有已建立起來的區域性物件的解構函式。例如:

void Foo()
{
    FileHandle file1(
"n1.txt","r");
    FileHandle file2(
"n2.txt","w");
    Bar();       //
可能丟擲異常
    FileHandle file3(
"n3.txt","rw")
}

Foo()呼叫Bar()時,區域性物件file1file2已經在Foo的函式呼叫棧中建立完畢,而file3卻尚未建立。如果Bar()丟擲異常,那麼file2file1的解構函式會被先後呼叫(注意:解構函式的呼叫順序與建構函式相反);由於此時棧中尚不存在file3物件,因此它的解構函式不會被呼叫。只有當一個物件的建構函式執行完畢之後,我們才認為該物件的建立工作已經完成。棧輾轉開解過程僅呼叫那些業已建立的物件的解構函式。

 

RAII慣用法同樣適用於需要管理多個資源的複雜物件。例如,Widget類的建構函式要獲取兩個資源:檔案myFile和互斥鎖myLock。每個資源的獲取都有可能失敗並且丟擲異常。為了正常使用Widget物件,這裡我們必須維護一個不變式(invariant):當呼叫建構函式時,要麼兩個資源全都獲得,物件建立成功;要麼兩個資源都沒得到,物件建立失敗。獲取了檔案而沒有得到互斥鎖的情況永遠不能出現,也就是說,不允許建立Widget物件的“半成品”。如果將RAII慣用法應用於成員物件,那麼我們就可以實現這個不變式:

class Widget {
public:
    Widget(char const* myFile, char const* myLock)
    : file_(myFile),     //
獲取檔案myFile
      lock_(myLock)      //
獲取互斥鎖myLock
    {}
    // ...
private:
    FileHandle file_;
    LockHandle lock_;
};

FileHandleLockHandle類的物件作為Widget類的資料成員,分別表示需要獲取的檔案和互斥鎖。資源的獲取過程就是兩個成員物件的初始化過程。在此係統會自動地為我們進行資源管理,程式設計師不必顯式地新增任何異常處理程式碼。例如,當已經建立完file_,但尚未建立完lock_時,有一個異常被丟擲,則系統會呼叫file_的解構函式,而不會呼叫lock_的解構函式。Bjarne所謂建構函式和解構函式與異常處理的互動作用”,說的就是這種情形。

綜上所述,RAII的本質內容是用物件代表資源,把管理資源的任務轉化為管理物件的任務,將資源的獲取和釋放與物件的構造和析構對應起來,從而確保在物件的生存期內資源始終有效,物件銷燬時資源必被釋放。換句話說,擁有物件就等於擁有資源,物件存在則資源必定存在。由此可見,RAII慣用法是進行資源管理的有力武器。C++程式設計師依靠RAII寫出的程式碼不僅簡潔優雅,而且做到了異常安全。難怪微軟的MSDN雜誌在最近的一篇文章中承認:“若論資源管理,誰也比不過標準C++”。

--------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------

相關文章