天天看點

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

RAII是指C++語言中的一個慣用法(idiom),它是“Resource Acquisition Is Initialization”的首字母縮寫。中文可将其翻譯為“資源擷取就是初始化”。雖然從某種程度上說這個名稱并沒有展現出該慣性法的本質精神,但是作為标準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的執行流程将變為:

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

很明顯,這裡忘記了一個重要的步驟:在操作g或h失敗之後,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()時,局部對象file1和file2已經在Foo的函數調用棧中建立完畢,而file3卻尚未建立。如果Bar()抛出異常,那麼file2和file1的析構函數會被先後調用(注意:析構函數的調用順序與構造函數相反);由于此時棧中尚不存在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_;

};

FileHandle和LockHandle類的對象作為Widget類的資料成員,分别表示需要擷取的檔案和互斥鎖。資源的擷取過程就是兩個成員對象的初始化過程。在此系統會自動地為我們進行資源管理,程式員不必顯式地添加任何異常處理代碼。例如,當已經建立完file_,但尚未建立完lock_時,有一個異常被抛出,則系統會調用file_的析構函數,而不會調用lock_的析構函數。Bjarne所謂構造函數和析構函數“與異常處理的互動作用”,說的就是這種情形。

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

不得不說,這篇文章寫得太好了,轉自:http://www.cnblogs.com/hsinwang/articles/214663.html

繼續閱讀