引言
異常,讓一個函式可以在發現自己無法處理的錯誤時丟擲一個異常,希望它的呼叫者可以直接或者間接處理這個問題。而傳統錯誤處理技術,檢查到一個區域性無法處理的問題時:
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++程式設計語言的異常處理。