1. 繼承體系中關于對象釋放遇到的問題描述
1.1 手動釋放
關于時間記錄有很多種方法,是以為不同的計時方法建立一個TimeKeeper基類和一些派生類就再合理不過了:
1 class TimeKeeper {
2
3 public:
4
5 TimeKeeper();
6
7 ~TimeKeeper();
8
9 ...
10
11 };
12
13 class AtomicClock: public TimeKeeper { ... };
14
15 class WaterClock: public TimeKeeper { ... };
16
17 class WristWatch: public TimeKeeper { ... };
18
19
許多用戶端隻想通路時間而不想知道關于時間計算的細節,是以可以建立一個工廠方法,這個工廠方法傳回一個指向新建立的派生類對象的基類指針,這個指針用來指向一個計時對象:
1 TimeKeeper* getTimeKeeper(); // returns a pointer to a dynamic-
2
3 // ally allocated object of a class
4
5 // derived from TimeKeeper
為了和工廠方法的約定保持一緻,getTimeKeeper傳回一個堆上的對象,是以為了避免洩露記憶體和其他資源,每個傳回的對象被合理的釋放掉(deleted)是很重要的:
1 TimeKeeper *ptk = getTimeKeeper(); // get dynamically allocated object
2
3 // from TimeKeeper hierarchy
4
5 ... // use it
6
7 delete ptk; // release it to avoid resource leak
Item13中解釋到依賴客戶執行deletion比較容易出錯,在Item18中解釋了如何改變工廠函數的接口來預防一般的用戶端錯誤,但是這些關注點在這裡都是次要的,因為在這個條款中,我們為上面的代碼提出一個更基本的弱點:即使用戶端把一切都做對了,根本沒有方法去知道程式如何運轉。
1.2非虛析構函數引入的問題
問題在于getTimeKeeper傳回一個指向派生類對象的指針(AtomicClock),這個對象通過一個基類指針(一個TimeKeeper*指針)來進行釋放(delete),基類中(TimeKeeper)有一個非虛析構函數。這是造成災難的一個因素,因為c++指出:通過一個基類的指針來釋放一個派生類的對象,如果基類的析構函數是非虛的,那麼結果未定義。在運作時有可能發生以下狀況:對象的派生類部分永遠不會被釋放掉。如果對getTimeKeeper的調用恰巧傳回一個指向AtomicClock對象的指針,對象的AtomicClock部分(也就是在AtomicClock類中聲明的資料成員)可能不會被釋放掉,AtomicClock類的析構函數也不會被執行。然而,基類部分(也就是TimeKeeper部分)是會被釋放掉的,這會導緻産生一個古怪的“部分被釋放的”對象。這是使資源洩露,破壞資料結構和在debugger上花費大把時間的絕佳方法。
2.如何解決問題-聲明虛析構函數
消除這個問題很簡單:為基類提供一個虛析構函數。這時如果delete一個派生類對象将會做到你想要的。它會釋放掉整個對象,包括派生類的所有部分:
1 class TimeKeeper {
2
3 public:
4
5 TimeKeeper();
6
7 virtual ~TimeKeeper();
8
9 ...
10
11 };
12
13 TimeKeeper *ptk = getTimeKeeper();
14
15 ...
16
17 delete ptk; // now behaves correctly
基類中(TimeKeeper)除了析構函數外一般情況下會包含虛函數,因為虛函數存在的目的是為了函數在派生類中的定制化實作(Item34)。舉個例子,TimeKeeper會有一個虛函數,getCurrentTime,這個函數在不同的派生類中會有不同的實作。任何有虛函數的類應該肯定有一個虛析構函數。
3.不要在不當作基類的類中聲明虛析構函數
如果類中不包含虛函數,這通常表明它不會被用作基類,如果并沒有打算将一個類作為一個基類,将析構函數聲明為虛是一個壞的想法。考慮一個表示二維空間的點的類:
1 class Point { // a 2D point
2
3 public:
4
5 Point(int xCoord, int yCoord);
6
7 ~Point();
8
9 private:
10
11 int x, y;
12
13 };
如果int占用32Bits,那麼一個Point對象可被放入一個64-bit的緩存器中。并且,這個Point對象可以以一個”64-bit quantity”傳給用其他語言編寫的函數,例如c語言和Fortran。如果将Point的析構函數聲明成虛的,狀況就會發生變化。
虛函數的實作需要對象帶一些資訊,根據這些資訊在運作時能夠決定對象的哪個虛函數會被觸發。這些資訊表現為一個被叫做vptr(virtual table pointer)指針的形式。我們把指向一個函數指針數組的vptr指針叫做vtbl(virtual table);每個有虛函數的類都有一個關聯的vtbl.當虛函數在一個對象上被觸發,實際調用的函數是由對象的vtbl中的vptr來決定的,在vtbl中會查找到合适的函數指針。
關于虛函數是如何實作的細節并不重要。重要的是如果Point類中包含一個虛函數,這個類型的對象會在占用空間上有所增加:在32位機器中,空間會從64bits(兩個int)增加到96bits;在64位機器中,空間會從64bits增加到128bits,因為64位機器上的指針在空間上占用64bits.Point額外增加了一個vptr而緻使記憶體空間增加50-100%。Point将不能在放進64bits的緩存中。并且,c++中的Point也不再同其他語言(如C語言)中聲明的對象有類似的結構了,因為其他語言沒有vptr,是以你不再能夠向(從)其他語言編寫的函數中傳進(傳出)指針了,除非你對vptr進行明确的補償,這屬于實作細節,代碼是以也不能夠被移植了。
是以,無緣無故的将所有析構函數聲明成虛函數同永遠不将其聲明為虛函數犯了一樣的錯誤。事實上,許多人将上面的情形其總結如下:在類中聲明虛析構函數當且僅當類中至少包含一個虛函數。
4.不要繼承析構函數為非虛的類
在虛函數完全缺席的情況下,非虛析構函數的問題同樣會導緻隻釋放部分記憶體的問題。舉個例子,标準string類型不包含虛函數,但是一些被誤導的程式員有時會将其當作基類:
1 class SpecialString: public std::string { // bad idea! std::string has a
2
3 ... // non-virtual destructor
4
5 };
乍一看這麼實作也許無傷大雅,但是如果在一個應用中的某個地方,你以某種方式将指向SpecialString的指針轉換成指向string的指針,然後你在string指針上使用delete,你馬上會被轉到未定義行為的領地:
1 SpecialString *pss =new SpecialString("Impending Doom");
2
3 std::string *ps;
4
5 ...
6
7 ps = pss; // SpecialString* ⇒ std::string*
8
9 ...
10
11 delete ps; // undefined! In practice,
12
13 // *ps’s SpecialString resources
14
15 // will be leaked, because the
16
17 // SpecialString destructor won’t
18
19 // be called
同樣的分析适用于任何缺少虛析構函數的類,包含所有的STL容器類型(例如 vector,list set,tr1::unordered_map(Item54))。如果你曾經受到誘惑,從一個标準容器類或其他沒有虛析構函數的類中繼承,你需要抵抗這種誘惑!(不幸的是,c++沒有提供不能繼承的機制,java中有final類,c#中有sealed類)。
5.純虛析構函數
偶爾情況下為類提供一個純虛析構函數是很友善的。有純虛函數的類是一個抽象類,其不能夠被執行個體化。然而有時候,你想将一個類變成一個抽象類,但是沒有任何純虛函數。該怎麼辦?因為一個抽象類将來會被用作基類,并且基類應該有一個虛析構函數,同時一個純虛函數産生一個抽象類,是以解決方案很簡單:在你想要其變成抽象的類中聲明一個純虛析構函數。看下面的例子:
1 class AWOV { // AWOV = “Abstract w/o Virtuals”
2
3 public:
4
5 virtual ~AWOV() = 0; // declare pure virtual destructor
6
7 };
這個類有一個純虛函數,是以它是抽象類。因為它有一個虛析構函數,是以你不必擔心因為析構函數出現的問題。這裡有個竅門,你必須為純虛函數提供一份定義:
1 AWOV::~AWOV() {} // definition of pure virtual dtor
析構函數工作的方法是最底部的派生類先被調用,然後析構函數的每一個基類會被依次調用。編譯器會從派生類的析構函數中生成一個對~AWOV的調用,是以你必須確定為這個函數提供一個函數體。如果不提供會有連結錯誤。
6.其他一些需要注意的地方
為基類提供虛析構函數的法則隻适用于多态基類,多态基類也就是将基類設計成允許通過基類接口來操作派生類型的類。TImeKeeper是一個多态基類,因為我們想能夠操作AtomicClokc和WaterClock對象,在即使隻有TimeKeeper指針指向這些派生類對象的情況下。
并不是所有的基類都被設計成能夠使用多态。舉個例子,标準string類型還有STL容器類型并沒有被設計成基類,更不用說多态了。一些類被設計成當基類使用,但是沒有被設計成使用多态。舉個例子,Item6中的UnCopyable和來自标準庫中的input_iterator_tag(Item47),這樣的類沒有被設計成通過基類接口操作派生類。是以,也不需要虛析構函數。
作者:
HarlanC部落格位址:
http://www.cnblogs.com/harlanc/個人部落格:
http://www.harlancn.me/本文版權歸作者和部落格園共有,歡迎轉載,但未經作者同意必須保留此段聲明,且在文章頁面明顯位置給出,
原文連結如果覺的部落客寫的可以,收到您的贊會是很大的動力,如果您覺的不好,您可以投反對票,但麻煩您留言寫下問題在哪裡,這樣才能共同進步。謝謝!