c++ 從析構函數中拋出異常




std:: exception (13)

大多數人說永遠不會從析構函數中拋出異常 - 這樣做會導致未定義的行為。 Stroustrup指出: “向量析構函數明確地為每個元素調用析構函數,這意味著如果一個元素析構函數拋出,向量銷毀失敗......真的沒有什麼好辦法來防止從析構函數中拋出異常,所以庫如果元素析構函數拋出不作任何保證“(來自附錄E3.2)

這篇文章似乎另有說法 - 拋出析構函數或多或少都可以。

所以我的問題是 - 如果從析構函數中拋出導致未定義的行為,那麼如何處理析構函數期間發生的錯誤?

如果在清理操作過程中發生錯誤,您是否忽略它? 如果它是一個可能在堆棧中處理的錯誤,但在析構函數中不正確,那麼將異常拋出析構函數沒有意義嗎?

顯然這些錯誤是罕見的,但可能的。


從ISO草案C ++(ISO / IEC JTC 1 / SC 22 N 4411)

所以析構函數通常應該捕獲異常,而不是讓它們從析構函數中傳播出去。

3從try塊到throw-expression的路徑上構造的自動對象調用析構函數的過程稱為“堆棧展開”。[注意:如果在堆棧展開過程中調用的析構函數退出時發生異常,則調用std :: terminate (15.5.1)。 所以析構函數通常應該捕獲異常,而不是讓它們從析構函數中傳播出去。 - 結束註釋]


問自己關於從析構函數拋出的真正問題是“調用者可以用這個來做什麼?” 有沒有什麼有用的,你可以做的例外,這將抵消從析構函數拋出創建的危險?

如果我摧毀了一個Foo對象,並且Foo析構函數拋出了一個異常,我可以合理地使用它嗎? 我可以記錄它,或者我可以忽略它。 就這樣。 我不能“修復”它,因為Foo像已經消失了。 最好的情況下,我記錄異常並繼續,如果沒有任何事情發生(或終止程序)。 通過從析構函數中拋出是否真的有可能導致未定義的行為?


Martin Ba(上圖)正處於正確的軌道 - 您對RELEASE和COMMIT邏輯的構造方式不同。

發布:

你應該吃任何錯誤。 您正在釋放內存,關閉連接等。系統中的其他人都不會再看到這些事情,並且您將資源交還給操作系統。 如果看起來你需要真正的錯誤處理,這可能是你的對像模型中的設計缺陷的後果。

對於Commit:

這就是你想要的類似RAII包裝對象的地方,像std :: lock_guard一樣提供互斥體。 與那些你不把提交邏輯放在Dtor AT ALL中。 你有一個專用的API,然後包裝對象,RAII將它提交給它們並處理那裡的錯誤。 請記住,你可以在析構函數中捕獲異常就好了; 它發布它們是致命的。 這也可以讓你實現策略和不同的錯誤處理,只需構建一個不同的包裝器(例如std :: unique_lock vs std :: lock_guard),並確保你不會忘記調用提交邏輯 - 這是唯一的中途有理由將它放在第一名的位置。


我在這個小組中認為,在許多情況下,拋出析構函數的“範圍守衛”模式很有用 - 特別是對於單元測試。 但是,請注意,在C ++ 11中,拋出析構函數會導致對std::terminate的調用,因為析構函數會使用noexcept隱式註釋。

AndrzejKrzemieński在有關析構函數的話題上發表了一篇很棒的文章:

他指出C ++ 11有一個機制來覆蓋析構函數的默認noexcept

在C ++ 11中,析構函數隱式指定為noexcept 。 即使你沒有添加任何規範並且像這樣定義你的析構函數:

  class MyType {
        public: ~MyType() { throw Exception(); }            // ...
  };

編譯器將仍然無形地將規範noexcept添加到您的析構函數中。 這意味著當析構函數拋出異常時,即使沒有雙重異常情況, std::terminate也會被調用。 如果你真的決定允許你的析構函數拋出,你必須明確地指定它; 你有三個選擇:

  • 顯式指定你的析構函數為noexcept(false)
  • 從另一個已經指定其析構函數的類繼承noexcept(false)
  • 將已經指定其析構函數的非靜態數據成員放入您的類中作為noexcept(false)

最後,如果你決定拋出析構函數,你應該始終注意到雙重異常的風險(拋出堆棧時由於異常而退出)。 這會導致對std::terminate的調用,而這很少是你想要的。 為了避免這種行為,你可以在使用std::uncaught_exception()拋出一個新的之前檢查是否已經有一個異常。


所以我的問題是 - 如果從析構函數中拋出導致未定義的行為,那麼如何處理析構函數期間發生的錯誤?

主要問題是:你不能失敗 。 畢竟,失敗意味著什麼? 如果向數據庫提交事務失敗,並且失敗(無法回滾),那麼數據的完整性會發生什麼變化?

由於析構函數是針對正常和異常(失敗)路徑調用的,因此它們本身不會失敗,否則我們“無法失敗”。

這是一個概念上難以解決的問題,但通常解決方法是找到一種方法來確保失敗不會失敗。 例如,數據庫可能會在提交到外部數據結構或文件之前寫入更改。 如果事務失敗,那麼可以拋棄文件/數據結構。 所有它必須確保的是,提交來自該外部結構/文件的更改是不可能失敗的原子事務。

務實的解決方案或許只是確保失敗失敗的可能性是天文數字不可能的,因為在某些情況下使事情不可能失敗是幾乎不可能的。

對我來說最合適的解決方案是編寫非清理邏輯,使清理邏輯不會失敗。 例如,如果您想要創建一個新的數據結構來清理現有的數據結構,那麼您可能會試圖提前創建該輔助結構,以便我們不必再在析構函數內創建它。

誠然,說起來容易做起來容易,但這是我看到的唯一真正適合的方式。 有時我認為應該有能力為正常執行路徑編寫單獨的析構函數邏輯,以避免出現異常情況,因為有時析構函數會感覺有點像他們通過嘗試處理這兩個函數來承擔雙重責任(例如需要明確解僱的範圍守護程序;如果他們能夠區分特殊的銷毀途徑和非特殊的銷毀路徑,他們就不會要求這樣)。

仍然最終的問題是我們不能失敗,而且在所有情況下都是完美解決的概念設計難題。 如果你不太複雜的控制結構中包含大量的小物件,它們會變得更容易,而這些結構中的很多小物件會相互作用,而是以稍大一點的方式模擬您的設計(例如:帶有析構器的粒子系統來摧毀整個粒子系統,而不是每個粒子的單獨的非平凡析構函數)。 當你在這種較粗糙的級別上設計你的設計時,你需要處理更少的不平凡的析構函數,並且通常也可以承擔任何需要的內存/處理開銷,以確保析構函數不會失敗。

這是最簡單的解決方案之一,自然是不經常使用析構函數。 在上面的粒子示例中,也許在摧毀/去除粒子時,應該完成一些可能因任何原因而失敗的事情。 在這種情況下,不是通過粒子的dtor調用這種可以在特殊路徑上執行的邏輯,而是可以在粒子系統移除粒子時由粒子系統完成。 去除粒子可能總是在非常規路徑中完成。 如果系統被破壞,也許它可以清除所有的粒子,而不用去掉可能失效的單個粒子去除邏輯,而只有在粒子系統正常執行時才會執行可能失敗的邏輯,當它移除一個或多個粒子時。

如果避免使用不平凡的析構函數處理大量小型對象,那麼通常會出現類似的解決方案。 在那裡你可能會陷入一團糟,看起來幾乎不可能是異常 - 安全是當你糾纏在許多小巧的物體上時,它們都有非平凡的時間。

如果nothrow / noexcept實際上被翻譯成編譯器錯誤,如果任何指定它的東西(包括應該繼承其基類的noexcept規範的虛擬函數)試圖調用任何可能拋出的東西,它會有很大幫助。 這樣我們就可以在編譯時捕獲所有這些東西,如果我們實際上無意中編寫了一個可能拋出的析構函數。


拋出析構函數可能會導致崩潰,因為此析構函數可能被稱為“堆棧展開”的一部分。 堆棧展開是拋出異常時發生的過程。 在這個過程中,所有從“try”開始直到拋出異常被推入堆棧的對象將被終止 - >它們的析構函數將被調用。 在這個過程中,另一個異常拋出是不允許的,因為一次不能處理兩個異常,因此這會引發一個調用abort(),程序將崩潰,控制權將返回到操作系統。


從析構函數中拋出異常是很危險的。
如果另一個異常已經傳播,應用程序將終止。

#include <iostream>

class Bad
{
    public:
        // Added the noexcept(false) so the code keeps its original meaning.
        // Post C++11 destructors are by default `noexcept(true)` and
        // this will (by default) call terminate if an exception is
        // escapes the destructor.
        //
        // But this example is designed to show that terminate is called
        // if two exceptions are propagating at the same time.
        ~Bad() noexcept(false)
        {
            throw 1;
        }
};
class Bad2
{
    public:
        ~Bad2()
        {
            throw 1;
        }
};


int main(int argc, char* argv[])
{
    try
    {
        Bad   bad;
    }
    catch(...)
    {
        std::cout << "Print This\n";
    }

    try
    {
        if (argc > 3)
        {
            Bad   bad; // This destructor will throw an exception that escapes (see above)
            throw 2;   // But having two exceptions propagating at the
                       // same time causes terminate to be called.
        }
        else
        {
            Bad2  bad; // The exception in this destructor will
                       // cause terminate to be called.
        }
    }
    catch(...)
    {
        std::cout << "Never print this\n";
    }

}

這基本上歸結為:

任何危險的(即可能拋出異常)都應該通過公共方法(不一定是直接)完成。 然後,您的類的用戶可以通過使用公共方法並捕獲任何潛在的異常來處理這些情況。

然後析構函數將通過調用這些方法來完成對象(如果用戶沒有明確這樣做),但是任何異常拋出都會被捕獲並丟棄(在嘗試修復問題之後)。

所以實際上你將責任傳遞給用戶。 如果用戶能夠糾正異常,他們將手動調用相應的功能並處理任何錯誤。 如果對象的用戶不擔心(因為對象將被銷毀),那麼析構函數被留下來處理業務。

一個例子:

的std :: fstream的

close()方法可能會引發異常。 如果文件已被打開,析構函數調用close(),但確保任何異常不會傳播出析構函數。

因此,如果文件對象的用​​戶想要對與關閉文件相關的問題進行特殊處理,他們將手動調用close()並處理任何異常。 另一方面,如果他們不在乎,那麼解析器將被留下來處理這種情況。

Scott Myers在他的書“Effective C ++”中有一篇關於該主題的優秀文章,

編輯:

顯然,在“更有效的C ++”
項目11:防止異常離開析構函數


與構造函數不同的是,拋出異常可能是指示對象創建成功的有用方法,不應在析構函數中拋出異常。

在堆棧展開過程中從析構函數拋出異常時會發生此問題。 如果發生這種情況,編譯器會處於不知道是繼續堆棧展開過程還是處理新的異常的情況。 最終結果是您的程序將立即終止。

因此,最好的行動方式就是完全放棄在析構函數中使用異常。 改寫消息到日誌文件。


作為對主要答案的補充,這些答案是好的,全面的和準確的,我想對你引用的文章發表評論 - 說“在析構函數中拋出異常並不是那麼糟糕”。

文章採用了“拋出異常的替代方法是什麼”這一行,並列出了每個替代方案的一些問題。 這樣做後得出結論,因為我們找不到無問題的選擇,所以我們應該繼續拋出異常。

麻煩的是,它所列出的替代品的問題都不像異常行為那麼糟糕,我們記得它是“未定義的程序行為”。 作者的一些反對意見包括“審美醜陋”和“鼓勵不良風格”。 現在你想要哪一個? 一個風格不好的程序,或者一個表現出不確定行為的程序?


你的析構函數可能在其他析構函數的鏈中執行。 拋出未被直接調用者捕獲的異常可能會使多個對象處於不一致狀態,從而導致更多問題,從而忽略清理操作中的錯誤。


我們必須在此區分 ,而不是盲目地遵循針對具體情況的一般建議。

請注意,以下內容忽略了對象容器的問題以及面對容器內多個對象時要做什麼。 (部分對象可能會被忽略,因為有些對像不適合放入容器。)

當我們以兩種類型拆分類時,整個問題變得更容易思考。 一個類可以有兩個不同的職責:

  • (R)釋放語義(aka釋放內存)
  • (C) 提交語義(即flush file到磁盤)

如果我們用這種方式來看待這個問題,那麼我認為可以認為(R)語義不應該導致一個例外,因為存在這樣一個例外:我們無法做到這一點,並且b)許多自由資源操作不甚至提供錯誤檢查,例如void free(void* p);

具有(C)語義的對象,例如需要成功刷新其數據的文件對像或在Dtor中執行提交的(“範圍防護”)數據庫連接,具有不同的類型:我們可以對錯誤進行操作應用程序級別),我們真的不應該繼續,因為沒有任何事情發生。

如果我們遵循RAII路線並且允許在它們的角色中具有(C)語義的對象,那麼我認為我們還必須考慮到這種情況可能出現的奇怪情況。 由此可見,不應該將這些對象放入容器中,而且如果在另一個異常處於活動狀態時拋出commit-dtor,程序仍然可以terminate()

關於錯誤處理(提交/回滾語義)和異常, Andrei Alexandrescu有一個很好的演講: C ++ /聲明控制流程中的錯誤處理 (在NDC 2014舉辦)

在細節中,他解釋了Folly庫如何為ScopeGuard工具實現UncaughtExceptionCounter

(我應該注意到others也有類似的想法。)

雖然談話並不關注擲骰子,但它展示了一種工具,可以用來擺脫拋出時間的問題

未來 ,這可能有一個標準功能, 請參閱N3614並對此進行討論

更新'17:C ++ 17標準功能是std::uncaught_exceptions afaikt。 我會盡快引用cppref文章:

筆記

使用int -returning uncaught_exceptions的示例是......首先創建一個警戒對象,並在其構造函數中記錄未捕獲異常的數量。 輸出由guard對象的析構函數執行,除非foo()拋出( 在這種情況下,析構函數中的未捕獲異常的數量大於構造函數觀察到的數量


它是危險的,但從可讀性/代碼可理解性的角度來看,它也沒有意義。

你必須要問的是在這種情況下

int foo()
{
   Object o;
   // As foo exits, o's destructor is called
}

什麼應該抓住例外? 應該是foo的調用者嗎? 或者應該處理它? foo的調用者為什麼應該關心foo內部的某個對象? 這種語言可能有一種定義這是有道理的,但它會變得難以理解和難以理解。

更重要的是,Object的內存何去何從? 對象擁有的內存在哪裡去? 它仍然分配(表面上是因為析構函數失敗)? 考慮到對像是在堆棧空間 ,所以它顯然不管。

然後考慮這種情況

class Object
{ 
   Object2 obj2;
   Object3* obj3;
   virtual ~Object()
   {
       // What should happen when this fails? How would I actually destroy this?
       delete obj3;

       // obj 2 fails to destruct when it goes out of scope, now what!?!?
       // should the exception propogate? 
   } 
};

當刪除obj3失敗時,我如何以確保不會失敗的方式實際刪除? 它是我的記憶!

現在考慮在第一個代碼片段中,Object自動消失,因為它在堆棧上,而Object3在堆上。 由於指向Object3的指針消失了,你就是SOL。 你有內存洩漏。

現在做一件安全的事情是以下幾點

class Socket
{
    virtual ~Socket()
    {
      try 
      {
           Close();
      }
      catch (...) 
      {
          // Why did close fail? make sure it *really* does close here
      }
    } 

};

另請參閱此FAQ


其他人已經解釋了為什麼拋出析構函數是可怕的...你可以做些什麼呢? 如果您正在執行可能失敗的操作,請創建一個單獨的公用方法來執行清理並可以拋出任意異常。 在大多數情況下,用戶會忽略這一點。 如果用戶想要監視清理的成功/失敗,他們可以簡單地調用顯式清理例程。

例如:

class TempFile {
public:
    TempFile(); // throws if the file couldn't be created
    ~TempFile() throw(); // does nothing if close() was already called; never throws
    void close(); // throws if the file couldn't be deleted (e.g. file is open by another process)
    // the rest of the class omitted...
};




raii