引入
討論C++
的異常,首先回顧一下C
語言傳統的錯誤處理方式~
- 終止程序,如
assert
。
缺陷:太過嚴苛。如發生內存錯誤 / 除0
錯誤就會終止程序。(雖然犯錯,但罪不至死吧大人~) - 返回錯誤碼。
缺陷:需要程序員自己去查找對應的錯誤。如系統的很多庫的接口函數都是通過把錯誤碼放到errno
中表示錯誤。 - C 標準庫中
setjmp
和longjmp
組合。
實際使用場景很少。
實際中C
語言基本都是使用返回錯誤碼的方式處理錯誤,部分情況下使用終止程序處理非常嚴重的錯誤。
C++異常概念
異常是一種處理錯誤的方式:當一個函數發現自己無法處理的錯誤時就可以拋出異常,讓函數的直接或間接的調用者處理這個錯誤。
throw
: 當問題出現時,程序會拋出一個異常。這是通過使用throw
關鍵字來完成的。catch
: 在想要處理問題的地方,通過異常處理程序捕獲異常catch
關鍵字用於捕獲異常,可以有多個catch
進行捕獲。try
: try 塊中的代碼標識將被激活的特定異常,它後面通常跟着一個或多個catch
塊。
如果有一個塊拋出一個異常,捕獲異常的方法會使用try
和catch
關鍵字。try
塊中放置可能拋出異常的代碼,try
塊中的代碼被稱爲保護代碼。
使用 try/catch
語句的語法如下所示:
try {
// 保護的標識代碼
}
catch( ExceptionName e1 ) {
// catch 塊 1
}catch( ExceptionName e2 ) {
// catch 塊 2
}catch( ExceptionName eN ) {
// catch 塊 3
}
使用方法
異常的拋出和捕獲
double Div(int a, int b) {
// 發生除0操作時拋出異常
if (b == 0)
throw "Division by zero condition!";
else
return ((double)a / (double)b);
}
void Func() {
int len, time;
cin >> len >> time;
cout << Div(len, time) << endl;
}
int main() {
try {
Func();
}
catch (const char* errmsg) {
cout << errmsg << endl;
}
catch (...) {
cout << "unkown exception" << endl;
}
return 0;
}
輸入:
10 0
輸出:
Division by zero condition!
異常的拋出和匹配原則:
- 異常是通過拋出對象而引發的,該對象的類型決定了應該激活哪個
catch
的處理代碼。 - 被選中的處理代碼是調用鏈中與該對象類型匹配且離拋出異常位置最近的那一個。
- 拋出異常對象後,會生成一個異常對象的拷貝,因爲拋出的異常對象可能是一個臨時對象,所以會生成一個拷貝對象,這個拷貝的臨時對象會在被
catch
以後銷燬。
(這裏的處理類似於函數的傳值返回) catch(...)
可以捕獲任意類型的異常,問題是不知道異常錯誤是什麼。...
表示不定參,一般置於最後捕獲任意類型的異常。- 實際中拋出和捕獲的匹配原則有個例外,並不都是類型完全匹配:拋出的派生類對象,可以使用基類捕獲。
在函數調用鏈中異常棧展開匹配原則:
- 首先檢查
throw
本身是否在try
塊內部,如果是再查找匹配的catch
語句。如果有匹配的,則調到catch
的地方進行處理。 - 沒有匹配的
catch
則退出當前函數棧,繼續在調用函數的棧中進行查找匹配的catch
。 - 如果到達
main
函數的棧,依舊沒有匹配的,則終止程序。上述這個沿着調用鏈查找匹配的catch
子句的過程稱爲棧展開。
所以實際中我們最後都要加一個catch(...)
捕獲任意類型的異常,否則當有異常沒捕獲,程序就會直接終止~ - 找到匹配的
catch
子句並處理以後,會繼續沿着catch
子句後面繼續執行。
異常的重新拋出
如果有這樣的場景:設定單個的catch
不能完全處理一個異常,在進行一些校正處理以後,希望再交給更外層的調用鏈函數來處理,catch
則可以通過重新拋出將異常傳遞給更上層的函數進行處理。
double Div(int a, int b) {
// 發生除0操作時拋出異常
if (b == 0)
throw "Division by zero condition!";
else
return ((double)a / (double)b);
}
void Func(){
// 這裏可以看到如果發生除0錯誤拋出異常,另外下面的array沒有得到釋放。
// 所以這裏捕獲異常後並不處理異常,異常還是交給外面處理,這裏捕獲了再重新拋出異常。
int* array = new int[10];
try {
int len, time;
cin >> len >> time;
cout << Div(len, time) << endl;
}
catch (...){ //拋出異常時的執行邏輯
cout << "delete1 []" << array << endl;
delete[] array;
throw;
}
//沒有拋出異常時的執行邏輯
cout << "delete2 []" << array << endl;
delete[] array;
}
int main(){
try{
Func();
}
catch (const char* errmsg){
cout << errmsg << endl;
}
system("pause");
return 0;
}
輸入:
10 0
輸出:
Delete1 []007BB8E0
Division by zero condition!
輸入:
10 2
輸出:
5
Delete2 []003CB8E0
異常安全
- 構造函數完成對象的構造和初始化,最好不要在構造函數中拋出異常,否則可能導致對象不完整或沒有完全初始化。
- 析構函數主要完成資源的清理,最好不要在析構函數內拋出異常,否則可能導致資源泄漏(內存泄漏、句柄未關閉等)
C++
中異常經常會導致資源泄漏的問題,比如在new
和delete
中拋出了異常,導致內存泄漏,在lock
和unlock
之間拋出了異常導致死鎖
C++經常使用RAII
來解決以上問題~
異常規範
- 異常規範說明的目的:是爲了讓函數使用者知道該函數可能拋出的異常有哪些。 可以在函數的後面接
throw(類型)
,列出這個函數可能拋擲的所有異常類型。 - 函數的後面接
throw()
,表示函數不拋異常。 - 若無異常接口聲明,則此函數可以拋擲任何類型的異常。
// 這裏表示這個函數會拋出A/B/C/D中的某種類型的異常
void fun() throw(A,B,C,D);
// 這裏表示這個函數只會拋出bad_alloc的異常
void* operator new (std::size_t size) throw (std::bad_alloc);
// 這裏表示這個函數不會拋出異常
void* operator new (std::size_t size, void* ptr) throw();
自定義異常體系
實際使用中很多公司都會自定義自己的異常體系進行規範的異常管理,因爲一個項目中如果大家隨意拋異
常,那麼外層的調用者就難以一一處理,所以實際中都會定義一套繼承的規範體系。
這樣大家拋出的都是繼承的派生類對象,捕獲一個基類就可以了。
服務器開發中通常使用的異常繼承體系:
class Exception{
protected:
string _errmsg;
int _id;
};
class SqlException : public Exception //SQL異常
{};
class CacheException : public Exception //Cache異常
{};
class HttpServerException : public Exception //HTTP服務器異常
{};
int main(){
try {
// server.Start();
// 拋出對象都是派生類對象
}
catch (const Exception& e) // 這裏捕獲父類對象就可以
{}
catch (...){
cout << "Unkown Exception" << endl;
}
return 0;
}
C++標準庫的異常體系
C++
提供了一系列標準的異常,我們可以在程序中使用這些標準的異常。它們是以父子類層次結構組織起來的。
例如:std::bad_alloc
、std::bad_cast
、std::out_of_range
等等。
實際中我們可以繼承exception
類實現自己的異常類。
但是更多情況自己定義一套異常繼承體系。因爲C++標準庫設計的不夠好用。
int main(){
try {
vector<int> v(10, 5);
// 這裏如果系統內存不夠也會拋異常 -> bad_alloc異常
v.reserve(1000000000);
// 這裏越界會拋異常
v.at(10) = 100;
}
catch (const exception& e){ // 這裏可以通過捕獲父類對象完成異常捕獲
cout << e.what() << endl;
}
catch (...){
cout << "Unkown Exception" << endl;
}
return 0;
}
C++異常的優缺點
優點
- 異常對象定義好了,相比錯誤碼的方式可以清晰準確的展示出錯誤的各種信息,甚至可以包含堆棧調用的信息(如小項目中的
TRACE_LOG
與ERROR_LOG
),這樣可以幫助更好的定位程序的bug
。 - 返回錯誤碼的傳統方式有個很大的問題就是,在函數調用鏈中,深層的函數返回了錯誤,那麼我們得層層返回錯誤,最外層才能拿到錯誤。
例:下面這段僞代碼我們可以看到ConnnectSql中出錯了,先返回給ServerStart,ServerStart再返回給
main
函數,main函數再針對問題處理具體的錯誤。
如果使用C++
異常體系,不管是ConnnectSql
還是ServerStart
及調用函數出錯都不用檢查,因爲拋出的異常異常會直接跳到main
函數中catch
捕獲的地方,main
函數直接處理錯誤!
int ConnnectSql(){
// 用戶名密碼錯誤
if (...)
return 1;
// 權限不足
if (...)
return 2;
}
int ServerStart() {
if (int ret = ConnnectSql() < 0)
return ret;
int fd = socket()
if(fd < 0)
return errno;
}
int main(){
if (ServerStart() < 0)
...
return 0;
}
- 很多的第三方庫都包含異常,比如
boost
、gtest
、gmock
等等常用的庫,那麼我們使用它們也需要使用異常。 - 很多測試框架都使用異常,這樣能更好的使用單元測試等進行白盒測試。
- 部分函數使用異常更好處理,比如構造函數沒有返回值,不方便使用錯誤碼方式處理。比如
T& operator
這樣的函數,如果pos
越界了只能使用異常或者終止程序處理,沒辦法通過返回值表示錯誤。
缺點
- 異常會導致程序的執行流亂跳,並且非常的混亂,並且是運行時出錯拋異常就會亂跳。這會導致跟蹤調試時以及分析程序時,比較困難。
- 異常會有一些性能開銷。當然在現代硬件速度很快的情況下,這個影響基本忽略不計~
C++
沒有GC
(垃圾回收機制),資源需要自己管理。有了異常非常容易導致內存泄漏、死鎖等異常安全問題。這個需要使用RAII
來處理資源的管理問題。學習成本較高。C++
標準庫的異常體系定義得不好,導致大家各自定義各自的異常體系,非常的混亂。
總結
異常儘量規範使用,否則後果不堪設想,隨意拋異常,外層捕獲的用戶苦不堪言。。。
所以異常規範有兩點:
- 拋出異常類型都繼承自一個基類。
- 函數是否拋異常、拋什麼異常,都使用
func() throw();
的方式規範化,利人利己。
異常總體而言還是瑕不掩瑜的,所以平時實際工程中也還是鼓勵使用異常的。另外OO
(面向對象)的語言基本都是用異常處理錯誤,這也可以看出這是大勢所趨!