天天看點

C++ 析構函數中抛出的異常

前兩篇文章讨論了對象在構造過程中(構造函數)和運作過程中(成員函數)出現異常時的處理情況,本文将讨論最後一種情況,當異常發生在對象的析構銷毀過程中時,又會有什麼不同呢?主人公阿愚在此可以非常有把握地告訴大家,這将會有大大的不同,而且處理不善還将會毫不留情地影響到軟體系統的可靠性和穩定性,後果非常嚴重。不危言聳聽了,看正文吧!

  析構函數在什麼時候被調用執行?

  對于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,[[email protected]@@YA[email protected][email protected]@std@@@[email protected]@@Z (0041610c)

  00401331 push eax

  00401332 mov edi,esp

  00401334 push offset string "unknow exception" (0041401c)

  00401339 mov ecx,dword ptr [__imp_?co[email protected]@@[email protected][email protected]@std@@@[email protected] (00416124)

  0040133F push ecx

  00401340 call dword ptr [__imp_??6std@@[email protected][email protected]@std@@@[email protected]@[email protected] (004

  00401346 add esp,8

  00401349 cmp edi,esp

  0040134B call _chkesp (004016b2)

  00401350 mov ecx,eax

  00401352 call dword ptr [__imp_??6?$ba[email protected][email protected]@std@@@std@@[email protected]@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) 主人公阿愚吐血地提醒朋友們,一定要切記上面這幾條總結,析構函數中抛出異常導緻程式不明原因的崩潰是許多系統的緻命内傷!