C++營養(RAII)

來源:互聯網
上載者:User
C++的營養 
莫華楓 
    動物都會攝取食物,吸收其中的營養,用於自身生長和活動。然而,並非食物中所有的物質都能為動物所吸收。那些無法消化的物質,通過消化道的另一頭(某些動物消化道只有一頭)排出體外。不過,一種動物無法消化的排泄物,是另一種動物(生物)的食物,後者可以從中攝取所需的營養。 
    一門程式設計語言,對於程式員而言,如同食物那樣,包含著所需的養分。當然也包含著無法消化的東西。不同的是,隨著程式員不斷成長,會逐步消化過去無法消化的那些東西。 
    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等,區分引用和值的做法儘管需要初學者花更多的精力理解其中的差別,但在使用中則更加妥善和安全。畢竟學習是暫時的,使用則是永遠的。

聯繫我們

該頁面正文內容均來源於網絡整理,並不代表阿里雲官方的觀點,該頁面所提到的產品和服務也與阿里云無關,如果該頁面內容對您造成了困擾,歡迎寫郵件給我們,收到郵件我們將在5個工作日內處理。

如果您發現本社區中有涉嫌抄襲的內容,歡迎發送郵件至: info-contact@alibabacloud.com 進行舉報並提供相關證據,工作人員會在 5 個工作天內聯絡您,一經查實,本站將立刻刪除涉嫌侵權內容。

A Free Trial That Lets You Build Big!

Start building with 50+ products and up to 12 months usage for Elastic Compute Service

  • Sales Support

    1 on 1 presale consultation

  • After-Sales Support

    24/7 Technical Support 6 Free Tickets per Quarter Faster Response

  • Alibaba Cloud offers highly flexible support services tailored to meet your exact needs.