天天看點

異常與構造函數、析構函數

寫Java代碼的時候,遇到錯誤總是喜歡抛出異常,簡單實用。最近開始寫C++代碼,發現異常沒那麼簡單,使用須謹慎。

翻閱了《Effective C++》 《More Effective C++》《Inside The C++ Object Model》的相關章節,大概弄明白了一些東東,總結在本文。

本文不是總結普适的C++異常機制,還沒有這個内力哈! 主要是結合構造函數和析構函數,來總結異常對他倆的影響。構造函數和析構函數本來就很折磨腦筋,再疊加上異常機制,确實比較複雜。

異常與析構函數

本節内容較少,是以先說。構造函數放到下一節讨論。

絕對不要将異常抛出析構函數

這一條在《Effective C++》 《More Effective C++》中均被作為獨立章節講解,可見其重要性。

有一點不要誤解:析構函數的代碼當然可以throw異常,隻是這個異常不要被抛出析構函數之外。如果在析構函數中catch住異常,并且不再抛出,這就不會帶來問題。

至于原因,有兩點。我們先看第一點。

異常被抛出析構函數之外,往往意味着析構函數的工作沒有做完。如果析構函數需要釋放一些資源,異常可能導緻資源洩露,使得程式處于一個不安全的狀态。

如下面的僞代碼所示,異常導緻p不能free,進而造成記憶體洩露。

class A
{
public:
    ~A()
   {
        throw exception;
        free(p);
   }
};
           

OK,這個問題好辦,我好好寫代碼,確定析構函數釋放所有的資源之後,才抛出異常。這還不行嗎?

class A
{
public:
    ~A()
   {
        free(p);
        throw exception;
   }
};
           

嗯,确實不行。我們來看第二個原因。

如果兩個異常同時存在:第一個異常還沒有被catch,第二個異常又被抛出,這會導緻C++會調用terminate函數,把程式結束掉!

這簡直是災難,遠比資源洩漏要嚴重。

那麼,什麼時候會同時出現兩個異常呢?看下面的代碼。

void f()
{
    A a;   // 沒錯,就是前面的class A
    throw exception;
}
           

f()抛出異常後,會進行stack-unwinding。在這個過程中,會析構所有的active local object。所謂active local object,就是已經構造完成的局部對象,例如上面的對象a。

調用a的析構函數時,(第一個)異常還沒有被catch。可是a的析構函數也抛出了(第二個)異常。這時,兩個異常同時存在了。程式會毫不留情地結束!

這個理由足夠充分了:再也不要讓異常逃離你的析構函數!

異常與構造函數

構造函數本來就是一件難以琢磨的東東,背後做了很多事情:成員對象的構造、基類成分的構造、虛表指針的設定等。這些事情本來就很糾結了,再讓構造函數抛出異常,會出現怎樣的悲劇呢?

有一點比較安慰:異常即使被抛出構造函數之外,也不會造成程式結束。那麼,是否存在資源洩漏的問題呢?不可一概而論,我們分情況分析。

對象自身的記憶體如何釋放

對象有可能在棧上,也可能在堆上,我們分兩種情況讨論。

// 對象在棧上
f()
{
    A a;
}

// 對象在堆上
f()
{
    A * a = new A();
}
           

如果對象是在棧上,那麼函數退棧自然會釋放a占用的空間,無需多慮。

如果對象是在堆上,我們還得兩種情況讨論:

  1. 如果是new運算符抛出的異常,那麼堆空間還沒有配置設定成功,也就無需釋放
  2. 如果是構造函數抛出的異常,堆空間已經配置設定成功,那麼編譯器會負責釋放堆空間(Inside The C++ Object Model, p301)

可見,對象本身的記憶體,是不會洩露的。

成員對象和基類成分怎麼辦

成員對象和基類成分的記憶體,會随着對象自身記憶體的釋放而被一起釋放,沒什麼問題。

但是,有一點需要謹記:如果一個對象的構造函數抛出異常,那麼該對象的析構函數不會被調用。

原因很簡單:如果對象沒有被構造完整,析構函數中的某些代碼可能會有風險。為了避免這類意外問題,編譯器拒絕生成調用析構函數的代碼。

那麼,成員對象的基類成員對象的析構函數,會被調用嗎?如果不會調用,則可能出現資源洩漏。答案是,會被調用。見下面的代碼。

class B : class C
{
    A a;
    A * pa;
public:
    B()
    {
        pa = new A();
    }

    ~B()
    {
        delete pa;
    }
};
           

如果B的構造函數抛出異常,編譯器保證:成員對象a的析構函數、基類C的析構函數會被調用(Inside The C++ Object Model, p301)。

成員指針怎麼辦

注意上述代碼中的pa,它指向一塊堆空間,由于B的析構函數不會被調用了,記憶體就會出現洩漏。

這還真是一個問題,編譯器也不能幫我們做更多事情,隻能由程式員自己負責釋放記憶體。

我們可能要這樣寫代碼

class B : class C
{
    A a;
    A * pa;
public:
    B()
    {
        pa = new A();
        try {
            throw exception;
        } catch(...)
        {
            delete pa; //確定釋放pa
            throw;
        }
    }

    ~B()
    {
        delete pa;
    }
};
           

這樣的代碼難看很多,有一種建議的做法就是:用智能指針包裝pa。智能指針作為B的成員對象,其析構函數是可以被自動調用的,進而釋放pa。

析構函數如何被自動調用

上面提到:

  1. 普通函數抛出異常時,所有active local object的析構函數都會被調用
  2. 構造函數抛出異常時,所有成員對象以及基類成分的析構函數都會被調用

那麼,這是怎麼實作的呢?

我們以第一種情況為例,分析實作細節。看下面的代碼:

f()
{
    A a1;
    if (...) {  // 某些條件下,抛出異常
        throw exception;
    }
    A a2;
    throw exception; // 總會抛出異常
}
           

如果L5抛出異常,那麼對象a1會被析構。如果L8抛出異常,那麼對象a1 a2都要被析構。編譯器是怎麼知道,什麼時候該析構哪些對象的呢?

支援異常機制的編譯器,會做一些”簿記“工作,将需要被析構的對象登記在特定的資料結構中。編譯器将上述代碼分成不同的區段,每個區段中需要被析構的對象,都不相同。

例如,上述代碼中,L3 L4~L7 L8就是三個不同的區段:

  1. 如果L3抛出異常,那麼沒有對象需要析構
  2. 如果L4~L7抛出異常,那麼a1需要被析構
  3. 如果L8抛出異常,那麼a1和a2都要析構

編譯器通過分析代碼,簿記這些區段以及需要析構的object list。運作時,根據異常抛出時所在的區段,查找上述的資料結構,就可以知道哪些對象需要被析構。

構造函數抛出異常時,成員對象及基類成分被析構的原理,是類似的。在C++運作時看來,構造函數隻是普通的函數而已。

總結

C++的異常機制,給編譯器和運作時均帶來了一定的複雜度和代價。上述的”簿記“工作,隻是冰上一角。

關于異常的使用,也有很多坑。怎麼throw 怎麼catch,都是有講究的。有空下次再做總結。