天天看點

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

【轉帖】    原位址不詳

關于C++中異常的争論何其多也,但往往是一些不合事實的誤解。異常曾經是一個難以用好的語言特性,幸運的是,随着C++社群經驗的積累,今天我們已經有足夠的知識輕松編寫異常安全的代碼了,而且編寫異常安全的代碼一般也不會對性能造成影響。

使用異常還是傳回錯誤碼?這是個争論不休的話題。大家一定聽說過這樣的說法:隻有在真正異常的時候,才使用異常。那什麼是"真正異常的時候"?在回答這個問題以前,讓我們先看一看程式設計中的不變式原理。

對象就是屬性聚合加方法,如何判定一個對象的屬性聚合是不是處于邏輯上正确的狀态呢?這可以通過一系列的斷言,最後下一個結論說:這個對象的屬性聚合邏輯上是正确的或者是有問題的。這些斷言就是衡量對象屬性聚合對錯的不變式。

我們通常在函數調用中,實施不變式的檢查。不變式分為三類:前條件,後條件和不變式。前條件是指在函數調用之前,必須滿足的邏輯條件,後條件是函數調用後必須滿足的邏輯條件,不變式則是整個函數執行中都必須滿足的條件。在我們的讨論中,不變式既是前條件又是後條件。前條件是必須滿足的,如果不滿足,那就是程式邏輯錯誤,後條件則不一定。現在,我們可以用不變式來嚴格定義異常狀況了:滿足前條件,但是無法滿足後條件,即為異常狀況。當且僅當發生異常狀況時,才抛出異常。

關于何時抛出異常的回答中,并不排斥傳回值報告錯誤,而且這兩者是正交的。然而,從我們經驗上來說,完全可以在這兩者中加以選擇,這又是為什麼呢?事實上,當我們做出這種選擇時,必然意味着接口語意的改變,在不改變接口的情況下,其實是無法選擇的(試試看,用傳回值處理構造函數中的錯誤)。通過不變式差別出正常和異常狀況,還可以更好地提煉接口。

對于異常安全的評定,可分為三個級别:基本保證、強保證和不會失敗。

  1. 基本保證:確定出現異常時程式(對象)處于未知但有效的狀态。所謂有效,即對象的不變式檢查全部通過。
  2. 強保證:確定操作的事務性,要麼成功,程式處于目标狀态,要麼不發生改變。
  3. 不會失敗:對于大多數函數來說,這是很難保證的。對于C++程式,至少析構函數、釋放函數和swap函數要確定不會失敗,這是編寫異常安全代碼的基礎。

首先從異常情況下資源管理的問題開始.很多人可能都這麼幹過:

Type* obj = new Type;

try{ do_something...}

catch(...){ delete obj; throw;}

不要這麼做!這麼做隻會使你的代碼看上去混亂,而且會降低效率,這也是一直以來異常名聲不大好的原因之一. 請借助于RAII技術來完成這樣的工作:

auto_ptr<Type> 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<TypeA> tmp_a(new TypeA);

    auto_ptr<TypeB> tmp_b(new TypeB);

    m_a = tmp_a.release();

    m_b = tmp_b.release();

}

當然,這樣的方法确實是能夠實作異常安全的代碼的,而且其中實作思想将是非常重要的,在如何實作強保證的異常安全代碼中會采用這種思想.然而這種做法不夠徹底,至少析構函數還是要手動完成的。我們仍然可以借助RAII技術,把這件事做得更為徹底:shared_ptr<TypeA> m_a; shared_ptr<TypeB> m_b;這樣,我們就可以輕而易舉地寫出異常安全的代碼:

如果你覺得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);

清晰而優雅!繼續考察這個例子,假設我們并不需要成對操作, 顯然,修改scoped_lock構造函數即可解決問題。然而,往往方法名稱和參數也不是那麼固定的,怎麼辦?可以借助這樣一個輔助類:

template<typename FEnd, typename FBegin>

struct pair_guard{

    pair_guard(FEnd fe, FBegin fb) : m_fe(fe) {if (fb) fb();}

    ~pair_guard(){m_fe();}

    FEnd m_fe;

    ...//禁止複制

typedef pair_guard<function<void () > , function<void()> > simple_pair_guard;

好了,借助boost庫,我們可以這樣來編寫代碼了:

simple_pair_guard guard(bind(&Lock::unlock, a_lock), bind(&Lock::lock, a_lock) );

我承認,這樣的代碼不如前面的簡潔和容易了解,但是它更靈活,無論函數名稱是什麼,都可以拿來結對。我們可以加強對bind的運用,結合占位符和reference_wrapper,就可以處理函數參數、動态綁定變量。所有我們在catch内外的相同工作,交給pair_guard去完成即可。

考察前面的幾個例子,也許你已經發現了,所謂異常安全的代碼,竟然就是如何避免try...catch的代碼,這和直覺似乎是違背的。有些時候,事情就是如此違背直覺。異常是無處不在的,當你不需要關心異常或者無法處理異常的時候,就應該避免捕獲異常。除非你打算捕獲所有異常,否則,請務必把未處理的異常再次抛出。try...catch的方式固然能夠寫出異常安全的代碼,但是那樣的代碼無論是清晰性和效率都是難以忍受的,而這正是很多人抨擊C++異常的理由。在C++的世界,就應該按照C++的法則來行事。

如果按照上述的原則行事,能夠實作基本保證了嗎?誠懇地說,基礎設施有了,但技巧上還不夠,讓我們繼續分析不夠的部分。

對于一個方法正常的執行過程,我們在方法内部可能需要多次修改對象狀态,在方法執行的中途,對象是可能處于非法狀态的(非法狀态 != 未知狀态),如果此時發生異常,對象将變得無效。利用前述的手段,在pair_guard的析構中修複對象是可行的,但缺乏效率,代碼将變得複雜。最好的辦法是......是避免這麼作,這麼說有點不厚道,但并非毫無道理。當對象處于非法狀态時,意味着此時此刻對象不能安全重入、不能共享。現實一點的做法是:

  • 每一次修改對象,都確定對象處于合法狀态
  • 或者當對象處于非法狀态時,所有操作決不會失敗。

在接下來的強保證的讨論中細述如何做到這兩點。

強保證是事務性的,這個事務性和資料庫的事務性有差別,也有共通性。實作強保證的原則做法是:在可能失敗的過程中計算出對象的目标狀态,但是不修改對象,在決不失敗的過程中,把對象替換到目标狀态。考察一個不安全的字元串指派方法:

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的運用),完全可以實作基本保證了...哦,其實還是有一點疏漏,不過,那就留到最後吧。

繼續,讓上面的代碼實作強保證:

char* tmp =NULL;

    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){

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)。

繼續閱讀