要將多態基類的解構函式聲明為虛函數
現在考慮一個計時器的問題,我們首先建立一個名為 TimeKeeper 的基類,然後在它的基礎上建立各種衍生類別,從而用不同手段來計時。由於計時有很多方式,所以這樣做是值得的:
class TimeKeeper {
public:
TimeKeeper();
~TimeKeeper();
...
};
class AtomicClock: public TimeKeeper { ... }; // 原子鐘
class WaterClock: public TimeKeeper { ... }; // 水時鐘
class WristWatch: public TimeKeeper { ... }; // 腕錶
許多用戶端程式員希望在訪問時間的同時,不用關心它是如何計算的,所以在此時可以使用一個工廠函數來返回一個指向計時器對象的指標,該函數返回的是一個基類指標,這個指標指向一個新建立的衍生類別對象:
TimeKeeper* getTimeKeeper();
// 返回一個繼承自 TimeKeeper 的動態分配的對象
為了不破壞工廠函數的慣例, getTimeKeeper 返回的對象被放置在堆上,所以必須要在適當的時候刪除每一個返回的對象,從而避免記憶體或者其他資源發生泄漏:
TimeKeeper *ptk = getTimeKeeper(); // 從 TimeKeeper 層取得
// 一個動態分配的對象
... // 使用這個對象
delete ptk; // 釋放它,以防資源泄漏
把釋放工作推給用戶端程式員不是一個好的做法, 第 13 條 中解釋了這一點。關於如何修改工廠函數的介面從而防止一般的用戶端錯誤發生,請參見第 18 條。但是這些議題在此都不是主要的,這一條中我們主要討論的是一個更為基本的議題,即上文中的代碼存在著更為基本的弱點:即使用戶端程式員把每一件事都做得很完美,我們仍無法預知程式會產生怎樣的行為。
現在的問題是: getTimeKeeper 返回一個指向其衍生類別對象的指標(比如說 AtomicClock ),這個對象通過一個基類的指標得到刪除(比如說一個 TimeKeeper* 指標),而基類( TimeKeeper )有一個非虛擬析構器。這裡埋藏著災難,這是因為 C++ 做出了這樣的規定:當一個衍生類別對象通過一個指向基類的指標來刪除,並且這一基類有一個非虛擬析構器,此時的結果是不可預知的。通常情況下在運行時,衍生類別中新派生出的部分得不到銷毀。如果 getTimeKeeper 返回了一個指向 AtomicClock 對象的指標,這一對象中派生出的部分( AtomicClock 這一部分,也就是 AtomicClock 類中新生命的資料成員)有可能不會被銷毀掉, AtomicClock 的析構器也可能不會得到運行。然而,這一對象中基類那一部分(也就是 TimeKeeper 這一部分)很自然的會被銷毀掉,這樣便會產生一個古怪的“部分被銷毀的”對象。用這種方法來泄漏資源、破壞資料結構、浪費調試時間,實在是“再好不過”了。
排除這一問題的方法很簡單:為基類提供一個虛擬析構器。這時刪除一個衍生類別對象,程式就會精確地按你的需要運行了。整個對象都會得到銷毀,包括所有新派生的部分:
class TimeKeeper {
public:
TimeKeeper();
virtual ~TimeKeeper();
...
};
TimeKeeper *ptk = getTimeKeeper();
...
delete ptk; // 現在,程式正常運轉
通常情況下,像 TimeKeeper 這樣的基類會包含除析構器以外的虛函數,這是因為虛函數的目的是允許衍生類別實現中對它們進行自訂(參見第 34 條)。比如說,對於 TimeKeeper 類中的 getCurrentTime 函數來說,它在不同的衍生類別中有可能有不同的實現方式,必須要將其聲明為虛函數。任何有虛函數的類幾乎都要包含一個虛析構器。
如果一個類不包含虛函數,通常情況下意味著它將不作為基類使用。當一個類不作為基類時,將它的析構其聲明為虛擬通常情況下不是個好主意。請看下面的樣本,這個類代表的是二維空間中的點:
class Point { // 2D 的點
public:
Point(int xCoord, int yCoord);
~Point();
private:
int x, y;
};
在一般情況下,如果一個 int 佔用 32 個位,一個 Point 對象便適合置入一個 64 位元的寄存器中。而且,這樣的一個 Point 對象可以以一 個 64 位元數值的形式傳給其他語言編寫的函數,比如 C 或者 FORTRAN 。然而如果 Point 的析構器是虛擬,那麼就是另一種情況了。
虛函數的實現需要它所在的對象包含額外的資訊,這一資訊用來在運行時確定本對象需要調用哪個虛函數。通常,這一資訊採取一個指標的形式,這個指標被稱為“ vptr ”(“虛函數表指標”)。 vptr 指向一個包含函數指標的數組,這一數組稱為“ vtbl ”(“虛函數表”),每個包含虛函數的類都有一個與之相關的 vtbl 。當一個虛函數被一個對象調用時,就用到了該對象的 vptr 所指向的 vtbl ,在 vtbl 中尋找一個合適的函數指標,然後調用相應的實函數。
虛函數實現的細節並不重要。重要的僅僅是,如果 Point 類包含一個虛函數,這一類型的對象將會變大。在一 個 32 位 的架構中, Point 對象將會由 64 位元 (兩個 int 大小)增長至 96 位(兩個 int 加一個 vptr );在 64 位元架構中, Point 對象將由 64 位元 增長至 128 位 。這是因為指向 64 位元架構的指標有 64 位元 大小。可以看到,為 Point 添加一個 vptr 將會使其變 大 50-100% !這樣,一個 64 位元的 寄存器便容不下一個 Point 對象了。而且, C++ 中的 Point 對象便不再與其它語言(比 如 C 語 言)有同樣的結構,這是因為其它語言很可能沒有 vptr 的概念。於是,除非你顯式增補一個 vptr 的等價物(但這是這種語言的實現細節,而且不具備可移植性),否則 Point 對象便無法與其它語言編寫的函數互連。
不得不承認,無故將所有的析構器聲明為虛擬,與從不將它們聲明為虛函數一樣糟糕,這一點最為重要。實際上,許多人總結出一條解決途徑:若且唯若類中至少包含一個虛函數時,要聲明一個虛析構器。
甚至在完全沒有虛函數的類裡,你也可能會被非虛擬構造器所糾纏。比如說,標準的 string 類型不包含虛函數,但是誤入歧途的程式員有些時候還是會將其作為基類:
class SpecialString: public std::string {
// 這不是個好主意!
// std::string 有一個非虛擬析構器
...
};
乍一看, 這樣的代碼似乎沒什麼問題,但是如果在應用時,你不知出於什麼原因希望將一個指向 SpecialString 的指標轉型為指向 string 的指標,然後你又對這個 string 指標使用了 delete ,你的程式會立刻陷入無法預知的狀態:
SpecialString *pss = new SpecialString("Impending Doom");
std::string *ps;
...
ps = pss; // SpecialString* ⇒ std::string*
...
delete ps; // 結果是無法預知的!在實踐中 *ps 的
// SpecialString 這一部分資源將會
// 泄漏,這是因為 SpecialString 的
// 析構器沒有被調用。
對於所有沒有虛析構器的類上面的分析也成立,包括所有的 STL 容器類型(比如 vector 、 list 、 set 、 tr1::unordered_map (參見第 54 條),等等)。如果你曾經繼承過一個標準容器或者其他任何包含非虛析構器的類,一定要打消這個念頭!(遺憾的是, C++ 沒有提供類似 Java 中的 final 類或 C# 中的 sealed 類那種防止繼承的機制)
在個別情況下,為一個類提供一個純虛析構器是十分方便的。你可以回憶一下,純虛函數將會使所在的類變成抽象類別——這種類不可以執行個體化(也就是說,你無法建立這種類型的對象)。然而某些時刻,你希望一個類成為一個抽象類別,但是你有沒有任何純虛函數,這時候要怎麼辦呢?因為抽象類別應該作為基類來使用,而基類應該有虛析構器,又因為純虛函數可以造就一個抽象類別,那麼解決方案就顯而易見了:如果你希望一個類成為一個抽象類別,那麼在其中聲明一個純虛析構器。下邊是樣本:
class AWOV { // AWOV = "Abstract w/o Virtuals"
public:
virtual ~AWOV() = 0; // 聲明純虛析構器
};
這個類有一個純虛析函數,所以它是一個抽象類別,同時它擁有一個虛析構器,所以你不需要擔心析構器出現問題。然而這裡還是有一個彆扭的地方:你必須為純虛析構器提供一個定義:
AWOV::~AWOV() {} // 純虛析構器的定義
析構器的工作方式是這樣的:首先調用最後派生出的類的析構器,然後依次調用上一層基類的析構器。由於當調用一個 AWOV 的衍生類別的析構器時,編譯器會自動調用 ~AWOV ,因此你必須為 ~AWOV 提供一個函數體。否則連接器將會報錯。
為基類提供虛析構器的原則僅對多態基類(這種基類允許通過其介面來操控衍生類別的類型)有效。我們說 TimeKeeper 是一個多態基類,這是由於即使我們手頭只有 TimeKeeper 指向它們的指標,我們仍可以對 AtomicClock 和 WaterClock 進行操控。
並不是所有的基類都要具有多態性。比如說,標準 string 類型、 STL 容器都不用作基類,因此它們都不具備多態性。另外有一些類是設計用作基類的,但是它們並未被設計成多態類。這些類(例如 第 6 條 中的 Uncopyable 和標準類中的 input_iterator_tag (參見第 47 條 ))不允許通過其介面來操控它的衍生類別。因此,它們並不需要虛析構器。
需要記住的
- 應該為多態基類聲明虛析構器。一旦一個類包含虛函數,它就應該包含一個虛析構器。
- 如果一個類不用作基類或者不需具有多態性,便不應該為它聲明虛析構器。