如何編寫異常安全的C++代碼

      關於C++中異常的爭論何其多也,但每每是一些不合事實的誤解。異常曾經是一個難以用好的語言特性,幸運的是,隨着C++社區經驗的積累,今天咱們已經有足夠的知識輕鬆編寫異常安全的代碼了,並且編寫異常安全的代碼通常也不會對性能形成影響。

    使用異常仍是返回錯誤碼?這是個爭論不休的話題。你們必定據說過這樣的說法:只有在真正異常的時候,才使用異常。那什麼是「真正異常的時候」?在回答這個問題之前,讓咱們先看一看程序設計中的不變式原理。
    對象就是屬性聚合加方法,如何斷定一個對象的屬性聚合是否是處於邏輯上正確的狀態呢?這能夠經過一系列的斷言,最後下一個結論說:這個對象的屬性聚合邏輯上是正確的或者是有問題的。這些斷言就是衡量對象屬性聚合對錯的不變式。
    咱們一般在函數調用中,實施不變式的檢查。不變式分爲三類:前條件,後條件和不變式。前條件是指在函數調用以前,必須知足的邏輯條件,後條件是函數調用後必須知足的邏輯條件,不變式則是整個函數執行中都必須知足的條件。在咱們的討論中,不變式既是前條件又是後條件。前條件是必須知足的,若是不知足,那就是程序邏輯錯誤,後條件則不必定。如今,咱們能夠用不變式來嚴格定義異常情況了:知足前條件,可是沒法知足後條件,即爲異常情況。當且僅當發生異常情況時,才拋出異常。
    關於什麼時候拋出異常的回答中,並不排斥返回值報告錯誤,並且這二者是正交的。然而,從咱們經驗上來講,徹底能夠在這二者中加以選擇,這又是爲何呢?事實上,當咱們作出這種選擇時,必然意味着接口語意的改變,在不改變接口的狀況下,實際上是沒法選擇的(試試看,用返回值處理構造函數中的錯誤)。經過不變式區別出正常和異常情況,還能夠更好地提煉接口。
    對於異常安全的評定,可分爲三個級別:基本保證、強保證和不會失敗。
基本保證:確保出現異常時程序(對象)處於未知但有效的狀態。所謂有效,即對象的不變式檢查所有經過。
強保證:確保操做的事務性,要麼成功,程序處於目標狀態,要麼不發生改變。
不會失敗:對於大多數函數來講,這是很難保證的。對於C++程序,至少析構函數、釋放函數和swap函數要確保不會失敗,這是編寫異常安全代碼的基礎。
    首先從異常狀況下資源管理的問題開始.不少人可能都這麼幹過:
    Type* obj = new Type;
    try{  do_something...}
    catch(...){ delete obj; throw;}

    不要這麼作!這麼作只會使你的代碼看上去混亂,並且會下降效率,這也是一直以來異常名聲不大好的緣由之一. 請藉助於RAII技術來完成這樣的工做:
    auto_ptr obj_ptr(new Type);
    do_something...

    這樣的代碼簡潔、安全並且無損於效率。當你不關心或是沒法處理異常時,請不要試圖捕獲它。並不是使用try...catch才能編寫異常安全的代碼,大部分異常安全的代碼都不須要try...catch。我認可,現實世界並不是老是如上述的例子那樣簡單,可是這個例子確實能夠表明不少異常安全代碼的作法。在這個例子中,boost::scoped_ptr是auto_ptr一個更適合的替代品。
    如今來考慮這樣一個構造函數:
    Type() : m_a(new TypeA), m_b(new TypeB){}
    假設成員變量m_a和m_b是原始的指針類型,而且和Type內的申明順序一致。這樣的代碼是不安全的,它存在資源泄漏問題,構造函數的失敗回滾機制沒法應對這樣的問題。若是new TypeB拋出異常,new TypeA返回的資源是得不到釋放機會的.曾經,不少人用這樣的方法避免異常:
    Type() : m_a(NULL), m_b(NULL){
        auto_ptr tmp_a(new TypeA);
        auto_ptr tmp_b(new TypeB);
        m_a = tmp_a.release();
        m_b = tmp_b.release();
    }

固然,這樣的方法確實是可以實現異常安全的代碼的,並且其中實現思想將是很是重要的,在如何實現強保證的異常安全代碼中會採用這種思想.然而這種作法不夠完全,至少析構函數仍是要手動完成的。咱們仍然能夠藉助RAII技術,把這件事作得更爲完全:shared_ptr m_a; shared_ptr m_b;這樣,咱們就能夠垂手可得地寫出異常安全的代碼:
    Type() : m_a(new TypeA), m_b(new TypeB){}
若是你以爲shared_ptr的性能不能知足要求,能夠編寫一個接口相似scoped_ptr的智能指針類,在析構函數中釋放資源便可。若是類設計成不可複製的,也能夠直接用scoped_ptr。強烈建議不要把auto_ptr做爲數據成員使用,scoped_ptr雖然名字不大好,可是至少很安全並且不會致使混亂。
    RAII技術並不只僅用於上述例子中,全部必須成對出現的操做均可以經過這一技術完成而沒必要try...catch.下面的代碼也是常見的:
    a_lock.lock(); 
    try{ ...} catch(...) {a_lock.unlock();throw;}
    a_lock.unlock(); 

能夠這樣解決,先提供一個成對操做的輔助類:
    struct scoped_lock{
        explicit scoped_lock(Lock& lock) : m_l(lock){m_l.lock();}
        ~scoped_lock(){m_l.unlock();}
    private:  
        Lock& m_l;
    };

而後,代碼只需這樣寫:
    scoped_lock guard(a_lock);
    do_something...

清晰而優雅!繼續考察這個例子,假設咱們並不須要成對操做, 顯然,修改scoped_lock構造函數便可解決問題。然而,每每方法名稱和參數也不是那麼固定的,怎麼辦?能夠藉助這樣一個輔助類:
    template
    struct pair_guard{
        pair_guard(FEnd fe, FBegin fb) : m_fe(fe) {if (fb) fb();}
        ~pair_guard(){m_fe();}
    private:
        FEnd m_fe;
        ...//禁止複製
    };
    typedef pair_guard , function > simple_pair_guard;

好了,藉助boost庫,咱們能夠這樣來編寫代碼了:
    simple_pair_guard guard(bind(&Lock::unlock, a_lock), bind(&Lock::lock, a_lock) );
    do_something...

我認可,這樣的代碼不如前面的簡潔和容易理解,可是它更靈活,不管函數名稱是什麼,均可以拿來結對。咱們能夠增強對bind的運用,結合佔位符和reference_wrapper,就能夠處理函數參數、動態綁定變量。全部咱們在catch內外的相同工做,交給pair_guard去完成便可。
    考察前面的幾個例子,也許你已經發現了,所謂異常安全的代碼,居然就是如何避免try...catch的代碼,這和直覺彷佛是違背的。有些時候,事情就是如此違背直覺。異常是無處不在的,當你不須要關心異常或者沒法處理異常的時候,就應該避免捕獲異常。除非你打算捕獲全部異常,不然,請務必把未處理的異常再次拋出。try...catch的方式當然可以寫出異常安全的代碼,可是那樣的代碼不管是清晰性和效率都是難以忍受的,而這正是不少人抨擊C++異常的理由。在C++的世界,就應該按照C++的法則來行事。
    若是按照上述的原則行事,可以實現基本保證了嗎?誠懇地說,基礎設施有了,但技巧上還不夠,讓咱們繼續分析不夠的部分。
    對於一個方法常規的執行過程,咱們在方法內部可能須要屢次修改對象狀態,在方法執行的中途,對象是可能處於非法狀態的(非法狀態 != 未知狀態),若是此時發生異常,對象將變得無效。利用前述的手段,在pair_guard的析構中修復對象是可行的,但缺少效率,代碼將變得複雜。最好的辦法是......是避免這麼做,這麼說有點不厚道,但並不是毫無道理。當對象處於非法狀態時,意味着此時此刻對象不能安全重入、不能共享。現實一點的作法是:
    a.每一次修改對象,都確保對象處於合法狀態
    b.或者當對象處於非法狀態時,全部操做決不會失敗。
在接下來的強保證的討論中細述如何作到這兩點。

    強保證是事務性的,這個事務性和數據庫的事務性有區別,也有共通性。實現強保證的原則作法是:在可能失敗的過程當中計算出對象的目標狀態,可是不修改對象,在決不失敗的過程當中,把對象替換到目標狀態。考察一個不安全的字符串賦值方法:
string& operator=(const string& rsh){
    if (this != &rsh){
        myalloc locked_pool(m_data);
        locked_pool.deallocate(m_data);
        if (rsh.empty())
        m_data = NULL;
        else{
        m_data = locked_pool.allocate(rsh.size() + 1);
        never_failed_copy(m_data, rsh.m_data, rsh.size() + 1);
        }
    }
    return *this;
    }

locked_pool是爲了鎖定內存頁。爲了討論的簡單起見,咱們假設只有locked_pool構造函數和allocate是可能拋出異常的,那麼這段代碼連基本保證也沒有作到。若allocate失敗,則m_data取值將是非法的。參考上面的b條目,咱們能夠這樣修改代碼:
myalloc locked_pool(m_data);
    locked_pool.deallocate(m_data);   //進入非法狀態
    m_data = NULL;            //馬上再次回到合法狀態,且不會失敗
    if(!rsh.empty()){
    m_data = locked_pool.allocate(rsh.size() + 1);
    never_failed_memcopy(m_data, rsh.m_data, rsh.size() + 1);
    }

如今,若是locked_pool失敗,對象不發生改變。若是allocate失敗,對象是一個空字符串,這既不是初始狀態,也不是咱們預期的目標狀態,但它是一個合法狀態。咱們闡明瞭實現基本保證所須要的技巧部分,結合前述的基礎設施(RAII的運用),徹底能夠實現基本保證了...哦,其實仍是有一點疏漏,不過,那就留到最後吧。
   繼續,讓上面的代碼實現強保證:
myalloc locked_pool(m_data);
    char* tmp = NULL;
    if(!rsh.empty()){
    tmp = locked_pool.allocate(rsh.size() + 1); 
    never_failed_memcopy(tmp, rsh.m_data, rsh.size() + 1); //先生成目標狀態
    }
    swap(tmp, m_data);       //對象安全進入目標狀態
    m_alloc.deallocate(tmp);    //釋放原有資源

強保證的代碼多使用了一個局部變量tmp,先計算出目標狀態放在tmp中,而後在安全進入目標狀態,這個過程咱們並無損失什麼東西(代碼清晰性,性能等等)。看上去,實現強保證並不比基本保證困難多少,通常而言,也確實如此。不過,別太自信,舉一種典型的很難實現強保證的例子,對於區間操做的強保證:
    for (itr = range.begin(); itr != range.end(); ++itr){
    itr->do_something();
    }

若是某個do_something失敗了,range將處於什麼狀態?這段代碼仍然作到了基本保證,但不是強保證的,根據實現強保證的基本原則,咱們能夠這麼作:
    tmp = range;
    for (itr = tmp.begin(); itr != tmp.end(); ++itr){
    itr->do_something();
    }
    swap(tmp, range);

彷佛很簡單啊!呵呵,這樣的作法並不是不可取,只是有時候行不通。由於咱們額外付出了性能的代價,並且,這個代價可能很大。不管如何,咱們闡述了實現強保證的方法,怎麼取捨則由您決定了。

    接下來討論最後一種異常安全保證:不會失敗。
    一般,咱們並不須要這麼強的安全保證,可是咱們至少必須保證三類過程不會失敗:析構函數,釋放類函數,swap。析構和釋放函數不會失敗,這是RAII技術有效的基石,swap不會失敗,是爲了「在決不失敗的過程當中,把對象替換到目標狀態」。咱們前面的全部討論都是創建在這三類過程不會失敗的基礎上的,在這裏,彌補了上面的那個疏漏。
    通常而言,語言內部類型的賦值、取地址等運算是不會發生異常的,上述三類過程邏輯上也是不會發生異常的。內部運算中,除法運算可能拋出異常。可是地址訪問錯一般是一種錯誤,而不是異常,咱們本應該在前條件檢查中就發現的這一點的。全部不會發生異常操做的簡單累加,仍然不會致使異常。

好了,如今咱們能夠總結一下編寫異常安全代碼的幾條準則了:
1.只在應該使用異常的地方拋出異常
2.若是不知道如何處理異常,請不要捕獲(截留)異常。
3.充分使用RAII,旁路異常。
4.努力實現強保證,至少實現基本保證。
5.確保析構函數、釋放類函數和swap不會失敗。

另外,還有一些語言細節問題,由於和這個主題有關也一併列出:
1.不要這樣拋出異常:throw new exception;這將致使內存泄漏。
2.自定義類型,應該捕獲異常的引用類型:catch(exception& e)或catch(const exception& e)。
3.不要使用異常規範,即便是空異常規範。編譯器並不保證只拋出異常規範容許的異常,更多內容請參考相關書籍。
數據庫

相關文章
相關標籤/搜索