C++的另一種錯誤處理策略

周昌鴻發表於2014-01-13

這篇短文是討論一個大多數程式設計師都感興趣的一個話題:錯誤處理。錯誤處理是程式設計的一個“黑暗面”。它既是應用程式的“現實世界”的關鍵點,也是一個你想隱藏的複雜業務。

在早期的C程式設計生涯中,我知道三種錯誤處理的方式。

C語言的方式:返回錯誤碼

C語言風格的錯誤處理是最簡單的,但是並不完美。

C語言風格的錯誤處理依賴於“當程式遇到錯誤時返回一個錯誤碼”。這裡是一個簡單的例子:

使用這種方式的有什麼好處?

你可以在呼叫函式之後直接處理錯誤碼(在C語言中,你也會這樣處理),顯示一個錯誤訊息或者直接終止程式。或者僅僅恢復程式最近的一個狀態,終止計算。

當你找不到錯誤處理在哪裡的時候,你只需要後頭看看函式呼叫,錯誤處理就在那個附近。

使用這種方式有什麼不好?

有人可能會告訴你,這種異常/錯誤處理方式和“執行邏輯”混在了一起。當你順序地閱讀這些程式碼的時候就行程式執行一樣,你看到了一會錯誤處理,一會程式執行。這樣很糟糕,你可能更喜歡只讀程式執行邏輯或者錯誤處理邏輯。

並且你被限定使用錯誤碼,如果你想要提供更多的資訊,你需要建立一些功能函式比如:errstr或者提供全域性變數。

使用C++的方式

C++作為對C的增強,引入了一種新的錯誤處理方式——異常。異常通過丟擲一個錯誤的方式來中斷正常程式碼執行邏輯,並可以被其他地方所捕獲。下面是一個簡單的例子:

這樣做的好處?

程式邏輯和錯誤處理分離了。一邊你可以看到函式是如何工作的,而另一邊你可以看到函式失敗時候是怎麼處理的。這樣做很完美,可以很容易看出錯誤處理和正常程式邏輯。

另外,現在你可以為你的錯誤提供你需要的儘可能多的資訊,因為你可以將需要的內容填充在自定義異常物件裡。

這樣做的壞處

編寫詳盡的異常處理變得很冗。你需要一個異常樹,但是最好不要太大,這樣,你可以選擇捕獲感興趣的異常。同時,內部需要提供錯誤碼,來獲知究竟發生了什麼,同時需要檢索一些錯誤訊息,等等。編寫寫異常類通常都是冗長,這是將資訊嵌入到錯誤裡來靈活處理更多的資訊的成本。

這裡的錯誤處理哲學是將錯誤儘可能推遲到需要處理的地方再處理,當你不知道程式執行過程究竟哪裡會產生一個錯誤,你需要跳過不同的檔案和功能函式來查詢,這通常都是困難的,如果你在一個很深的呼叫樹(這裡意思是當你將函式呼叫繪製出一個圖形,其形狀類似一棵樹)上引發了一個異常,你需要指定在哪裡來處理這個異常,當它被處理的時候,它又是在哪裡發生的。特別是當你的程式很大,又是很早之前編寫,有恰巧設計不夠良好的時候,就更加顯得困難。而大多數商業專案都是這樣。

所以我覺得“異常是危險的”。雖然它提供了一種良好的方式來處理錯誤——僅限於一些小專案,並且這裡的呼叫圖簡單且易於掌握時候。

錯誤封裝的模式

我這裡把它叫做一種模式,所以人們不必害怕擔心。後面,我會給它一種更好的命名,所以請不要著急。

錯誤封裝的主旨是建立一種封裝來包含錯誤訊息或者錯誤的返回值。我們通常會選擇字串而不是其他,因為這也並不容易實現。我們盡力保證語法的可讀性,可理解,並且容易應用。我們不處理拷貝構造或者多引數函式及返回值,這裡僅給出一個儘可能簡單的例子。

讓我們以下面的例子開始:

乍一看,這裡有點類似C語言的風格,但是不是,為表明這一點,請看接下來的多個函式呼叫例子:

好了,這裡發生了什麼?bind是一個成員函式來繫結你的函式呼叫,試著去應用它。如果錯誤裝箱裡面含有一個值,那麼它就應用於函式呼叫,繼續返回一個錯誤裝箱(編譯器不允許你返回一個不帶錯誤裝箱的函式)。

所以,我們鏈式呼叫了find_slashe,do_some_arithmetic, format和display.它們都不處理錯誤裝箱,由於bind函式的作用,我們將函式E<something_out> f(something_in)返回結果給E<something_out> f(E<something_in>)函式做引數。

這裡的好處是什麼?

再一次,函式邏輯(呼叫鏈)和錯誤處理分離了。和異常一樣,我們可以簡單讀一下函式呼叫鏈來了解程式碼邏輯,而不用關心執行是在哪裡被中斷的。事實上,函式呼叫鏈可以在任何呼叫時被中斷。但是我們可以認為沒有錯誤發生,如果我們的邏輯是正確的,可以很快速檢查。

當然,型別推導會阻止你在呼叫display之後繼續進行繫結。所以我們也沒有失去型別能力。

注意,我們沒有在其他地方呼叫這些函式,我們在最後將這些方法組裝在一起。這裡是關鍵,你應該編寫一些小的模組函式(另外,注意:你應該編寫模板函式使其工作)接收一個值,然後計算一個新值或者返回失敗。在每一步中,你都不需要考慮可能出現錯誤導致你的控制流中斷,並且校驗你是否在一個有效的狀態上(異常安全基於查詢每個函式呼叫,指出函式是否中斷你的控制流程,如果出現異常會發生什麼),基於這一點,這樣做更安全。

和異常一樣,我們可以處理很詳細的資訊,儘管這裡我們編寫的是一個偏模板函式,所以也容易理解一些。

我們可以很容易放置異常處理邏輯,把它放在函式呼叫鏈之後(除非這個返回值還需要進一步被連結)。現在,我們有一個大的的執行流,沒有中斷,使用小的函式處理流程,容易定位。當需要新增一個新的錯誤時,你只需找到那些函式,通過函式呼叫鏈,你可以直接定位到處理位置,並根據需要新增。大型專案變得更加的線性化,並且更易讀。

這樣做有什麼不足?

首先,這是一個新的處理方式,並且和C++的方式不相容。這不是一個標準處理方法,當你使用stl時,你仍然需要使用異常。

對於我來說,這樣做還是有點冗長。需要顯式編寫fail<int>(…)的模板推導顯得有點怪異,如果你有個多型錯誤型別就更糟了,你不得不這樣寫fail<return_type, error_type>("...").

當函式有多個引數時編寫也很困難,在其他一些語言中,可以使用適用型別和抽象型別很好地解決這個問題,不過這在C++中不會提供。我想更適合使用bind2(E<a>, E<b>, f)bind3(E<a>, E<b>, E<c>, f),可變模板引數功能更有用。

為獲取封裝錯誤中的值,我們需要檢查這個值是否是有效值,接著呼叫一個“to_value”方法。我們沒辦法不通過檢查來做到這一點。我們希望的是“解構”一個物件,不過這在C++中不支援,這也不是一些可以說“我們把它加入到下一個標準”的特性。

目前為止,我不知道讀者是否有方法將其適配到成員函式中,如果你有想法,請測試一下,如果可以,請告知我們。

實現原子錯誤處理

我實現了它,我定義了這個黑魔法的名字——“原子化”,你可以認為“原子化”是一個對值和錯誤上下文的裝箱,比如,一個box包含一個值或者什麼也不包含是一個原子組(這裡作為一個練習,你可以試著實現一下)。

有點奇怪的是,從某個角度來說佇列是一個原子組,他們擁有一個上下文的值。

讓我們從上面的E模版類實現開始,這裡使用了C++11標準中的decltype和auto -> decltype型別,允許自動推導得到表示式的型別,這非常有用。

這裡的bind函式有點怪異,但是他實現了我剛才提到的內容。

這裡,我過載了<<運算子,所以匯出裝箱中的內容更容易一些。我們並不是一定需要它,在“真”值時去掉這一點也更好一些。

這裡的例子,我們需要一個“E<void>”型別,但是它可能不一定使用。我們需要為void實現一個特別的過載,這裡其實也是一樣的,只不過期望的值是一個“空箱”。

我們沒有提到ret和fail方法,事實上,它們只是對xxx::fail和xxx::ret函式的封裝。

這裡,你可以編譯並執行一下上面的程式碼。

如果你想要更多的,可以試試下面這個更具體一點的例子:

相關文章