C++異常處理第四篇 Loki::ScopeGuard

轉載:神奇的Loki::ScopeGuard 2011-07-05 12:52:05
分類: C/C++
轉載:http://blog.csdn.net/fangqu/article/details/4242245
----------------------------------------------------------------------------------------
作者:Andrei Alexandrescu and Petru Marginean
原文地址:http://www.ddj.com/cpp/184403758
翻譯,裁剪,修改:purewinter
注:裁剪修改只是爲了讓更多csdn上的讀者不會因爲此文太長而放棄閱讀。。。
注2:Loki::ScopeGuard不僅對通常意義的異常有用,對於所有可以使用RAII的地方均有用。包括new出來的內存空間的管理,FILE或CFile之類的文件句柄等。
第一次翻譯,不足之處多多指教。 

可能有異常出現的時候,編寫正確的代碼並不是一件簡單的工作,這是我們都要面對的問題。異常導致了一個與程序主控制流幾乎無關的控制流。因此,解決異常流的問題也就需要另外一種途徑,以及另外一種工具。
 
編寫異常安全代碼很困難—— 一個普通的例子
(譯註:原文這個例子介紹得很詳細,此處將只說要點)假設一個用戶管理中,可以爲已登陸的用戶添加好友。好友將添加至內存列表以及數據庫。下面是部分源代碼:
 


class User
...{
    ...
    string GetName();
    void AddFriend(User& newFriend);
private:
    typedef vector<User*> UserCont;
    UserCont friends_;
    UserDatabase* pDB_;
};
void User::AddFriend(User& newFriend)
...{
    // Add the new friend to the database
    pDB_->AddFriend(GetName(), newFriend.GetName());  //語句1
    // Add the new friend to the vector of friends
    friends_.push_back(&newFriend);  //語句2
}
 
對很多人來說會很吃驚,對於這個兩行代碼的AddFriend,卻有一個致命的bug:如果內存不足導致語句2執行失敗,數據庫裏就會有新好友的記錄,而內存中沒有——這種不一致是十分危險的。
一個很簡單的解決方案就是,把語句1和語句2對調。這樣語句2失敗時將拋出異常,也就不會執行語句1了。問題是,如果數據庫操作拋出異常了呢?這種不一致性就更危險了。嗯,是時候質問那些寫數據庫代碼的人了:“你們這些傢伙,就不能返回個錯誤代碼啊?爲什麼非要拋出異常啊?”那些傢伙說了,“嗯,你想,我們是構建在高可靠性的基於TZN網絡的XYZ數據庫服務器集羣上的,所以失敗是及其罕見的。既然如此罕見,我們認爲最好用異常來表示處理失敗,畢竟異常只出現在異常情況下嘛( because exceptions appear only in exceptional conditions),對吧?”(譯註:TZN和XYZ都是隨你YY的名稱)這似乎很有道理。但是你又不想讓一個數據庫異常讓你的程序跌入混沌深淵...你該怎麼辦呢? 實際上,你必須要執行兩個操作,其中任何一個失敗你都要回滾到什麼都沒做的狀態。讓我們看看幾種實現的方法。
 
解決方案1:蠻幹
很簡單,加try-catch就是。


void User::AddFriend(User& newFriend)
{
    friends_.push_back(&newFriend);
    try
    {
        pDB_->AddFriend(GetName(), newFriend.GetName());
    }
    catch (...)
    {
        friends_.pop_back();
        throw;
    }
}
 
如果push_back失敗了,那沒關係,因爲這樣的話AddFriend不會執行。若AddFriend失敗,那麼會被catch,然後執行pop_back,最後漂亮地把異常重新拋出。嗯,這的確行得通。但是這樣做也問題多多:
代碼增加並且顯得很笨拙。2行的代碼變成了10行,而且這樣及其令人反感:想象一下你的代碼裏到處是這樣的try-catch.....

更嚴重的是,這樣的代碼很難擴展。想象一個有第三條語句要加進來。那將使代碼變得更笨拙,(由於三條語句任意一條都可以失敗,)你得寫嵌套的try,或者自己寫一種複雜的流控制標誌和語句。(譯註:我是不敢想象,因爲我就寫過,實在是痛苦。)
這種方法不僅使代碼膨脹,效率低下,更嚴重的是是可讀性變得很差,也難以維護。
 
解決方案2:原則上正確的途徑
給任何一個真正熟悉C++的人說說,他們馬上會說,“噢,你應該使用RAII(resource acquisition isinitialization)來解決這個問題。在失敗的情況下讓析構函數自動處理資源釋放(或回滾)。”OK,讓我們來試試。對於每一個你需要undo的操作,都需要相應的一個class。這個class的構造函數執行那個操作,而析構函數回滾它,除非你調用了一個“提交”函數,告訴析構函數不要回滾操作。對於push_back操作,可能的class代碼如下:


class VectorInserter
{
public:
    VectorInserter(std::vector& v, User& u)
    : container_(v), commit_(false)
    {
        container_.push_back(&u);
    }
    void Commit() throw()
    {
        commit_ = true;
    }
    ~VectorInserter()
    {
        if (!commit_) container_.pop_back();
    }
private:
    std::vector& container_;
    bool commit_;
};
 
這其中最重要的可能就是在Commit函數定義旁的“throw()”描述符了。它指出了Commit操作永遠會成功這個事實。而實際上Commit只是告訴VectorInserter:“一切正常,不要回滾任何操作。”你將這樣使用這個類:


void User::AddFriend(User& newFriend)
{
    VectorInserter ins(friends_, &newFriend);
    pDB_->AddFriend(GetName(), newFriend.GetName());
    // Everything went fine, commit the vector insertion
    ins.Commit();
}
 
AddFriend現在有兩個部分:做實際操作的部分,還有提交的部分。提交的部分不會發生異常:它只是阻止回滾而已。這種方法在任何情況下都工作得很好。如果push_back操作失敗,由於ins對象構建失敗,自然下面的操作以及ins的析構函數都不會執行。(ins都沒構建完畢,自然無所謂析構)如果數據庫操作失敗,那麼ins的析構函數將會執行pop_back。
這個方案看上去很不錯,實際上工作得也很好,但在現實中卻還是有一些麻煩。你得爲※每個※需要回滾的操作額外寫一個類,於是你的類列表裏就多出了相當多的這種類。重複的寫這種類顯然不是一種好主意,而且很容易出錯——沒注意到嗎?VectorInserter就有一個bug:它的拷貝構造函數幹了一件很不好的事情。(天哪!那將會pop_back多次......)定義類本來就不是一件簡單的事情,這也是不要寫很多這種類的另一個理由。
 
解決方案3:普遍的現實
無論你看完了剛纔所有的討論或者根本就沒看,你知道你最終的方案是什麼?當然,你很清楚,就是這樣:


void User::AddFriend(User& newFriend)
{
    friends_.push_back(&newFriend);
    pDB_->AddFriend(GetName(), newFriend.GetName());
}
 
這是一種建立在並不科學的論據的基礎上的方案:“誰說內存不足了?這裏還有狗X的一半多呢!”“就算內存用光了,不是還有內存分頁機制會使程序變成蝸牛的速度來避免程序崩潰嗎!”“那些搞數據庫的傢伙說了,那個AddFriend數據庫操作幾乎不可能失敗!他們可是用了XYZ和TZN!”“這根本不值得煩惱。我們會在以後的review中再解決。”於是,最終代碼就成了這樣。隨着時間的流逝以及日程壓力,那些本來只在“理論”上的問題開始浮現,而你卻不知道它是由於硬件問題還是由於異常。隨着用戶數量的增加,你的程序逐漸耗盡內存,你的網絡管理員可能因爲性能抖動太大而關閉分頁機制,你的數據庫可能變得不那麼可靠.....而你對此沒有任何準備。
 
解決方案4:Petru的辦法(Loki::ScopeGuard)
用Loki::ScopeGuard,你可以輕鬆,正確,高效地寫出代碼:


void User::AddFriend(User& newFriend)
{
    friends_.push_back(&newFriend);
    ScopeGuard guard = MakeObjGuard(friends_, &UserCont::pop_back);
    pDB_->AddFriend(GetName(), newFriend.GetName());
    guard.Dismiss();
}

 
guard做的唯一工作就是當它離開它的作用範圍時調用friends_.pop_back,除非你調用了Dismiss(如果那樣,guard就什麼也不做)。ScopeGuard實現了在它的析構函數裏自動對函數或成員函數的調用。(譯註:看到RAII方法的缺陷時就應該要想到,ScopeGuard其實就是通用的RAII方法的實現。因此,它有RAII的一切優點,並且避免了其缺點。)
你將這樣使用ScopeGuard:如果你有一些操作需要“要麼全做,要麼都不做”(原子性),那就在每一個操作後面放一個ScopeGuard。在全部完成以後,再全部提交。(提交是不會異常的,因此對提交順序沒有要求)
ScopeGuard對普通函數同樣有用(注意上面的例子是對類的成員函數):


void* buffer = std::malloc(1024);
ScopeGuard freeIt = MakeGuard(std::free, buffer);
FILE* topSecret = std::fopen("cia.txt");
ScopeGuard closeIt = MakeGuard(std::fclose, topSecret);

(譯註:上面的普通函數的例子和前面的異常安全的例子還有不同:上面的例子是解決“釋放資源”,而異常安全的例子是爲了“自動回滾”。其實,ScopeGuard真正是適用於任何需要RAII情況,反而不是適用於任何需要異常安全情況。另外,由於資源是肯定要釋放的,所以基本上不會Dismiss。也就可以用下面的宏:


{
    FILE* topSecret = fopen("cia.txt");
    ON_BLOCK_EXIT(std::fclose, topSecret);
    ... use topSecret ...
} // topSecret automagically closed
關於ScopeGuard於異常安全的其他討論,我可能會新開一貼。)
如果所有操作都成功了,你將Dismiss所有操作。否則,每一個成功構建的ScopeGuard將自動調用你初始化時給它的函數。這樣你就不必爲了pop_back,關閉文件等undo操作寫一個專用的類。這使得ScopeGuard成爲一個很有用的輕鬆的寫異常安全代碼的可重用工具。(譯註:如果一個undo操作要執行一系列函數,Loki有MultiMethods。但是若對應不到一個函數.....就要自己手寫了。這使我想到Java裏的匿名函數...要是C++能支持多好。)
 
使用注意
(譯註:爲了照顧大多數國人的習慣,我把這個從文章最後移了上來並做了修改,否則估計很多人會無視它。)
你應該像它本意一樣使用它:當做函數裏的變量。你不應該把它用做類的成員變量,不應該把它放到vector裏去,不應該在堆上創建它。(如果你要這麼做,應該使用Janitor類,但這會導致一些性能損失。)

另外,像在下文裏提到的,雖然ScopeGuard中有防範措施,但最好不要傳入一個可能拋出異常的回滾操作。畢竟,回滾的時候失敗了,那該怎麼辦?(所以說ScopeGuard並不適用於任何異常安全情況。)
 
實現ScopeGuard
(譯註:以下內容涉及C++ 模版知識,還不清楚的請複習一下:C++ Primer,C++ Templates,Effective STL隨你挑一本看看吧。)
 ScopeGuard是RAII的一種泛化實現。它與RAII的不同是ScopeGuard只關注清理的部分——資源申請你來做,ScopeGuard幫你清除。清理(回收)資源有很多辦法,如調用一個函數/仿函數,調用一個對象的成員函數。它們都可能需要0個,1個或多個參數。自然的,我們建立了一個類的繼承體系來處理這種差異。在這個繼承體系的對象的析構函數來做實際的工作。而這個繼承體系的基類,ScopeGuardImplBase,定義如下:


class ScopeGuardImplBase
{
public:
    void Dismiss() const throw()
    {    dismissed_ = true;    }
protected:
    ScopeGuardImplBase() : dismissed_(false)
    {}
    ScopeGuardImplBase(const ScopeGuardImplBase& other)
    : dismissed_(other.dismissed_)
    {    other.Dismiss();    }
    ~ScopeGuardImplBase() {} // nonvirtual (see below why)
    mutable bool dismissed_;

private:
    // Disable assignment
    ScopeGuardImplBase& operator=(
        const ScopeGuardImplBase&);
};
 
ScopeGuardImplBase管理dismissed_標誌,而它決定子類是否執行清除操作。注意到,我們的析構函數並沒有使用"virtual"關鍵字。控制住你的好奇心,我們有一個好方法來獲得多態行爲卻不需要virtual關鍵字。(譯註:噢,他說的是多態行爲,而不是析構函數,雖然這個“多態”的確是針對析構函數的。這裏用protected來保證這東西不會被用戶代碼胡亂的析構。當然,如果你非要繼承它然後自己胡亂析構。。。那應該由你自己負責。)至於現在,讓我們看看我們怎樣實現一個對象:它會在析構時調用一個只有一個參數的函數或仿函數,並且如果調用Dismiss方法,它在析構時不做任何事。


template <typename Fun, typename Parm>
class ScopeGuardImpl1 : public ScopeGuardImplBase
{
public:
    ScopeGuardImpl1(const Fun& fun, const Parm& parm)
    : fun_(fun), parm_(parm) 
    {}
    ~ScopeGuardImpl1()
    {
        if (!dismissed_) fun_(parm_);
    }
private:
    Fun fun_;
    const Parm parm_;
};

爲了能方便地使用ScopeGuardImpl1,我們定義一個輔助函數:


template <typename Fun, typename Parm>
ScopeGuardImpl1<Fun, Parm>
MakeGuard(const Fun& fun, const Parm& parm)
{
    return ScopeGuardImpl1<Fun, Parm>(fun, parm);
}
就像STL裏make_pair和bind1st等等一樣,這種輔助函數使你不必輸入模版參數。實際上,你不必顯式地創建一個ScopeGuardImpl1對象。還在奇怪我們如何實現多態行爲卻不需要virtual的嗎?OK,讓我們看看ScopeGuard的定義吧:


typedef const ScopeGuardImplBase& ScopeGuard;

令人驚奇的是,它只是個typedef。現在讓我們解開這個謎團。根據C++標準,一個由臨時變量定義的引用,將導致改臨時變量的生命週期延長至和此引用一樣長。還是用一個例子來說明吧。如果你寫下下面代碼:


FILE* topSecret = std::fopen("cia.txt");
ScopeGuard closeIt = MakeGuard(std::fclose, topSecret);
那麼MakeGuard將會生成如下類型的一個臨時變量:


ScopeGuardImpl1<int (&)(FILE*), FILE*>

然後這個臨時變量將會被賦值給你定義的引用類型的常量closeit。本來這個臨時變量的生命週期到此結束。但是由於上述標準的規定,這個臨時變量的生命週期被延長了。於是,在closeit退出作用範圍時,臨時變量進行析構——自然這時調用的是正確的析構函數。(譯註:這是因爲並非由closeit來調用析構函數,只是closeit生命結束時臨時變量同時進行析構而已。的確是巧妙的多態。但是,若基類的析構函數不是protected,則用戶代碼將可能進行錯誤析構。)在這裏,析構函數將關閉打開的文件。

ScopeGuardImpl1支持有一個參數的函數/仿函數。很容易寫出類似的支持有0個,2個,3個等等參數的函數的實現(ScopeGuardImpl0,ScopeGuardImpl2,ScopeGuardImpl3...)。如果你完成了這些,那麼可以簡單的實現MakeGuard的重載,比如:


template <typename Fun>
ScopeGuardImpl0<Fun>
MakeGuard(const Fun& fun)
{
    return ScopeGuardImpl0<Fun >(fun);
}
...
到現在我們已經完成了所有對類C API界面的函數支持,並且沒有使用虛函數。讓我們再接再厲... 
 
讓ScopeGuard 支持類成員函數
下面該加入對類成員函數的支持了。(譯註:研究過funtor的人應該知道,不過是對付operator.*和operator->*而已,更何況這裏不需要更復雜情況的支持。)其實一點也不困難,讓我們實現ObjScopeGuardImpl0,讓它支持需要0個參數的類成員函數。


template <class Obj, typename MemFun>
class ObjScopeGuardImpl0 : public ScopeGuardImplBase
{
public:
    ObjScopeGuardImpl0(Obj& obj, MemFun memFun)
    : obj_(obj), memFun_(memFun) 
    {}
    ~ObjScopeGuardImpl0()
    {
        if (!dismissed_) (obj_.*fun_)();
    }
private:
    Obj& obj_;
    MemFun memFun_;
};

除了使用成員函數指針那裏的古怪語法,其他沒什麼特別。讓我們看看該怎麼實現MakeObjGuard:


template <class Obj, typename MemFun>
ObjScopeGuardImpl0<Obj, MemFun, Parm>
MakeObjGuard(Obj& obj, Fun fun)
{
    return ObjScopeGuardImpl0<Obj, MemFun>(obj, fun);
}

還是沒什麼區別。再看看怎麼使用:


ScopeGuard guard = MakeObjGuard(friends_, &UserCont::pop_back);

注意那個"&"。這次我們傳進去的是成員函數的指針,所以需要一個取址操作符。而MakeObjGuard將返回一個具有如下類型的臨時變量:


ObjScopeGuardImpl0<UserCont, void (UserCont::*)()>

幸運的是,如同MakeGuard,並不需要我們來輸入一個如此奇怪的類型。就這樣,對成員函數的支持也完成了,與對普通函數的支持區別不大。
 
 
錯誤處理
 你應該清楚,類的析構函數是絕對不能(must not)拋出異常的。如果一個類的析構函數拋出異常,將導致未定義的行爲。(譯註:也就是什麼事都可能發生,包括你的程序啪地就沒了。至於你的電腦會不會啪地......那得看編譯器、操作系統和硬件了。......喂,我說的是啪地就黑了,不要想歪了。)由於ObjScopeGuardImplX和ScopeGuardImplX的析構函數調用的都是用戶提供的函數,而它們是有可能拋出異常的。在理論上,你不應該把可能拋出異常的函數傳給MakeGuard或MakeObjGuard,在實際上,析構函數有如下的保護:


template <class Obj, typename MemFun>
class ObjScopeGuardImpl0 : public ScopeGuardImplBase
{
    ...
public:
    ~ScopeGuardImpl1()
    {
        if (!dismissed_)
            try { (obj_.*fun_)(); }
            catch(...) {}
    }
}
(譯註:而在現在的版本,這個try-catch是放到Base的SafeExecute函數中去了。如下:)


//在Base中加入此成員模版函數:
template <typename J>
        static void SafeExecute(J& j) throw() 
        {
            if (!j.dismissed_)
                try
                {
                    j.Execute();
                }
                catch(...)
                {}
        }

//在子類中使用如下代碼:
~ObjScopeGuardImpl0() throw() 
        {
            SafeExecute(*this);
        }

        void Execute() 
        {
            fun_();
        }


不過,不管怎樣,使用不會拋出異常的函數纔是最好的。
 
 
支持引用傳參
 到現在我們都很開心地使用ScopeGuard,直到我們被下面這個問題困住了:


void Decrement(int& x) { --x; }
void UseResource(int refCount)
{
    ++refCount;
    ScopeGuard guard = MakeGuard(Decrement, refCount);
    ...
}

上面的guard對象保證了refCount的值將被保存直到退出UseResource。不管這個想法是否有用,上面的代碼並不起作用。問題是,ScopeGuard保存了refCount的一個拷貝。(見ScopeGuardImpl1的定義)而現在我們需要保持refCount的一個引用,這樣Decrement才能操作它。一種解決方案是實現額外的類,不過這將導致大量重複。並且如果你遇到多個變量,有的要引用有的不需要,那將十分麻煩。我們依然使用小巧的輔助類來解決這個問題:


template <class T>
class RefHolder
{
    T& ref_;
public:
    RefHolder(T& ref) : ref_(ref) {}
    operator T& () const
    {
        return ref_;
    }
};
template <class T>
inline RefHolder<T> ByRef(T& t)
{
    return RefHolder<T>(t);
}


RefHolder和它的輔助函數ByRef是很精巧的。它們無縫的將引用適配成一個值,並允許ScopeGuardImpl1和引用一起工作而不需要任何改動。(譯註:並且在最後用戶函數調用時又會把引用傳給它。)而你只需要把你的引用交給ByRef來包裝,如下:


void Decrement(int& x) { --x; }
void UseResource(int refCount)
{
    ++refCount;
    ScopeGuard guard = MakeGuard(Decrement, ByRef(refCount));
    ...
}


我們發現這個解決方案是十分有表現力和創見的。最棒的是那個在ScopeGuardImpl1中用到的"const"修飾符。相關部分摘錄如下:


template <typename Fun, typename Parm>
class ScopeGuardImpl1 : public ScopeGuardImplBase
{
    ...
private:
    Fun fun_;
    const Parm parm_;
};

這個小小的const是十分重要的。它防止了使用non-const引用而導致的編譯和運行錯誤。換句話說,如果你忘記使用ByRef,編譯器會向你抱怨而拒絕工作。(譯註:當然,這要求你的函數接受的就是一個引用參數。如果你提供的函數不是接受引用參數,那你就可以傳non-const變量或non-const引用給它。這纔是它的精巧之處。)
 
等等,還有更多。。。
現在你擁有了編寫異常安全代碼的所有需要的東西,不必再爲此煩惱了。不過,有時候你需要ScopeGuard永遠在你退出(定義它)的語句塊時都執行。在這種情況下,再聲明一個ScopeGuard變量顯得有些累贅——你只需要一個臨時變量,一個沒有名字的臨時變量。這個時候你就可以使用ON_BLOCK_EXIT宏。


{
    FILE* topSecret = fopen("cia.txt");
    ON_BLOCK_EXIT(std::fclose, topSecret);
    ... use topSecret ...
} // topSecret automagically closed
  ON_BLOCK_EXIT聲稱:“我希望在當前塊退出時執行這個操作。”類似的,ON_BLOCK_EXIT_OBJ爲類成員函數實現了類似的功能。
這兩個宏使用了非傳統(雖然合法)的宏技巧,這種技巧將不公開(譯註:也不特別,沒必要不公開吧。)對它們感到好奇的人可以到源代碼中找到它們。
 
在真實世界中的ScopeGuard
可能ScopeGuard最棒的地方就是它的容易使用和概念簡單了。這篇文章詳細地介紹了整個實現,不過介紹ScopeGuard的使用方法只用了一小部分時間。在我的同仁中,ScopeGuard像野火般傳播開去。每個人都把ScopeGuard當作一個對很多情況都很有用的工具,無論是無序返回或者異常。在ScopeGuard的幫助下,你最終可以輕鬆地編寫異常安全代碼,並且也能輕鬆地理解和維護它。
 
 
 
後記:
上述的代碼在VC6中可以運行(但不能同RefHolder一起),但是不能在VC8中運行。原因是MakeGuard函數自動推導模版參數失敗:不能從重載函數到重載函數進行參數推導。而目前Loki的代碼已改正這個問題,就是在每個ImplX里加入static的MakeGuard模版函數,而全局MakeGuard模版函數制定使用的類名。相關修改如下:


 template <typename F, typename P1>
    class ScopeGuardImpl1 : public ScopeGuardImplBase
    {
    public:
        static ScopeGuardImpl1<F, P1> MakeGuard(F fun, P1 p1)
        {
            return ScopeGuardImpl1<F, P1>(fun, p1);
        }
   ....
  };

//對應的全局MakeGuard模版函數:
template <typename F, typename P1> 
    inline ScopeGuardImpl1<F, P1> MakeGuard(F fun, P1 p1)
    {
        return ScopeGuardImpl1<F, P1>::MakeGuard(fun, p1);
    }


具體原因尚不清楚。似乎是由於構造函數有重載,所以導致推導失敗,而指定調用類中的一個函數以後,由於後者沒有重載(只有模版參數),所以推導成功。

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章