1. 程式人生 > 其它 >vs 不能自動 解構函式_Effective C++讀書筆記(8): 解構函式不能丟擲異常

vs 不能自動 解構函式_Effective C++讀書筆記(8): 解構函式不能丟擲異常

技術標籤:vs 不能自動 解構函式程式設計 ul 不能一行顯示 跳到下行返回主函式訪問衝突

守則08: 不要讓異常從解構函式裡跑出去

"Prevent exceptions from leaving destructors"

本篇關鍵詞:棧展開,解構函式,異常處理


在步入正題前,我們先來講講什麼叫棧展開(stack unwinding),才能更好理解C++異常(exception)的機制是怎樣運作的:

void f1() throw(int){           //函式f1會丟擲一個整型的異常程式碼
  cout<<"f1 starts"<<endl;
  int i;                       //這個變數會在棧展開的過程中被釋放資源
  throw 100;                   //丟擲異常,程式開始在棧中搜索對應的異常處理器,即開始棧展開
  cout<<"f1 ends"<<endl;       //這行程式碼不會被執行
}

void f2 throw(int){            //函式f2呼叫了f1,所以丟擲異常的型別也是整型
  cout<<"f2 starts"<<endl;
  int j;                      //這個變數也會在棧展開的過程中被釋放資源
  f1();                       //f1沒有搜尋到對應的異常處理,因此返回到f2搜尋
  cout<<"f2 ends"<<endl;      //這行程式碼也不會被執行
}

void f3(){
  cout<<"f3 starts"<<endl;
  try{                        //函式f3在try裡呼叫f2,並可能會catch一個整型的異常
    f2();
  }catch(int i){              //f2也沒有找到異常處理,最後返回了f3並找到了異常處理
    cout<<"exception "<<i<<endl;
  }
  cout<<"f3 ends"<<endl;
}

int main(){
  f3();
  return 0;
}

在C++裡,當有異常被丟擲,呼叫棧(call stack),即棧中用來儲存函式呼叫資訊的部分,會被按次序搜尋,直到找到對應型別的處理程式(exception handler)。而這裡的搜尋順序就是f1->f2->f3。f1沒有對應型別的catch塊,因此跳到了f2,但f2也沒有對應型別的catch塊,因此跳到f3才能處理掉這個異常。

以上這個尋找異常相應型別處理器的過程就叫做棧展開。同時在這一過程中,當從f1返回到f2時,f1裡區域性變數的資源會被清空,即呼叫了物件的解構函式。同樣,在從f2返回到f3時,f2裡區域性變數也會被呼叫解構函式並清空資源。


現在可以回到正題了,C++並不阻止在類的解構函式中丟擲異常,但這是一個非常不好的做法!因為棧展開的前提是已經有一個未處理的異常,並且棧展開會自動呼叫函式本地物件的解構函式,如果這時物件的解構函式時又丟擲一個異常,現在就同時有兩個異常出現,但C++最多隻能同時處理一個異常,因此程式這時會自動呼叫std::terminate()函式,導致我們所謂的閃退或者崩潰。

此外,如下的栗子也會導致程式同時出現多個異常:

class Widget{
  public:
    ...
    ~Widget(){...}        //假設此解構函式可能會丟擲異常
};

void doSomething(){
  std::vector<Widget> v;
}                         //在這一行呼叫了v的解構函式,資源被釋放

當v被呼叫解構函式,它包含的所有Widget物件也都會被呼叫解構函式。又因為v是一個容器,如果在釋放第一個元素時觸發了異常,它也只能繼續釋放別的元素,否則會導致其它元素的資源洩露。如果在釋放第二個元素的時候又觸發了異常,那麼程式同樣會導致崩潰。

不僅僅是std::vector,所有STL容器的類甚至包括陣列也都會像這樣因為解構函式丟擲異常而崩潰程式,所以在C++中,不要讓解構函式丟擲異常!


但是如果解構函式所使用的程式碼可能無法避免丟擲異常呢?我們再來看一個栗子:

class DBConnection{                   //某用來建立資料庫連線的類
  public:
    ...
    static DBConnection create();     //建立一個連線
    void close();                     //關閉一個連線,假設可以丟擲異常
};

class DBConn{                         //建立一個資源管理類來提供更好的使用者介面
  public:
    ....
    ~DBConn{ db.close(); ]            //終止時自動呼叫關閉連線的方法
  private:
    DBConnection db;
};


...{                                 
  DBConn dbc(DBConnection::create()); //建立一個DBConn類的物件
  ...                                 //使用這個物件
}                                     //物件dbc被釋放資源
                                      //但它的解構函式呼叫了可能會丟擲異常的close()方法

我們通過DBConn的解構函式來釋放資源並關閉連線,但解構函式所呼叫的close()方法可能會丟擲異常,那麼有什麼方法來解決呢?

  • 消化掉這個異常
DBConn::~DBConn(){
  try{ 
    db.close();
  }catch(...){
    //記錄訪問歷史
  }
}

棧展開的過程終止於異常被對應型別的catch塊接到,因此在這種情況下,只要catch包括了所有可能的異常,解構函式就能消化掉這個異常,防止異常從解構函式裡跑出來,和別的異常產生衝突。

但這樣做法的缺點是可能會給程式的穩定執行帶來隱患,因為當某些比較嚴重或者不能處理的異常發生時,我們繼續讓程式執行,就可能導致程式的未知行為。當然如果能保證所有異常都能被正確處理,程式能繼續穩定執行,就可以使用這個方法。

  • 主動關閉程式
DBConn::~DBConn(){
  try{ 
    db.close();
  }catch(...){
    //記錄訪問歷史
    std::abort();
  }
}

通過std::abort()函式來主動關閉程式,而不是任由程式在某個隨機時刻突然崩潰,這樣能減少潛在的使用者風險。對於某些比較嚴重的異常,就可以使用這個方法。並且我們可以結合使用上面的方法,把能處理的異常消化掉。

但這些做法治標不治本,只能當做plan B,我們再來看一個更好的方法:

  • 把可能丟擲異常的程式碼移出解構函式

我們設計DBConn類的更安全的介面,讓其他函式來承擔這個風險,而且這樣也可以事先在解構函式這樣的緊要關頭前對異常做出處理。

class DBConn{
  public:
    ...
    ~DBConn();
    void close();        //當要關閉連線時,手動呼叫此函式
  private:
    ...
    closed = true;       //顯示連線是否被手動關閉
};

void DBConn::close(){    //當需要關閉連線,手動呼叫此函式
  db.close();
  closed = true;
}

DBConn::~DBcon(){
  if(!closed)            //解構函式雖然還是要留有備用,但不用每次都承擔風險了
    try{
      db.close();
    }catch(...){
      //記錄訪問歷史
      //消化異常或者主動關閉
    }
}

通過以上的做法,當關閉連線時,我們先手動呼叫close()方法,這樣就算丟擲了異常,我們也可以事先處理,然後再呼叫解構函式。當然解構函式還是要檢查是否被手動關閉並留有備用方案。如果沒有被手動關閉,解構函式還是需要在消化掉異常和終止程式中做出選擇。


總結:

  • 不要讓異常從解構函式裡跑出來。如果解構函式的某些程式碼可能會丟擲異常,要保證它們能在跑出解構函式之前被catch塊接到,然後選擇消化異常還是終止程式。
  • 我們可以把可能丟擲異常的程式碼從解構函式中移到別的函式裡,這樣就可以事先對異常做出反應。