有許多種做法可以記錄時間,因此,設計一個TimeKeeper base class和一些derived classes作為不同的計時方法很合理:
class TimeKeeper
{
public:TimeKeeper();~TimeKeeper();// ...
};class AtomicClock : public TimeKeeper { /* ... */ }; // 原子鐘
class WaterClock : public TimeKeeper { /* ... */ }; // 水鐘
class WristWatch : public TimeKeeper { /* ... */ }; // 腕表
很多客戶只想在程序中使用時間,不想操心時間如何計算等細節,這是我們可以設計factory(工廠)函數,返回指針指向一個計時對象,Factory函數會“返回一個base class指針,指向新生成的derived class對象”:
TimeKeeper *getTimeKeeper(); // 返回一個指針,指向一個TimeKeeper派生類的動態分配對象
為遵循factory函數的規矩,被getTimeKeeper()返回的對象必須位于heap。因此為了避免泄露內存和其他資源,將factory函數返回的每一個對象適當地delete掉很重要:
TimeKeeper *ptk = getTimeKeeper(); // 從TimeKeeper繼承體系獲得一個動態分配對象
// 運用它...
delete ptk; // 釋放它,避免資源泄露
條款13說“倚賴客戶執行delete,基本上便帶有某種錯誤傾向”,條款18則談到factory函數接口該如何修改以便預防常見的客戶錯誤,但這些在此都是次要的,因為此條款內我們要對付的是上述代碼的一個更根本的弱點:縱使客戶把每一件事都做對了,仍然沒辦法知道程序如何行動。
問題出在getTimeKeeper返回的指針指向一個derived class對象(例如AtomicClock),而那個對象卻經由一個base class指針(例如一個TimeKeeper *指針)被刪除,而目前的base class(TimeKeeper)有個non-virtual析構函數。
這是有問題的,因為C++明白指出,當derived class對象經由一個base class的指針被刪除,而該base class帶著一個non-virtual析構函數時,其結果未定義——實際執行時通常發生的是對象的derived成分沒被銷毀。如果getTimeKeeper返回指針指向一個AtomicClock對象,其內的AtomicClock成分(也就是聲明于AtomicClock class內的成員變量)很可能沒被銷毀,而AtomicClock的析構函數也未能執行起來。然而其base class成分(也就是TimeKeeper這一部分)通常會被銷毀,于是造成一個詭異的“局部銷毀”對象。這會形成資源泄露、毀壞數據結構、浪費許多時間在調試器上。
消除這個問題的做法很簡單:給base class一個virtual析構函數。此后刪除derived class對象就會如你想要的那般,銷毀整個對象,包括所有derived class成分:
class TimeKeeper
{
public:TimeKeeper();virtual ~TimeKeeper();
};TimeKeeper *ptk = getTimeKeeper();
// ...
delete ptk; // 現在,行為正確
像TimeKeeper這樣的base classes除了析構函數之外通常還有其他virtual函數,因為virtual函數的目的是允許derived class的實現得以客制化(見條款34)。例如TimeKeeper就可能擁有一個virtual getCurrentTime,它在不同的derived class中有不同的實現。任何class只要帶有virtual函數都幾乎確定應該也有一個virtual析構函數。
如果class不含virtual函數,通常表示它并不打算被用做一個base class。當class被用作base class,令其析構函數為virtual往往是個餿主意。考慮一個用來表示二維空間點坐標的class:
class Point
{ // 一個二維空間點(2D point)
public:Point(int xCoord, int yCorrd);~Point();
private:int x, y;
};
如果int占用32bits,那么Point對象可塞入一個64-bit緩存器(用于臨時存儲數據的高速存儲設備。它通常位于處理器內部或者靠近處理器,并且比主存儲器更快)中,更有甚者,這樣一個Point對象可被當做一個“64-bit量”傳給以其他語言如C或FORTRAN撰寫的函數。然而當Point的析構函數是virtual,形勢起了變化。
欲實現出virtual函數,對象必須攜帶某些信息,主要用來在運行期決定哪一個virtual函數該被調用。這份信息通常是由一個所謂vptr(virtual table pointer)指針指出。vptr指向一個由函數指針構成的數組,稱為vtbl(virtual table);每一個帶有virtual函數的class都有一個相應的vtbl。當對象調用某一virtual函數,實際被調用的函數取決于該對象的vptr所指的那個vtbl——編譯器在其中尋找適當的函數指針。
virtual函數的實現細節不重要,重要的是如果Point class內含virtual函數,其對象的體積會增加:在32-bit計算機體系結構中將占用64 bits(為了存放兩個ints)至96 bits(兩個ints加上vptr);在64-bit計算機體系結構中占用128bits(兩個ints加上vptr,在64位系統中,int還是占32位),因為指針在這樣的計算機結構中占64 bits。因此,為Point添加一個vptr會增加其對象大小達50%~100%!Point對象不能再塞入一個64-bit緩存器,而C++的Point對象也不再和其他語言(如C)內的相同聲明有著一樣的結構(因為其他語言的對應物并沒有vptr),因此也就不再可能把它傳遞至(或接受自)其他語言所寫的函數,除非你明確補償vptr——那屬于實現細節,也因此不再具有移植性。
因此,無端地將所有classes的析構函數聲明為virtual,就像從未聲明它們為virtual一樣,都是錯誤的。許多人的心得是:只有當class內含至少一個virtual函數,才為它聲明virtual析構函數。
即使class完全不帶virtual函數,被“non-virtual析構函數問題”給咬傷還是有可能的。舉個例子,標準string不含任何virtual函數,但有時候程序員會錯誤地把它當做base class:
class SpecialString : public std::string // 餿主意!std::string有個non-virtual析構函數
{// ...
};
乍看似乎無害,但如果你在程序任意某處無意間將一個pointer-to-SpecialString轉換為一個pointer-to-string,然后將轉換所得的那個string指針delete掉,行為就不正確了:
SpecialString *pss = new SpecialString("Impending Doom");
std::string *ps;
// ...
ps = pss; // SpecialString * => std::string *
// ...
delete ps; // 未定義行為,現實中*ps的SpecialString資源會泄漏,因為SpecialString析構函數沒被調用
相同的分析適用于任何不帶virtual析構函數的class,包括所有STL容器如vector、list、set、tr1::unordered_map(見條款54)等等。因此不要繼承一個標準容器或任何其他“帶有non-virtual析構函數”的class(很不幸C++沒有提供類似Java的final classes或C#的sealed classes那樣的“禁止派生”機制)。
有時候令class帶一個pure virtual析構函數,可能頗為便利。pure virtual函數導致abstract(抽象) class——也就是不能被實體化(instantiated)的class。也就是說,你不能為那種類型創建對象。然而有時候你希望擁有抽象class,但手上沒有任何pure virtual函數,怎么辦?由于抽象class總是被當做一個base class來用,而又由于base class應該有個virtual析構函數,并且由于pure virtual函數會導致抽象class,因此很簡單:為你希望它成為抽象class的那個class聲明一個pure virtual析構函數。下面是一個例子:
class AWOV // AWOV="Abstract w/o Virtuals"
{
public:virtual ~AWOV() = 0; // 聲明為pure virtual析構函數
};
這個class有一個pure virtual函數,所以它是個抽象class,又由于它有個virtual析構函數,所以你不需要擔心析構函數的問題。但你必須要為這個pure virtual析構函數提供一份定義:
AWOV::~AWOV() { } // pure virtual析構函數的定義
析構函數的運作方式是,最深層派生(most derived)的那個class其析構函數最先被調用,然后是其每一個base class的析構函數被調用。編譯器會在AWOV的derived classes的析構函數中創建一個對~AWOV的調用動作,所以你必須為這個函數提供一份定義。如果不這樣做,鏈接器會發出抱怨。
“給base classes一個virtual析構函數”,這個規則只適用于polymorphic(帶多態性質的)base classes身上。這種base classes的設計目的是為了用來“通過base class接口處理derived class對象”。TimeKeeper就是一個polymorphic base class,因為我們希望處理AtomicClock和WaterClock對象,縱使我們只有TimeKeeper指針指向它們。
并非所有base classes的設計目的都是為了多態用途。例如標準string和STL容器都不被設計作為base classes使用,更別提多態了。某些classes的設計目的是作為base classes使用,但不是為了多態用途。這樣的classes如條款6的Uncopyable和標準程序庫的input_iterator_tag(C++標準庫中定義的一個標簽類型,用于標識一個迭代器是輸入迭代器。它是一個空的結構體,通常用于迭代器的分類和特性判斷)(條款47),它們并非被設計用來“經由base class接口處置derived class對象”,因此它們不需要virtual析構函數。
請記住:
1.polymorphic(帶多態性質的)base class應該聲明一個virtual析構函數。如果class帶有任何virtual函數,它就應該擁有一個virtual析構函數。
2.Classes的設計目的如果不是作為base classes使用,或不是為了具備多態性(polymorphically),就不該聲明virtual析構函數。