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