RAII:在類的建構函式中分配資源,在解構函式中釋放資源

bzhxuexi發表於2013-11-26

                                                      RAII介紹及例項分析:
  動物都會攝取食物,吸收其中的營養,用於自身生長和活動。然而,並非食物中所有的物質都能為動物所吸收。那些無法消化的物質,通過消化道的另一頭(某些動物消化道只有一頭)排出體外。不過,一種動物無法消化的排洩物,是另一種動物(生物)的食物,後者可以從中攝取所需的營養。
    一門程式語言,對於程式設計師而言,如同食物那樣,包含著所需的養分。當然也包含著無法消化的東西。不同的是,隨著程式設計師不斷成長,會逐步消化過去無法消化的那些東西。
    C++可以看作一種成分複雜的食物,對於多數程式設計師而言,是無法完全消化的。正因為如此,很多程式設計師認為C++太難以消化,不應該去吃它。但是,C++的營養不可謂不豐富,就此捨棄,而不加利用,則是莫大的罪過。好在食物可以通過加工,變得易於吸收,比如說發酵。鑑於程式設計師們的消化能力的差異,也為了讓C ++的營養能夠造福他人,我就暫且扮演一回酵母菌,把C++的某些營養單獨提取出來,並加以分解,讓那些消化能力不太強的程式設計師也能享受它的美味。:)
    (為了讓這些營養便於消化,我將會用C#做一些案例。選擇C#的原因很簡單,因為我熟悉。:))

RAII

    RAII,好古怪的營養啊!它的全稱應該是“Resource Acquire Is Initial”。這是C++創始人Bjarne Stroustrup發明的詞彙,比較令人費解。說起來,RAII的含義倒也不算複雜。用白話說就是:在類的建構函式中分配資源,在解構函式中釋放資源。這樣,當一個物件建立的時候,建構函式會自動地被呼叫;而當這個物件被釋放的時候,解構函式也會被自動呼叫。於是乎,一個物件的生命期結束後將會不再佔用資源,資源的使用是安全可靠的。
    下面便是在C++中實現RAII的典型程式碼:
        class file
        {
        
public:
            file(
string const& name) {
                   m_fileHandle
=open_file(name.cstr());
            }
            
~file() {
                   close_file(m_fileHandle);
            }
            ...
        
private:
            handle m_fileHandle;
        }
    很典型的“在建構函式裡獲取,在解構函式裡釋放”。如果我寫下程式碼:       
        void fun1() {
            file myfile(
"my.txt");
            ... 
//操作檔案
        }
    //此處銷燬物件,呼叫解構函式,釋放資源
    當函式結束時,區域性物件myfile的生命週期也結束了,解構函式便會被呼叫,資源會得到釋放。而且,如果函式中的程式碼丟擲異常,那麼解構函式也會被呼叫,資源同樣會得到釋放。所以,在RAII下,不僅僅資源安全,也是異常安全的。
    但是,在如下的程式碼中,資源不是安全的,儘管我們實現了RAII:
         void fun2() {
             file pfile
=new file("my.txt");
                ... 
//操作檔案
         }
    因為我們在堆上建立了一個物件(通過new),但是卻沒有釋放它。我們必須運用delete操作符顯式地加以釋放:
        void fun3() {
             file pfile
=new file("my.txt");
                ... 
//操作檔案
                delete pfile;
        }
    否則,非但物件中的資源得不到釋放,連物件本身的記憶體也得不到回收。(將來,C++的標準中將會引入GC(垃圾收集),但正如下面分析的那樣,GC依然無法確保資源的安全)。
    現在,在fun3(),資源是安全的,但卻不是異常安全的。因為一旦函式中丟擲異常,那麼delete pfile;這句程式碼將沒有機會被執行。C++領域的諸位大牛們告誡我們:如果想要在沒有GC的情況下確保資源安全和異常安全,那麼請使用智慧指標:
        void fun4() {
              shared_ptr
<file> spfile(new file("my.txt"));
              ... 
//操作檔案
        }
  //此處,spfile結束生命週期的時候,會釋放(delete)物件
    那麼,智慧指標又是怎麼做到的呢?下面的程式碼告訴你其中的把戲(關於智慧指標的更進一步的內容,請參考std::auto_ptr,boost或tr1的智慧指標):
        template<typename T>
        
class smart_ptr
        
{
        
public:
            smart_ptr(T
* p):m_ptr(p) {}
            
~smart_ptr() { delete m_ptr; }
            ...
        
private:
            T
* m_ptr;
        }
    沒錯,還是RAII。也就是說,智慧指標通過RAII來確保記憶體資源的安全,也間接地使得物件上的RAII得到實施。不過,這裡的RAII並不是十分嚴格:物件(所佔的記憶體也是資源)的建立(資源獲取)是在建構函式之外進行的。廣義上,我們也把它劃歸RAII範疇。但是,Matthew Wilson在《Imperfect C++》一書中,將其獨立出來,稱其為RRID(Resource Release Is Destruction)。RRID的實施需要在類的開發者和使用者之間建立契約,採用相同的方法獲取和釋放資源。比如,如果在shared_ptr構造時使用malloc(),便會出現問題,因為shared_ptr是通過delete釋放物件的。
    對於內建了GC的語言,資源管理相對簡單。不過,事情並非總是這樣。下面的C#程式碼摘自MSDN Library的C#程式設計指南,我略微改造了一下:
        static void CodeWithoutCleanup()
        
{
            System.IO.FileStream file 
= null;
            System.IO.FileInfo fileInfo 
= new System.IO.FileInfo("C:file.txt");
            file 
= fileInfo.OpenWrite();
            file.WriteByte(
0xF);
        }
    那麼資源會不會洩漏呢?這取決於物件的實現。如果通過OpenWrite()獲得的FileStream物件,在解構函式中執行了檔案的釋放操作,那麼資源最終不會洩露。因為GC最終在執行GC操作的時候,會呼叫Finalize()函式(C#類的解構函式會隱式地轉換成Finalize()函式的過載)。這是由於C#使用了引用語義(嚴格地講,是對引用型別使用引用語義),一個物件實際上不是物件本身,而是物件的引用。如同C++中的那樣,引用在離開作用域時,是不會釋放物件的。否則,便無法將一個物件直接傳遞到函式之外。在這種情況下,如果沒有顯式地呼叫Close()之類的操作,資源將不會得到立刻釋放。但是像檔案、鎖、資料庫連結之類屬於重要或稀缺的資源,如果等到GC執行回收,會造成資源不足。更有甚者,會造成程式碼執行上的問題。我曾經遇到過這樣一件事:我執行了一個sql操作,獲得一個結果集,然後執行下一個sql,結果無法執行。這是因為我使用的SQL Server 2000不允許在一個資料連線上同時開啟兩個結果集(很多資料庫引擎都是這樣)。第一個結果集用完後沒有立刻釋放,而GC操作則尚未啟動,於是便造成在一個未關閉結果集的資料連線上無法執行新的sql的問題。
    所以,只要涉及了記憶體以外的資源,應當儘快釋放。(當然,如果記憶體能夠儘快釋放,就更好了)。對於上述CodeWithoutCleanup()函式,應當在最後呼叫file物件上的Close()函式,以便釋放檔案:
        static void CodeWithoutCleanup()
        
{
            System.IO.FileStream file 
= null;
            System.IO.FileInfo fileInfo 
= new System.IO.FileInfo("C:file.txt");
            file 
= fileInfo.OpenWrite();
            file.WriteByte(
0xF);
            file.Close();
        }
    現在,這個函式是嚴格資源安全的,但卻不是嚴格異常安全的。如果在檔案的操作中丟擲異常,Close()成員將得不到呼叫。此時,檔案也將無法及時關閉,直到GC完成。為此,需要對異常作出處理:
        static void CodeWithCleanup()
        
{
            System.IO.FileStream file 
= null;
            System.IO.FileInfo fileInfo 
= null;
            
try
            
{
                fileInfo 
= new System.IO.FileInfo("C:file.txt");
                file 
= fileInfo.OpenWrite();
                file.WriteByte(
0xF);
            }

            
catch(System.Exception e)
            
{
                System.Console.WriteLine(e.Message);
            }

            
finally
            
{
                
if (file != null)
                
{
                    file.Close();
                }

            }

       }
    try-catch-finally是處理這種情況的標準語句。但是,相比前面的C++程式碼fun1()和fun4()繁瑣很多。這都是沒有RAII的後果啊。下面,我們就來看看,如何在C#整出RAII來。
    一個有效的RAII應當包含兩個部分:構造/解構函式的資源獲取/釋放和確定性的解構函式呼叫。前者在C#中不成問題,C#有建構函式和解構函式。不過, C#的建構函式和解構函式是不能用於RAII的,原因一會兒會看到。正確的做法是讓一個類實現IDisposable介面,在IDisposable:: Dispose()函式中釋放資源:
        class RAIIFile : IDisposable
        
{
        
public RAIIFile(string fn) {
                System.IO.FileInfo fileInfo 
= new System.IO.FileInfo(fn);
                file 
= fileInfo.OpenWrite();
            }


        
public void Dispose() {
                  file.Close();
              }


        
private System.IO.FileStream file = null;
        }
    下一步,需要確保檔案在退出作用域,或發生異常時被確定性地釋放。這項工作需要通過C#的using語句實現:
        static void CodeWithRAII()
        
{
            
using(RAIIFile file=new RAIIFile("C:file.txt"))
            
{
                ... 
//操作檔案
            }
 //檔案釋放
        }
    一旦離開using的作用域,file.Dispose()將被呼叫,檔案便會得到釋放,即便丟擲異常,亦是如此。相比CodeWithCleanup ()中那坨雜亂繁複的程式碼,CodeWithRAII()簡直可以算作賞心悅目。更重要的是,程式碼的簡潔和規則將會大幅減少出錯可能性。值得注意的是 using語句只能作用於實現IDisposable介面的類,即便實現了解構函式也不行。所以對於需要得到RAII的類,必須實現 IDisposable。通常,凡是涉及到資源的類,都應該實現這個介面,便於日後使用。實際上,.net庫中的很多與非記憶體資源有關的類,都實現了 IDisposable,都可以利用using直接實現RAII。
    但是,還有一個問題是using無法解決的,就是如何維持類的成員函式的RAII。我們希望一個類的成員物件在該類例項建立的時候獲取資源,而在其銷燬的時候釋放資源:
        class X
        
{
        
public:
            X():m_file(
"c:file.txt"{}
        
private:
            File m_file;    
//在X的例項析構時呼叫File::~File(),釋放資源。
        }
    但是在C#中無法實現。由於uing中例項化的物件在離開using域的時候便釋放了,無法在建構函式中使用:
        class X
        
{
            
public X() {
                
using(m_file=new RAIIFile("C:file.txt"))
                
{
                }
//此處m_file便釋放了,此後m_file便指向無效資源
            }

            pravite RAIIFile m_file;
        }
    對於成員物件的RAII只能通過在解構函式或Dispose()中手工地釋放。我還沒有想出更好的辦法來。
    至此,RAII的來龍去脈已經說清楚了,在C#裡也能從中汲取到充足的養分。但是,這還不是RAII的全部營養,RAII還有更多的擴充套件用途。在《Imperfect C++》一書中,Matthew Wilson展示了RAII的一種非常重要的應用。為了不落個鸚鵡學舌的名聲,這裡我給出一個真實遇到的案例,非常簡單:我寫的程式需要響應一個Grid 控制元件的CellTextChange事件,執行一些運算。在響應這個事件(執行運算)的過程中,不能再響應同一個事件,直到處理結束。為此,我設定了一個標誌,用來控制事件響應:
        class MyForm
        
{
        
public:
            MyForm():is_cacul(
false{}
            ...
            
void OnCellTextChange(Cell& cell) {
                
if(is_cacul)
                    
return;
                is_cacul
=true;
                ... 
//執行計算任務
                is_cacul=false;
            }

        
private:
            
bool is_cacul;
        }
;
    但是,這裡的程式碼不是異常安全的。如果在執行計算的過程中丟擲異常,那麼is_cacul標誌將永遠是true。此後,即便是正常的 CellTextChange也無法得到正確地響應。同前面遇到的資源問題一樣,傳統上我們不得不求助於try-catch語句。但是如果我們運用 RAII,則可以使得程式碼簡化到不能簡化,安全到不能再安全。我首先做了一個類:
        class BoolScope
        
{
        
public:
            BoolScope(
bool& val, bool newVal)
                :m_val(val), m_old(val) 
{
                m_val
=newVal;
            }

            
~BoolScope() {
                m_val
=m_old;
            }


        
private:
            
bool& m_val;
            
bool m_old;
        }
;
    這個類的作用是所謂“域守衛(scoping)”,建構函式接受兩個引數:第一個是一個bool物件的引用,在建構函式中儲存在m_val成員裡;第二個是新的值,將被賦予傳入的那個bool物件。而該物件的原有值,則儲存在m_old成員中。解構函式則將m_old的值返還給m_val,也就是那個 bool物件。有了這個類之後,便可以很優雅地獲得異常安全:
        class MyForm
        
{
        
public:
            MyForm():is_cacul(
false{}
            ...
            
void OnCellTextChange(Cell& cell) {
                
if(is_cacul)
                    
return;
                BoolScope bs_(is_cacul, 
true);
                ... 
//執行計算任務
            }

        
private:
            
bool is_cacul;
        }
;
    好啦,任務完成。在bs_建立的時候,is_cacul的值被替換成true,它的舊值儲存在bs_物件中。當OnCellTextChange()返回時,bs_物件會被自動析構,解構函式會自動把儲存起來的原值重新賦給is_cacul。一切又都回到原先的樣子。同樣,如果異常丟擲,is_cacul 的值也會得到恢復。
    這個BoolScope可以在將來繼續使用,分攤下來的開發成本幾乎是0。更進一步,可以開發一個通用的Scope模板,用於所有型別,就像《Imperfect C++》裡的那樣。
    下面,讓我們把戰場轉移到C#,看看C#是如何實現域守衛的。考慮到C#(.net)的物件模型的特點,我們先實現引用型別的域守衛,然後再來看看如何對付值型別。其原因,一會兒會看到。
    我曾經需要向一個grid中填入資料,但是填入的過程中,控制元件不斷的重新整理,造成閃爍,也影響效能,除非把控制元件上的AutoDraw屬性設為false。為此,我做了一個域守衛類,在填寫操作之前關上AutoDraw,完成或異常丟擲時再開啟:
        class DrawScope : IDisposable
        
{
            
public DrawScope(Grid g, bool val) {
                m_grid
=g;
                m_old
=g->AutoDraw;
                m_grid
->AutoDraw=val;
            }

            
public void Dispose() {
                    g
->AutoDraw=m_old;
               }

            
private Grid m_grid;
            
private bool m_old;
        }
;
    於是,我便可以如下優雅地處理AutoDraw屬性設定問題:
        static void LoadData(Grid g) {
            
using(DrawScope ds=new DrawScope(g, false))
            
{
                ... 
//執行資料裝載
            }

        }
    現在,我們回過頭,來實現值型別的域守衛。案例還是採用前面的CellTextChange事件。當我試圖著手對那個is_cacul執行域守衛時,遇到了不小的麻煩。起初,我寫下了這樣的程式碼:
        class BoolScope
        
{
            
private ??? m_val; //此處用什麼型別?
            private bool m_old;
        }
;
    m_val應當是一個指向一個物件的引用,C#是沒有C++那些指標和引用的。在C#中,引用型別定義的物件實際上是一個指向物件的引用;而值型別定義的物件實際上是一個物件,或者說“棧物件”,但卻沒有一種指向值型別的引用。(關於這種物件模型的優劣,後面的“題外話”小節有一些探討)。我嘗試著採用兩種辦法,一種不成功,而另一種成功了。
    C#(.net)有一種box機制,可以將一個值物件打包,放到堆中建立。這樣,或許可以把一個值物件程式設計引用物件,構成C#可以引用的東西:
        class BoolScope : IDisposable
        
{
            
public BoolScope(object val, bool newVal) {
                    m_val
=val;                 //#1
                    m_old=(bool)val;
                    (
bool)m_val=newVal;    //#2
            }

            
public void Dispose() {
                    (
bool)m_val=m_old;    //#3
               }

            
private object m_val;
            
private bool m_old;
        }
    使用時,應當採用如下形式:
        class MyForm
        
{
            
public MyForm() {
                is_cacul
=new bool(false); //boxing
            }

            ...
            
void OnCellTextChange(Cell& cell) {
                
if(is_cacul)
                    
return;
                
using(BoolScope bs=new BoolScope(is_cacul, true))
                
{
                    ... 
//執行計算任務
                }

            }

            
private object is_cacul;
        }
;
    很可惜,此路不通。因為在程式碼#1的地方,並未執行引用語義,而執行了值語義。也就是說,沒有把val(它是個引用)的值賦給m_val(也是個引用),而是為m_val做了個副本。以至於在程式碼#2和#3處無法將newVal和m_old賦予val(也就是is_cacul)。或許C#的設計者有無數理由說明這種設計的合理性,但是在這裡,卻扼殺了一個非常有用的idom。而且,缺少對值物件的引用手段,大大限制了語言的靈活性和擴充套件性。
    第二種方法就非常直白了,也絕對不應當出問題,就是使用包裝類:
        class BoolVal
        
{
            
public BoolVal(bool v)
            
{
                m_val
=v;
            }

            
public bool getVal() {
                
return m_val;
            }

            
public void setVal(bool v) {
                m_val
=v;
            }

            
private bool m_val;
        }

        
class BoolScope : IDisposable
        
{
            
public IntScope(BoolVal iv, bool v)
            
{
                m_old 
= iv.getVal();
                m_Val 
= iv;
                m_Val.setVal(v);
            }

            
public virtual void Dispose()
            
{
                m_Val.setVal(m_old);
            }

            
private BoolVal m_Val;
            
private bool m_old;
        }
    這裡,我做了一個包裝類BoolVal,是個引用類。然後以此為基礎,編寫了一個BoolScope類。然後,便可以正常使用域守衛:
        class MyForm
        
{
            
public MyForm() {
                m_val.setVal(
false); //boxing
            }

            ...
            
void OnCellTextChange(Cell& cell) {
                
if(is_cacul)
                    
return;
                
using(BoolScope bs=new BoolScope(m_val, true))
                
{
                    ... 
//執行計算任務
                }

            }

            
private BoolVal m_val;
        }
;
    好了,一切都很不錯。儘管C#的物件模型給我們平添了不少麻煩,使得我多寫了不少程式碼,但是使用域守衛類仍然是一本萬利的事情。作為GP fans,我當然也嘗試著在C#裡做一些泛型,以免去反覆開發包裝類和域守衛類的苦惱。這些東西,就留給大家做練習吧。:)
    在某些場合下,我們可能會對一些物件做一些操作,完事後在恢復這個物件的原始狀態,這也是域守衛類的用武之地。只是守衛一個結構複雜的類,不是一件輕鬆的工作。最直接的做法是取出所有的成員資料,在結束後再重新複製回去。這當然是繁複的工作,而且效率不高。但是,我們將在下一篇看到,如果運用swap手法,結合複製建構函式,可以很方便地實現這種域守衛。這我們以後再說。
    域守衛作為RAII的一個擴充套件應用,非常簡單,但卻極具實用性。如果我們對“資源”這個概念加以推廣,把一些值、狀態等等內容都納入資源的範疇,那麼域守衛類的使用是順理成章的事。

題外話:C#的物件模型

    C#的設計理念是簡化語言的學習和使用。但是,就前面案例中出現的問題而言,在特定的情況下,特別是需要靈活和擴充套件的時候,C#往往表現的差強人意。C# 的物件模型實際上是以堆物件和引用語義為核心的。不過,考慮到維持堆物件的巨大開銷和效能損失,應用在一些簡單的型別上,比如int、float等等,實在得不嘗失。為此,C#將這些簡單型別直接作為值處理,當然也允許使用者定義自己的值型別。值型別擁有值語義。而值型別的本質是棧物件,引用型別則是堆物件。
    這樣看起來應該是個不錯的折中,但是實際上卻造成了不大不小的麻煩。前面的案例已經明確地表現了這種物件模型引發的麻煩。由於C#拋棄值和引用的差異(為了簡化語言的學習和使用),那麼對於一個引用物件,我們無法用值語義訪問它;而對於一個值物件,我們無法用引用語義訪問。對於前者,不會引發本質性的問題,因為我們可以使用成員函式來實現值語義。但是對於後者,則是無法逾越的障礙,就像在BoolScope案例中表現的那樣。在這種情況下,我們不得不用引用類包裝值型別,使得值型別喪失了原有的效能和資源優勢。
    更有甚者,C#的物件模型有時會造成語義上的衝突。由於值型別使用值語義,而引用型別使用引用語義。那麼同樣是物件定義,便有可能使用不同的語義:
        int i, j=10;  //值型別
        i=j;            //值語義,兩個物件複製內容
        i=5;           //i==5, j==10
        StringBuilder s1, s2 = new StringBuilder("s2");   //引用型別
        s1 = s2;        //引用語義,s1和s2指向同一個物件
        s1.Append(" is s1");    //s1==s2=="s1 is s2"

    同一個形式具有不同語義,往往會造成意想不到的問題。比如,在軟體開發的最初時刻,我們認為某個型別是值型別就足夠了,還可以獲得效能上的好處。但是,隨著專案進入後期階段,發現最初的設計有問題,值型別限制了該型別的某些特性(如不能擁有解構函式,不能引用等等),那麼需要把它改成引用型別。於是便引發一大堆麻煩,需要檢查所有使用該型別的程式碼,然後把賦值操作改成複製操作。這肯定不是討人喜歡的工作。為此,在實際開發中,很少自定義值型別,以免將來自縛手腳。於是,值型別除了語言內建型別和.net庫預定義的型別外,成了一件擺設。
    相比之下,傳統語言,如Ada、C、C++、Pascal等,區分引用和值的做法儘管需要初學者花更多的精力理解其中的差別,但在使用中則更加妥善和安全。畢竟學習是暫時的,使用則是永遠的。

相關文章