前兩篇文章討論了對象在構造過程中(建構函式)和運行過程中(成員函數)出現異常時的處理情況,本文將討論最後一種情況,當異常發生在對象的析構銷毀過程中時,又會有什麼不同呢?主人公阿愚在此可以非常有把握地告訴大家,這將會有大大的不同,而且處理不善還將會毫不留情地影響到軟體系統的可靠性和穩定性,後果非常嚴重。不危言聳聽了,看本文吧!
解構函式在什麼時候被調用執行?
對於C++程式員來說,這個問題比較簡單,但是比較愛嘮叨的阿愚還是建議應該在此再提一提,也算回顧一下C++的知識,而且這將對後面的討論和理解由一定協助。先看一個簡單的樣本吧!如下:
class MyTest_Base
{
public:
virtual ~ MyTest_Base ()
{
cout << "銷毀一個MyTest_Base類型的對象"<< endl;
}
};
void main()
{
try
{
// 構造一個對象,當obj對象離開這個範圍時析構將會被執行
MyTest_Base obj;
}
catch(...)
{
cout << "unknow exception"<< endl;
}
}
編譯運行上面的程式,從程式的運行結果將會表明對象的解構函式被執行了,但什麼時候被執行的呢?按C++標準中規定,對象應該在離開它的範圍時被調用運行。實際上各個廠商的C++編譯器也都滿足這個要求,拿VC來做個測實驗證吧!,下面列出的是剛剛上面的那個小樣本程式在調試時拷貝出的相關程式片段。注意其中obj對象將會在離開try block時被編譯器插入一段代碼,隱式地來調用對象的解構函式。如下:
325: try
326: {
00401311 mov dword ptr [ebp-4],0
327: // 構造一個對象,當obj對象離開這個範圍時析構將會被執行
328: MyTest_Base obj;
00401318 lea ecx,[obj]
0040131B call @ILT+40(MyTest_Base::MyTest_Base) (0040102d)
329:
330: } // 瞧下面,編譯器插入一段代碼,隱式地來調用對象的解構函式
00401320 lea ecx,[obj]
00401323 call @ILT+15(MyTest_Base::~MyTest_Base) (00401014)
331: catch(...)
00401328 jmp __tryend$_main$1 (00401365)
332: {
333: cout << "unknow exception"<< endl;
0040132A mov esi,esp
0040132C mov eax,[__imp_?endl@std@@YAAAV?$basic_ostream@DU?$char_traits@D@std@@@1@AAV21@@Z (0041610c)
00401331 push eax
00401332 mov edi,esp
00401334 push offset string "unknow exception" (0041401c)
00401339 mov ecx,dword ptr [__imp_?cout@std@@3V?$basic_ostream@DU?$char_traits@D@std@@@1@A (00416124)
0040133F push ecx
00401340 call dword ptr [__imp_??6std@@YAAAV?$basic_ostream@DU?$char_traits@D@std@@@0@AAV10@PBD@Z (004
00401346 add esp,8
00401349 cmp edi,esp
0040134B call _chkesp (004016b2)
00401350 mov ecx,eax
00401352 call dword ptr [__imp_??6?$basic_ostream@DU?$char_traits@D@std@@@std@@QAEAAV01@P6AAAV01@AAV01
00401358 cmp esi,esp
0040135A call _chkesp (004016b2)
334: }
0040135F mov eax,offset __tryend$_main$1 (00401365)
00401364 ret
335: }
解構函式中拋出的異常
1、仍然是先看樣本,如下:
class MyTest_Base
{
public:
virtual ~ MyTest_Base ()
{
cout << "開始準備銷毀一個MyTest_Base類型的對象"<< endl;
// 注意:在解構函式中拋出了異常
throw std::exception("在解構函式中故意拋出一個異常,測試!");
}
void Func() throw()
{
throw std::exception("故意拋出一個異常,測試!");
}
void Other() {}
};
void main()
{
try
{
// 構造一個對象,當obj對象離開這個範圍時析構將會被執行
MyTest_Base obj;
obj.Other();
}
catch(std::exception e)
{
cout << e.what() << endl;
}
catch(...)
{
cout << "unknow exception"<< endl;
}
}
程式啟動並執行結果是:
開始準備銷毀一個MyTest_Base類型的對象
在解構函式中故意拋出一個異常,測試!
從上面的程式運行結果來看,並沒有什麼特別的,在程式中首先是構造一個對象,當這個對象在離開它的範圍時,解構函式被調用,此時解構函式中拋出一個std::exception類型的異常,因此後面的catch(std::exception e)塊捕獲住這個異常,並列印出異常錯誤資訊。這個過程好像顯現出,發生在解構函式中的異常與其它地方發生的異常(如對象的成員函數中)並沒有什麼太大的不同,除了解構函式是隱式調用的以外,但這也絲毫不會影響到異常處理的機制呀!那究竟區別何在?玄機何在呢?繼續往下看吧!
2、在上面的程式基礎上做點小的改動,程式碼如下:
void main()
{
try
{
// 構造一個對象,當obj對象離開這個範圍時析構將會被執行
MyTest_Base obj;
// 下面這條語句是新添加的
// 調用這個成員函數將拋出一個異常
obj.Func();
obj.Other();
}
catch(std::exception e)
{
cout << e.what() << endl;
}
catch(...)
{
cout << "unknow exception"<< endl;
}
}
注意,修改後的程式現在的運行結果:非常的不幸,程式在控制台上列印一條語句後就崩潰了(如果程式是debug版本,會顯示一條程式將被終止的斷言;如果是release版本,程式會被執行terminate()函數後退出)。在主人公阿愚的機器上啟動並執行debug版本的程式結果如下:
許多朋友對這種結果也許會覺得傻了眼,這簡直是莫名奇妙嗎?這是誰的錯呀!難道是新添加的那條代碼的問題,但這完全不會呀!(其實很多程式員朋友受到過太多這種類似的冤枉,例如一個程式原來運行挺好的,以後進行功能擴充後,程式卻時常出現崩潰現象。其實有時程式擴充時也沒添加多少代碼,而且相關程式員也很認真仔細檢查自己添加的代碼,確認後來添加的代碼確實沒什麼問題呀!可相關的負責人也許不這麼認為,覺得程式以前一直運行挺好的,經過你這一番修改之後就出錯了,能不是你添加的代碼所導致的問題嗎?真是程式開發領域的竇娥冤呀!其實這種推理完全是沒有根據和理由的,客觀公正一點地說,程式的崩潰與後來添加的模組代碼肯定是會有一定的相關性!但真正的bug也許就在原來的系統中一直存在,只不過以前一直沒誘發表現出來而已!瞧瞧!主人公阿愚又岔題了,有感而發!還是迴歸正題吧!)
那究竟是什麼地方的問題呢?其實這實際上由於解構函式中拋出的異常所導致的,但這就更詫異了,解構函式中拋出的異常是沒有問題的呀!剛才的一個例子不是已經測試過了嗎?是的,但那隻是一種假象。如果要想使你的系統可靠、安全、長時間運行無故障,你在進行程式的異常處理設計和編碼過程中,至少要保證一點,那就是解構函式中是不永許拋出異常的,而且在C++標準中也特別聲明到了這一點,但它並沒有闡述真正的原因。那麼到底是為什麼呢?為什麼C++標準就規定解構函式中不能拋出異常?這確實是一個非常棘手的問題,很難闡述得十分清楚。不過主人公阿愚還是願意向大家論述一下它自己對這個問題的理解和想法,希望能夠與程式員朋友們達成一些理解上的共識。
C++異常處理模型是為C++語言量身設計的,更進一步的說,它實際上也是為C++語言中物件導向而服務的,我們在前面的文章中多次不厭其煩的聲明到,C++異常處理模型最大的特點和優勢就是對C++中的物件導向提供了最強大的無縫支援。好的,既然如此!那麼如果對象在運行期間出現了異常,C++異常處理模型有責任清除那些由於出現異常所導致的已經失效了的對象(也即對象超出了它原來的範圍),並釋放對象原來所分配的資源,這就是調用這些對象的解構函式來完成釋放資源的任務,所以從這個意義上說,解構函式已經變成了異常處理的一部分。不知大家是否明白了這段話所蘊含的真正內在涵義沒有,那就是上面的論述C++異常處理模型它其實是有一個前提假設——解構函式中是不應該再有異常拋出的。試想!如果對象出了異常,現在異常處理模組為了維護系統對象資料的一致性,避免資源泄漏,有責任釋放這個對象的資源,調用對象的解構函式,可現在假如析構過程又再出現異常,那麼請問由誰來保證這個對象的資源釋放呢?而且這新出現的異常又由誰來處理呢?不要忘記前面的一個異常目前都還沒有處理結束,因此這就陷入了一個矛盾之中,或者說無限的遞迴嵌套之中。所以C++標準就做出了這種假設,當然這種假設也是完全合理的,在對象的構造過程中,或許由於系統資源有限而致使對象需要的資源無法得到滿足,從而導致異常的出現,但解構函式完全是可以做得到避免異常的發生,畢竟你是在釋放資源呀!好比你在與公司續簽合約的時候向公司申請加薪,也許公司由於種種其它原因而無法滿足你的要求;但如果你主動申請不要薪水完全義務工作,公司能不樂意地答應你嗎?
假如無法保證在解構函式中不發生異常,怎麼辦?
雖然C++標準中假定了解構函式中不應該,也不永許拋出異常的。但有過的實際的軟體開發的程式員朋友們中也許會體會到,C++標準中的這種假定完全是站著講話不覺得腰痛,實際的軟體系統開發中是很難保證到這一點的。所有的解構函式的執行過程完全不發生一點異常,這根本就是天方夜譚,或者說自己欺騙自己算了。而且大家是否還有過這種體會,有時候發現析構一個對象(釋放資源)比構造一個對象還更容易發生異常,例如一個表示引用記數的控制代碼不小心出錯,結果導致資源重複釋放而發生異常,當然這種錯誤大多時候是由於程式員所設計的演算法在邏輯上有些小問題所導致的,但不要忘記現在的系統非常複雜,不可能保證所有的程式員寫出的程式完全沒有bug。因此杜絕在解構函式中決不發生任何異常的這種保證確實是有點理想化了。那麼當無法保證在解構函式中不發生異常時,該怎麼辦?我們不能眼睜睜地看著系統崩潰呀!
其實還是有很好辦法來解決的。那就是把異常完全封裝在解構函式內部,決不讓異常拋出函數之外。這是一種非常簡單,也非常有效方法。按這種方法把上面的程式再做一點改動,那麼程式將避免了崩潰的厄運。如下:
class MyTest_Base
{
public:
virtual ~ MyTest_Base ()
{
cout << "開始準備銷毀一個MyTest_Base類型的對象"<< endl;
// 一點小的改動。把異常完全封裝在解構函式內部
try
{
// 注意:在解構函式中拋出了異常
throw std::exception("在解構函式中故意拋出一個異常,測試!");
}
catch(…) {}
}
void Func() throw()
{
throw std::exception("故意拋出一個異常,測試!");
}
void Other() {}
};
程式啟動並執行結果如下:
開始準備銷毀一個MyTest_Base類型的對象
故意拋出一個異常,測試!
怎麼樣,現在是不是一切都已經風平浪靜了。
解構函式中拋出異常時概括性總結
(1) C++中解構函式的執行不應該拋出異常;
(2) 假如解構函式中拋出了異常,那麼你的系統將變得非常危險,也許很長時間什麼錯誤也不會發生;但也許你的系統有時就會莫名奇妙地崩潰而退出了,而且什麼跡象也沒有,崩得你滿地找牙也很難發現問題究竟出現在什麼地方;
(3) 當在某一個解構函式中會有一些可能(哪怕是一點點可能)發生異常時,那麼就必須要把這種可能發生的異常完全封裝在解構函式內部,決不能讓它拋出函數之外(這招簡直是絕殺!呵呵!);
(4) 主人公阿愚吐血地提醒朋友們,一定要切記上面這幾條總結,解構函式中拋出異常導致程式不明原因的崩潰是許多系統的致命內傷!