深入理解C++中的異常處理機制

zhangyifei216發表於2015-12-27

異常處理

增強錯誤恢復能力是提高程式碼健壯性的最有力的途徑之一,C語言中採用的錯誤處理方法被認為是緊耦合的,函式的使用者必須在非常靠近函式呼叫的地方編寫錯誤處理程式碼,這樣會使得其變得笨拙和難以使用。C++中引入了異常處理機制,這是C++的主要特徵之一,是考慮問題和處理錯誤的一種更好的方式。使用錯誤處理可以帶來一些優點,如下:

  • 錯誤處理程式碼的編寫不再冗長乏味,並且不再和正常的程式碼混合在一起,程式設計師只需要編寫希望產生的程式碼,然後在後面某個單獨的區段裡編寫處理錯誤的嗲嗎。多次呼叫同一個函式,則只需要某個地方編寫一次錯誤處理程式碼。
  • 錯誤不能被忽略,如果一個函式必須向呼叫者傳送一次錯誤資訊。它將丟擲一個描述這個錯誤的物件。

傳統的錯誤處理和異常處理

在討論異常處理之前,我們先談談C語言中的傳統錯誤處理方法,這裡列舉了如下三種:

  • 在函式中返回錯誤,函式會設定一個全域性的錯誤狀態標誌。
  • 使用訊號來做訊號處理系統,在函式中raise訊號,通過signal來設定訊號處理函式,這種方式耦合度非常高,而且不同的庫產生的訊號值可能會發生衝突
  • 使用標準C庫中的非區域性跳轉函式 setjmp和longjmp ,這裡使用setjmp和longjmp來演示下如何進行錯誤處理:
#include <iostream>
#include <setjmp.h>
jmp_buf static_buf; //用來存放處理器上下文,用於跳轉

void do_jmp()
{
    //do something,simetime occurs a little error
    //呼叫longjmp後,會載入static_buf的處理器資訊,然後第二個引數作為返回點的setjmp這個函式的返回值
    longjmp(static_buf,10);//10是錯誤碼,根據這個錯誤碼來進行相應的處理
}

int main()
{
    int ret = 0;
    //將處理器資訊儲存到static_buf中,並返回0,相當於在這裡做了一個標記,後面可以跳轉過來
    if((ret = setjmp(static_buf)) == 0) {
        //要執行的程式碼
        do_jmp();
    } else {    //出現了錯誤
        if (ret == 10)
            std::cout << "a little error" << std::endl;
    }
}

錯誤處理方式看起來耦合度不是很高,正常程式碼和錯誤處理的程式碼分離了,處理處理的程式碼都匯聚在一起了。但是基於這種區域性跳轉的方式來處理程式碼,在C++中卻存在很嚴重的問題,那就是物件不能被析構,區域性跳轉後不會主動去呼叫已經例項化物件的解構函式。這將導致記憶體洩露的問題。下面這個例子充分顯示了這點

#include <iostream>
#include <csetjmp>

using namespace std;

class base {
    public:
        base() {
            cout << "base construct func call" << endl;
        }
        ~base() {
            cout << "~base destruct func call" << endl;
        }
};

jmp_buf static_buf;

void test_base() {
    base b;
    //do something
    longjmp(static_buf,47);//進行了跳轉,跳轉後會發現b無法析構了
}

int main() {
    if(setjmp(static_buf) == 0) {
        cout << "deal with some thing" << endl;
        test_base();
    } else {
        cout << "catch a error" << endl;
    }
}

在上面這段程式碼中,只有base類的建構函式會被呼叫,當longjmp發生了跳轉後,b這個例項將不會被析構掉,但是執行流已經無法回到這裡,b這個例項將不會被析構。這就是區域性跳轉用在C++中來處理錯誤的時候帶來的一些問題,在C++中異常則不會有這些問題的存在。那麼接下來看看如何定義一個異常,以及如何丟擲一個異常和捕獲異常吧.

異常的丟擲

class MyError {
    const char* const data;
public:
    MyError(const char* const msg = 0):data(msg)
    {
        //idle
    }
};

void do_error() {
    throw MyError("something bad happend");
}

int main()
{
    do_error();
}

上面的例子中,通過throw丟擲了一個異常類的例項,這個異常類,可以是任何一個自定義的類,通過例項化傳入的引數可以表明發生的錯誤資訊。其實異常就是一個帶有異常資訊的類而已。異常被丟擲後,需要被捕獲,從而可以從錯誤中進行恢復,那麼接下來看看如何去捕獲一個異常吧。在上面這個例子中使用丟擲異常的方式來進行錯誤處理相比與之前使用區域性跳轉的實現來說,最大的不同之處就是異常丟擲的程式碼塊中,物件會被析構,稱之為堆疊反解.

異常的捕獲

C++中通過catch關鍵字來捕獲異常,捕獲異常後可以對異常進行處理,這個處理的語句塊稱為異常處理器。下面是一個簡單的捕獲異常的例子:

    try{
        //do something
        throw string("this is exception");
    } catch(const string& e) {
        cout << "catch a exception " << e << endl;
    }

catch有點像函式,可以有一個引數,throw丟擲的異常物件,將會作為引數傳遞給匹配到到catch,然後進入異常處理器,上面的程式碼僅僅是展示了丟擲一種異常的情況,加入try語句塊中有可能會丟擲多種異常的,那麼該如何處理呢,這裡是可以接多個catch語句塊的,這將導致引入另外一個問題,那就是如何進行匹配。

異常的匹配

異常的匹配我認為是符合函式引數匹配的原則的,但是又有些不同,函式匹配的時候存在型別轉換,但是異常則不然,在匹配過程中不會做型別的轉換,下面的例子說明了這個事實:

#include <iostream>

using namespace std;
int main()
{
    try{

        throw 'a';
    }catch(int a) {
        cout << "int" << endl;
    }catch(char c) {
        cout << "char" << endl;
    }
}

上面的程式碼的輸出結果是char,因為丟擲的異常型別就是char,所以就匹配到了第二個異常處理器。可以發現在匹配過程中沒有發生型別的轉換。將char轉換為int。儘管異常處理器不做型別轉換,但是基類可以匹配到派生類這個在函式和異常匹配中都是有效的,但是需要注意catch的形參需要是引用型別或者是指標型別,否則會 導致切割派生類這個問題。

//基類
class Base{
    public:
        Base(string msg):m_msg(msg)
        {
        }
        virtual void what(){
            cout << m_msg << endl;
        }
    void test()
    {
        cout << "I am a CBase" << endl;
    }
    protected:
        string m_msg;
};
//派生類,重新實現了虛擬函式
class CBase : public Base
{
    public:
        CBase(string msg):Base(msg)
        {

        }
        void what()
        {
           cout << "CBase:" << m_msg << endl;
        }
};

int main()
{
    try {
        //do some thing
    //丟擲派生類物件
        throw CBase("I am a CBase exception");

    }catch(Base& e) {  //使用基類可以接收
        e.what();
    }
}

上面的這段程式碼可以正常的工作,實際上我們日常編寫自己的異常處理函式的時候也是通過繼承標準異常來實現位元組的自定義異常的,但是如果將Base&換成Base的話,將會導致物件被切割,例如下面這段程式碼將會編譯出錯,因為CBase被切割了,導致CBase中的test函式無法被呼叫。

    try {
        //do some thing
        throw CBase("I am a CBase exception");

    }catch(Base e) {
        e.test();
    }

到此為此,異常的匹配算是說清楚了,總結一下,異常匹配的時候基本上遵循下面幾條規則:

異常匹配除了必須要是嚴格的型別匹配外,還支援下面幾個型別轉換.

  • 允許非常量到常量的型別轉換,也就是說可以丟擲一個非常量型別,然後使用catch捕捉對應的常量型別版本
  • 允許從派生類到基類的型別轉換
  • 允許陣列被轉換為陣列指標,允許函式被轉換為函式指標

假想一種情況,當我要實現一代程式碼的時候,希望無論丟擲什麼型別的異常我都可以捕捉到,目前來說我們只能寫上一大堆的catch語句捕獲所有可能在程式碼中出現的異常來解決這個問題,很顯然這樣處理起來太過繁瑣,幸好C++提供了一種可以捕捉任何異常的機制,可以使用下列程式碼中的語法。

   catch(...) {
    //異常處理器,這裡可以捕捉任何異常,帶來的問題就是無法或者異常資訊
   }

如果你要實現一個函式庫,你捕捉了你的函式庫中的一些異常,但是你只是記錄日誌,並不去處理這些異常,處理異常的事情會交給上層呼叫的程式碼來處理.對於這樣的一個場景C++也提供了支援.

    try{
        throw Exception("I am a exception");    
    }catch(...) {
        //log the exception
        throw;
    }

通過在catch語句塊中加入一個throw,就可以把當前捕獲到的異常重新丟擲.在異常丟擲的那一節中,我在程式碼中丟擲了一個異常,但是我沒有使用任何catch語句來捕獲我丟擲的這個異常,執行上面的程式會出現下面的結果.

terminate called after throwing an instance of 'MyError'
Aborted (core dumped)

為什麼會出現這樣的結果呢?,當我們丟擲一個異常的時候,異常會隨著函式呼叫關係,一級一級向上丟擲,直到被捕獲才會停止,如果最終沒有被捕獲將會導致呼叫terminate函式,上面的輸出就是自動呼叫terminate函式導致的,為了保證更大的靈活性,C++提供了set_terminate函式可以用來設定自己的terminate函式.設定完成後,丟擲的異常如果沒有被捕獲就會被自定義的terminate函式進行處理.下面是一個使用的例子:

#include <exception>
#include <iostream>
#include <cstdlib>
using namespace std;

class MyError {
    const char* const data;
public:
    MyError(const char* const msg = 0):data(msg)
    {
        //idle
    }
};

void do_error() {
    throw MyError("something bad happend");
}
//自定義的terminate函式,函式原型需要一致
void terminator()
{
    cout << "I'll be back" << endl;
    exit(0);
}

int main()
{
    //設定自定義的terminate,返回的是原有的terminate函式指標
    void (*old_terminate)() = set_terminate(terminator);
    do_error();
}
上面的程式碼會輸出I'll be back

到此為此關於異常匹配的我所知道的知識點都已經介紹完畢了,那麼接著可以看看下一個話題,異常中的資源清理.

異常中的資源清理

在談到區域性跳轉的時候,說到區域性調轉不會呼叫物件的解構函式,會導致記憶體洩露的問題,C++中的異常則不會有這個問題,C++中通過堆疊反解將已經定義的物件進行析構,但是有一個例外就是建構函式中如果出現了異常,那麼這會導致已經分配的資源無法回收,下面是一個建構函式丟擲異常的例子:

#include <iostream>
#include <string>
using namespace std;

class base
{
    public:
        base()
        {
            cout << "I start to construct" << endl;
            if (count == 3) //構造第四個的時候丟擲異常
                throw string("I am a error");
            count++;
        }

        ~base()
        {
            cout << "I will destruct " << endl;
        }
    private:
        static int count;
};

int base::count = 0;

int main()
{
        try{

            base test[5];

        } catch(...){

            cout << "catch some error" << endl;

        }
}
上面的程式碼輸出結果是:
I start to construct
I start to construct
I start to construct
I start to construct
I will destruct 
I will destruct 
I will destruct 
catch some error

在上面的程式碼中建構函式發生了異常,導致對應的解構函式沒有執行,因此實際程式設計過程中應該避免在建構函式中丟擲異常,如果沒有辦法避免,那麼一定要在建構函式中對其進行捕獲進行處理.最後介紹一個知識點就是函式try語句塊,如果main函式可能會丟擲異常該怎麼捕獲?,如果建構函式中的初始化列表可能會丟擲異常該怎麼捕獲?下面的兩個例子說明了函式try語句塊的用法:

#include <iostream>

using namespace std;

int main() try {
    throw "main";
} catch(const char* msg) {
    cout << msg << endl;
    return 1;
}
main函式語句塊,可以捕獲main函式中丟擲的異常.
class Base
{
    public:
        Base(int data,string str)try:m_int(data),m_string(str)//對初始化列表中可能會出現的異常也會進行捕捉
       {
            // some initialize opt
       }catch(const char* msg) {

            cout << "catch a exception" << msg << endl;
       }

    private:
        int m_int;
        string m_string;
};

int main()
{
    Base base(1,"zhangyifei");
}

上面說了很多都是關於異常的使用,如何定義自己的異常,編寫異常是否應該遵循一定的標準,在哪裡使用異常,異常是否安全等等一系列的問題,下面會一一討論的.

標準異常

C++標準庫給我們提供了一系列的標準異常,這些標準異常都是從exception類派生而來,主要分為兩大派生類,一類是logic_error,另一類則是runtime_error這兩個類在stdexcept標頭檔案中,前者主要是描述程式中出現的邏輯錯誤,例如傳遞了無效的引數,後者指的是那些無法預料的事件所造成的錯誤,例如硬體故障或記憶體耗盡等,這兩者都提供了一個引數型別為std::string的建構函式,這樣就可以將異常資訊儲存起來,然後通過what成員函式得到異常資訊.

#include <stdexcept>
#include <iostream>
#include <string>
using namespace std;

class MyError:public runtime_error {
public:
    MyError(const string& msg = "") : runtime_error(msg) {}

};

//runtime_error logic_error 兩個都是繼承自標準異常,帶有string建構函式
//
int main()
{
    try {
        throw MyError("my message");   
    }   catch(MyError& x) {
        cout << x.what() << endl;    
    }
}

異常規格說明

假設一個專案中使用了一些第三方的庫,那麼第三方庫中的一些函式可能會丟擲異常,但是我們不清楚,那麼C++提供了一個語法,將一個函式可能會丟擲的異常列出來,這樣我們在編寫程式碼的時候參考函式的異常說明即可,但是C++11中這中異常規格說明的方案已經被取消了,所以我不打算過多介紹,通過一個例子看看其基本用法即可,重點看看C++11中提供的異常說明方案:

#include <exception>
#include <iostream>
#include <cstdio>
#include <cstdlib>
using namespace std;

class Up{};
class Fit{};
void g();
//異常規格說明,f函式只能丟擲Up 和Fit型別的異常
void f(int i)throw(Up,Fit) {
    switch(i) {
        case 1: throw Up();
        case 2: throw Fit();    
    }
    g();
}

void g() {throw 47;}

void my_ternminate() {
    cout << "I am a ternminate" << endl;
    exit(0);
}

void my_unexpected() {
    cout << "unexpected exception thrown" << endl;
 //   throw Up();
    throw 8;
    //如果在unexpected中繼續丟擲異常,丟擲的是規格說明中的 則會被捕捉程式繼續執行
    //如果丟擲的異常不在異常規格說明中分兩種情況
    //1.異常規格說明中有bad_exception ,那麼會導致丟擲一個bad_exception
    //2.異常規格說明中沒有bad_exception 那麼會導致程式呼叫ternminate函式
   // exit(0);
}

int main() {
 set_terminate(my_ternminate);
 set_unexpected(my_unexpected);
 for(int i = 1;i <=3;i++)
 {
     //當丟擲的異常,並不是異常規格說明中的異常時
     //會導致最終呼叫系統的unexpected函式,通過set_unexpected可以
     //用來設定自己的unexpected汗函式
    try {
        f(i);    
    }catch(Up) {
        cout << "Up caught" << endl;    
    }catch(Fit) {
        cout << "Fit caught" << endl;    
    }catch(bad_exception) {
        cout << "bad exception" << endl;    
    }
 }
}

上面的程式碼說明了異常規格說明的基本語法,以及unexpected函式的作用,以及如何自定義自己的unexpected函式,還討論了在unexpected函式中繼續丟擲異常的情況下,該如何處理丟擲的異常.C++11中取消了這種異常規格說明.引入了一個noexcept函式,用於表明這個函式是否會丟擲異常

void recoup(int) noexecpt(true);  //recoup不會丟擲異常
void recoup(int) noexecpt(false); //recoup可能會丟擲異常

此外還提供了noexecpt用來檢測一個函式是否不丟擲異常.

異常安全

異常安全我覺得是一個挺複雜的點,不光光需要實現函式的功能,還要儲存函式不會在丟擲異常的情況下,出現不一致的狀態.這裡舉一個例子,大家在實現堆疊的時候經常看到書中的例子都是定義了一個top函式用來獲得棧頂元素,還有一個返回值是void的pop函式僅僅只是把棧頂元素彈出,那麼為什麼沒有一個pop函式可以 即彈出棧頂元素,並且還可以獲得棧頂元素呢?

template<typename T> T stack<T>::pop()
{
    if(count == 0)
        throw logic_error("stack underflow");
    else
        return data[--count];
}

如果函式在最後一行丟擲了一個異常,那麼這導致了函式沒有將退棧的元素返回,但是Count已經減1了,所以函式希望得到的棧頂元素丟失了.本質原因是因為這個函式試圖一次做兩件事,1.返回值,2.改變堆疊的狀態.最好將這兩個獨立的動作放到兩個獨立的函式中,遵守內聚設計的原則,每一個函式只做一件事.我們 再來討論另外一個異常安全的問題,就是很常見的賦值操作符的寫法,如何保證賦值操作是異常安全的.

class Bitmap {...};
class Widget {
    ...
private:
    Bitmap *pb;

};
Widget& Widget::operator=(const Widget& rhs)
{
    delete pb;
    pb = new Bitmap(*rhs.pb);
    return *this;
}

上面的程式碼不具備自我賦值安全性,倘若rhs就是物件本身,那麼將會導致*rhs.pb指向一個被刪除了的物件.那麼就緒改進下.加入證同性測試.

Widget& Widget::operator=(const Widget& rhs)
{
    If(this == rhs) return *this; //證同性測試
    delete pb;
    pb = new Bitmap(*rhs.pb);
    return *this;
}

但是現在上面的程式碼依舊不符合異常安全性,因為如果delete pb執行完成後在執行new Bitmap的時候出現了異常,則會導致最終指向一塊被刪除的記憶體.現在只要稍微改變一下,就可以讓上面的程式碼具備異常安全性.

Widget& Widget::operator=(const Widget& rhs)
{
    If(this == rhs) return *this; //證同性測試
    Bitmap *pOrig = pb;
    pb = new Bitmap(*rhs.pb); //現在這裡即使發生了異常,也不會影響this指向的物件
    delete pOrig;
    return *this;   
}

這個例子看起來還是比較簡單的,但是用處還是很大的,對於賦值操作符來說,很多情況都是需要過載的.

相關文章