引言
異常,讓一個函數可以在發現自己無法處理的錯誤時拋出一個異常,希望它的調用者可以直接或者間接處理這個問題。而傳統錯誤處理技術,檢查到一個局部無法處理的問題時:
1.終止程式(例如atol,atoi,輸入NULL,會產生段錯誤,導致程式異常退出,如果沒有core檔案,找問題的人一定會發瘋)
2.返回一個表示錯誤的值(很多系統函數都是這樣,例如malloc,記憶體不足,分配失敗,返回NULL指標)
3.返回一個合法值,讓程式處於某種非法的狀態(最坑爹的東西,有些第三方庫真會這樣)
4.調用一個預先準備好在出現"錯誤"的情況下用的函數。
第一種情況是不允許的,無條件終止程式的庫無法運用到不能當機的程式裡。第二種情況,比較常用,但是有時不合適,例如返回錯誤碼是int,每個調用都要檢查錯誤值,極不方便,也容易讓程式規模加倍(但是要精確控制邏輯,我覺得這種方式不錯)。第三種情況,很容易誤導調用者,萬一調用者沒有去檢查全域變數errno或者通過其他方式檢查錯誤,那是一個災難,而且這種方式在並發的情況下不能很好工作。至於第四種情況,本人覺得比較少用,而且回調的代碼不該多出現。
使用異常,就把錯誤和處理分開來,由庫函數拋出異常,由調用者捕獲這個異常,調用者就可以知道程式函數庫調用出現錯誤了,並去處理,而是否終止程式就把握在調用者手裡了。
但是,錯誤的處理依然是一件很困難的事情,C++的異常機製為程式員提供了一種處理錯誤的方式,使程式員可以更自然的方式處理錯誤。
異常實戰入門
假設我們寫一個程式,把使用者輸入的兩個字串轉換為整數,相加輸出,一般我們會這麼寫
char *str1 = "1", *str2 = "2";int num1 = atoi(str1);int num2 = atoi(str2);printf("sum is %d\n", num1 + num2);
假設使用者輸入的是str1,str2,如果str1和str2都是整數類型的字串,這段代碼是可以正常工作的,但是使用者的輸入有可能誤操作,輸入了非法字元,例如
char *str1 = "1", *str2 = "a";int num1 = atoi(str1);int num2 = atoi(str2);printf("sum is %d\n", num1 + num2);
這個時候結果是1,因為atoi(str2)返回0。
如果使用者輸入是這樣:
char *str1 = "1", *str2 = NULL;int num1 = atoi(str1);int num2 = atoi(str2);printf("sum is %d\n", num1 + num2);
那麼這段代碼會出現段錯誤,程式異常退出。
atoi我覺得是一個比較危險的函數,如果在一個重要系統中,調用者不知情,傳入了一個NULL字元,程式就異常退出了,導致服務中斷,或者傳入非法字元,結果返回0,代碼繼續走下去,在複雜的系統中想要定位這個問題,真是很不容易。
所以比較合適的方式,是我們用異常處理改造一個安全的atoi方法,叫parseNumber。
class NumberParseException {};bool isNumber(char * str) { using namespace std; if (str == NULL) return false; int len = strlen(str); if (len == 0) return false; bool isaNumber = false; char ch; for (int i = 0; i < len; i++) { if (i == 0 && (str[i] == '-' || str[i] == '+')) continue; if (isdigit(str[i])) { isaNumber = true; } else { isaNumber = false; break; } } return isaNumber;}int parseNumber(char * str) throw(NumberParseException) { if (!isNumber(str)) throw NumberParseException(); return atoi(str);}
上述代碼中NumberParseException是自訂的異常類,當我們檢測的時候傳入的str不是一個數字時,就拋出一個數字轉換異常,讓調用者處理錯誤,這比傳入NULL字串,導致段錯誤結束程式好得多,調用者可以捕獲這個異常,決定是否結束程式,也比傳入一個非整數字串,返回0要好,程式出現錯誤,卻繼續無聲無息執行下去。
於是我們之前寫的代碼可以改造如下:
char *str1 = "1", *str2 = NULL; try { int num1 = parseNumber(str1); int num2 = parseNumber(str2); printf("sum is %d\n", num1 + num2); } catch (NumberParseException) { printf("輸入不是整數\n"); }
這段代碼的結果是列印出"輸入不是整數".假設這段代碼是運行在一個遊戲統計系統中,系統需要定時從大量檔案中統計大量使用者進入遊戲頻道1和遊戲頻道2的次數,str1代表進入遊戲頻道1的次數,str2表示進入頻道2的次數,如果不是使用異常,當輸入是NULL程式會導致整個系統宕機,當輸入是非法整數,計算結果全部是錯誤的,當時程式仍然無聲無息"正確執行"。
輸入非法,拋出NumberParseException,即使調用者沒有考慮輸入是非法的,例如是:
char *str1 = "1", *str2 = "12,"; int num1 = parseNumber(str1); int num2 = parseNumber(str2); printf("sum is %d\n", num1 + num2);
就算調用者比較粗心,沒有捕獲異常,程式運行中會拋出NumberParseException,程式宕機,會留下coredump檔案,調用者通過"gdb 程式名 coredump檔案",查看程式宕機時的堆棧,就知道程式運行中,出現了非法整數字元,那麼他就很快知道問題所在,會學乖,把上述代碼改成
char *str1 = "1", *str2 = NULL; try { int num1 = parseNumber(str1); int num2 = parseNumber(str2); printf("sum is %d\n", num1 + num2); } catch (NumberParseException) { printf("輸入不是整數\n"); //列印檔案的路徑,行號,str1,str2等資訊足夠自己去定位問題所在 }
這樣,下次程式出現問題時,調用者就可以定位問題所在了,這就是異常的錯誤處理方式,把錯誤的發現(parseNumber)和錯誤的處理(遊戲統計代碼)分開。
這裡介紹了異常的拋出和捕獲,還有異常的使用情境,接下來就開始一步步講解C++異常。
異常的描述
函數和函數可能拋出的異常集合作為函式宣告的一部分是有價值的,例如
void f(int a) throw (x2,x3);
表示f()只能拋出兩個異常x2,x3,以及這些類型派生的異常,但不會拋出其他異常。如果f函數違反了這個規定,拋出了x2,x3之外的異常,例如x4,那麼當函數f拋出x4異常時,
會轉換為一個std::unexpected()調用,預設是調用std::terminate(),通常是調用abort()。
如果函數不帶異常描述,那麼假定他可能拋出任何異常。例如:
int f(); //可能拋出任何異常
帶任何異常的函數可以用空表表示:
int g() throw (); // 不會拋出任何異常
捕獲異常
捕獲異常的代碼一般如下:
try { throw E();}catch (H h) { //何時我們可以能到這裡呢}
1.如果H和E是相同的類型
2.如果H是E的基類
3.如果H和E都是指標類型,而且1或者2對它們所引用的類型成立
4.如果H和E都是參考型別,而且1或者2對H所引用的類型成立
從原則上來說,異常在拋出時被複製,我們最後捕獲的異常只是原始異常的一個副本,所以我們不應該拋出一個不允許拋出一個不允許複製的異常。
此外,我們可以在用於捕獲異常的類型加上const,就像我們可以給函數加上const一樣,限制我們,不能去修改捕捉到的那個異常。
還有,捕獲異常時如果H和E不是參考型別或者指標類型,而且H是E的基類,那麼h對象其實就是H h = E(),最後捕獲的異常對象h會丟失E的附加攜帶資訊。
異常處理的順序
我們之前寫的parseNumber函數會拋出NumberParseException,這個函數只是判斷是否數字才拋出異常,但是沒有考慮,但這個字串表示的整數太大,溢出,拋出異常Overflow.表示如下:
class NumberParseException {};class Overflow : public NumberParseException {};
假設我們parseNumber函數已經為字串的整數溢出做了檢測,遇到這種情況,會拋出Overflow異常,那麼異常捕獲代碼如下:
char *str1 = "1", *str2 = NULL; try { int num1 = parseNumber(str1); int num2 = parseNumber(str2); printf("sum is %d\n", num1 + num2); } catch (Overflow) { //處理Overflow或者任何由Overflow派生的異常 } catch (NumberParseException) { //處理不是Overflow的NumberParseException異常 }
異常組織這種階層對於代碼的健壯性很重要,因為庫函數發布之後,不可能不加入新的異常,就像我們的parseNumber,第一次發布時只是考慮輸入是否一個整數的錯誤,第二次發布時就考慮了判斷輸入的一個字串作為整數是否太大溢出,對於一個函數發布之後不再添加新的異常,幾乎所有的庫函數都不能接受。
如果沒有異常的階層,當函數升級加入新的異常描述時,我們可能都要修改代碼,為每一處調用這個函數的地方加入對應的catch新的異常語句,這很讓你厭煩,程式員也很容易忘記把某個異常加入列表,導致這個異常沒有捕獲,異常退出。
而有了異常的階層,函數升級之後,例如我們的parseNumber加入了Overflow異常描述,函數調用者只需要在自己感興趣的調用情境加入catch(Overflow),並做處理就行了,如果根據不關心Overflow錯誤,甚至不用修改代碼。
未捕獲的異常
如果拋出的異常未被捕捉,那麼就會調用函數std::terminate(),預設情況是調用abort,這對於大部分使用者是正確選擇,特別是排錯程式錯誤的階段(調用abort會產生coredump檔案,coredump檔案的使用可以參考部落格的"學會用core dump偵錯工具錯誤")。
如果我們希望在發生未捕獲異常時,保證清理工作,可以在所有真正需要關注的異常處理之外,再在main添加一個捕捉一切的異常處理,例如:
int main() { try { //... } catch (std::range_error) { cerr << "range error\n"; } catch (std::bad_alloc) { cerr << "new run out of memory\n"; } catch (...) { //.. }}
這樣就可以捕捉所有的異常,除了那些在全域變數構造和析構的異常(如果要獲得控制,唯一方式是set_unexpected)。
其中catch(...)表示捕捉所有異常,一般會在處理代碼做一些清理工作。
重新拋出
當我們捕獲了一個異常,卻發現無法處理,這種情況下,我們會做完局部能夠做的事情,然後再一次拋出這個異常,讓這個異常在最合適的地方地方處理。例如:
void downloadFileFromServer() { try { connect_to_server(); //... } catch (NetworkException) { if (can_handle_it_completely) { //處理網路異常,例如重連 } else { throw; } }}
這個函數是從遠程伺服器下載檔案,內部調用串連到遠程伺服器的函數,但是可能存在著網路異常,如果多次重連無法成功,就把這個網路異常拋出,讓上層處理。
重新拋出是採用不帶運算對象的throw表示,但是如果重新拋出,又沒有異常可以重新拋出,就會調用terminate();
假設NetworkException有兩個派生異常叫FtpConnectException和HttpConnectException,調用connect_to_server時是拋出HttpConnectException,那麼調用downloadFileFromServer仍然能捕捉到異常HttpConnectException。
標準異常
到了這裡,你已經基本會使用異常了,可是如果你是函數開發人員,並需要把函數給別人使用,在使用異常時,會涉及到自訂異常類,但是C++標準已經定義了一部分標準異常,請儘可能複用這些異常,標準異常參考http://www.cplusplus.com/reference/std/stdexcept/
雖然C++標準異常比較少,但是作為函數開發人員,儘可能還是複用c++標準異常,作為函數調用者就可以少花時間去瞭解的你自訂的異常類,更好的去調用你開發的函數。
總結
本文只是簡單從異常的使用情境,再介紹異常的基本使用方法,一些進階的異常用法沒有羅列,詳細資料可以參考c++之父的C++程式設計語言的異常處理。