看上去最為簡單的(public)繼承的概念由兩個單獨部分組成:函數接口的繼承和函數模闆繼承。這兩種繼承之間的差別同本書介紹部分讨論的函數聲明和函數定義之間的差別完全對應。
1. 類函數的三種實作
作為一個類設計者,有時候你隻想派生類繼承成員函數的接口(聲明)。有時候你想讓派生類同時繼承接口和實作,但是你允許它們覆寫掉繼承而來的函數實作。但有時候你卻想讓派生類繼承一個函數的接口和實作并且不允許它們被覆寫掉。
為了對這些不同的選擇有一個更好的了解,考慮表示幾何圖形的類繼承體系:
1 class Shape {
2 public:
3 virtual void draw() const = 0;
4 virtual void error(const std::string& msg);
5 int objectID() const;
6 ...
7 };
8 class Rectangle: public Shape { ... };
9 class Ellipse: public Shape { ... };
Shape是一個抽象類;是由純虛函數draw所标記的。是以客戶不能建立Shape類的執行個體,而隻有它的派生類才可以。盡管如此,Shape對(public)繼承自它的所有類會産生很大的影響,因為:
- 成員函數接口總是被繼承,正如 Item 32 中解釋的,public繼承意味着“is-a”,也就是對基類來說為真的任何東西對派生類來說也必須為真。是以,如果一個函數可以被應用在一個類中,它也必須能被應用到它的派生類中。
Shape類中聲明了三個函數。第一個,draw,畫出目前對象;第二個,error,當error需要被記錄的時候被調用。第三個,objectID,為目前對象傳回一個唯一的整型辨別符。每個函數以一種不同的方式被聲明:draw是純虛函數;error是簡單的(不是純的)虛函數;objectID是非虛函數。這些不同聲明隐藏的含義是什麼呢?
1.1 純虛函數
考慮第一個純虛函數draw:
1 class Shape {
2 public:
3 virtual void draw() const = 0;
4 ...
5 };
純虛函數的兩個最具特色的特征是:它們必須被繼承它們的任何具現類重新聲明;在抽象類中它們通常情況下沒有定義。将這兩個特征放在一起,你就會發現:
- 聲明純虛函數的意圖是讓派生類隻繼承函數接口
這使得Shape::draw函數是非常有意義的,因為對于所有的Shape對象來說能夠被畫出來是一個合理的需求,但是Shape類不能為這個函數提供合理的預設實作,比如,畫一個橢圓的算法和畫一個矩形的算法是不一樣的。Shape::draw的聲明對派生具現類的設計者說,“你必須提供一個draw函數,但是我并不知道你該如何實作它。”
順便說一下,為一個純虛函數提供一個定義也是可能的。也就是你可以為Shape::draw提供一個實作,C++不會發出抱怨,但是調用它的唯一方式是在函數名前加上類名限定符:
1 Shape *ps = new Shape; // error! Shape is abstract
2
3 Shape *ps1 = new Rectangle; // fine
4
5
6
7 ps1->draw(); // calls Rectangle::draw
8
9 Shape *ps2 = new Ellipse; // fine
10
11
12
13 ps2->draw(); // calls Ellipse::draw
14
15 ps1->Shape::draw(); // calls Shape::draw
16
17 ps2->Shape::draw(); // calls Shape::draw
除了幫助你在雞尾酒會上給你的程式員夥伴留下深刻印象之外,這個特性通常來說效用有限。然而,你在下面會看到,它可以作為一種機制為簡單的(非純的)虛函數提供比平常更加安全的預設實作。
1.2 非純的虛函數
簡單虛函數背後的故事同純虛函數有些不太一樣。通常情況下來說,派生類繼承函數接口,但是簡單虛函數提供了可能會被派生類覆寫的實作。如果你再想想,你會意識到:
- 聲明一個簡單虛函數的目的是讓派生類繼承一個函數接口或者一個預設實作。
考慮Shape::error的情況:
1 class Shape {
2 public:
3 virtual void error(const std::string& msg);
4 ...
5 };
這個接口表明在遇到錯誤的時候每個類必須提供一個錯誤函數,但是每個類對錯誤如何進行處理可以自由控制。如果一個類不想做任何特殊的事情,那麼調用基類Shape中error的預設實作就可以了。也就是Shape::error的聲明對派生類的設計者說,“你可以支援error函數,但如果你不想自己實作,你可以使用Shape類中的預設版本。”
1.2.1 同時為簡單虛函數提供函數接口和預設實作是危險的
同時為簡單虛函數提供函數接口和預設實作是危險的。為什麼?考慮為XYZ航空公司設計了飛機繼承體系。XYZ隻有兩種類型的的飛機,型号A和型号B,同種飛機的飛行方式相同。是以,XYZ設計了如下的繼承體系:
1 class Airport { ... }; // represents airports
2
3 class Airplane {
4
5 public:
6
7 virtual void fly(const Airport& destination);
8
9 ...
10
11 };
12
13 void Airplane::fly(const Airport& destination)
14
15 {
16
17 default code for flying an airplane to the given destination
18
19 }
20
21 class ModelA: public Airplane { ... };
22
23 class ModelB: public Airplane { ... };
為了表示所有的飛機必須支援fly函數,還有不同型号的飛機可能需要fly的不同實作,是以Airplane::fly被聲明為virtual。然而,為了防止在ModelA和ModelB中實作同一份代碼,我們為Airplane::fly提供了預設實作,ModelA和ModelB可以同時繼承。
這是典型的面向對象設計。兩個類分享同一個特征(實作fly的方式),是以一般的特征都會移到基類中,然後被派生類繼承。這種設計使得類的普通特性比較清晰,防止代碼重複,可以促進将來的增強實作,使長期維護更加容易——這是面向對象如此受歡迎的原因,XYZ應該為此感到驕傲。
現在假設XYZ公司界定引入新類型的飛機,Model C。型号C和型号A和B不一樣,它的飛行方式變了。
XYZ的程式員為Model C在繼承體系中添加了新類,但是他們如此匆忙的添加新類,以至于忘了重新定義fly函數:
1 class ModelC: public Airplane {
2
3
4
5 ... // no fly function is declared
6
7 };
在他們的代碼中有類似下面的實作:
1 Airport PDX(...); // PDX is the airport near my home
2
3 Airplane *pa = new ModelC;
4
5 ...
6
7
8 pa->fly(PDX); // calls Airplane::fly!
這會是一個災難:型号C的飛機嘗試用型号A或者型号B的飛行方式去飛行。這不是增加旅客信心的行為。
1.2.2 解決方法一,将預設實作分離成單獨函數
這裡的問題不在于Airplane::fly有預設的行為,而在于允許 Model C在沒有明确說明它需要基類行為的情況下繼承了基類的行為。幸運的是,很容易為派生類提供隻有在它們需要的情況下才為其提供的預設行為。這個竅門斷絕了虛函數接口和預設實作之間的聯系。下面是實作的方法:
1 class Airplane {
2 public:
3 virtual void fly(const Airport& destination) = 0;
4 ...
5 protected:
6 void defaultFly(const Airport& destination);
7 };
8 void Airplane::defaultFly(const Airport& destination)
9 {
10 default code for flying an airplane to the given destination
11 }
注意Airplane::fly已經轉成了一個純虛函數。它為飛行提供了接口。在Airplane類中同樣展示出了預設實作,但是現在它是以獨立函數的形式存在,defaultFly。像ModelA和ModelB這樣的類如果想使用預設實作,隻要在fly函數體内調用Inline函數defaultFly就可以了(
Item30中有inline函數和虛函數之間互動的資訊):
1 class ModelA: public Airplane {
2 public:
3 virtual void fly(const Airport& destination)
4 { defaultFly(destination); }
5 ...
6 };
7 class ModelB: public Airplane {
8 public:
9 virtual void fly(const Airport& destination)
10 { defaultFly(destination); }
11 ...
12 };
對于ModelC類來說,偶然的繼承fly的不正确實作将不再可能,因為Airplane中的純虛函數強制ModelC提供它自己版本的fly。
1 class ModelC: public Airplane {
2 public:
3 virtual void fly(const Airport& destination);
4 ...
5 };
6 void ModelC::fly(const Airport& destination)
7 {
8 code for flying a ModelC airplane to the given destination
9 }
這個機制也不是十分安全的(程式員仍然能夠複制粘貼而導緻錯誤),但是它比原來的設計可靠多了。因為對于Airplane::defaultFly來說,它是protected的是因為它是Airplane和它的派生類中的實作細節。使用airplane的客戶隻關心它們能夠起飛,而不管飛行是如何實作的。
Airplane::defaultFly是一個非虛函數同樣重要。因為沒有派生類可以重定義這個函數,這也是Item36所描述的真理。如果defaultFly是虛的,就會有一個循環問題:當派生類想重新定義defaultFly但是忘了會怎樣?
1.2.3 解決方法二,利用純虛函數提供預設實作
一些人反對将函數接口和預設實作分離的想法,就像上面的fly和defaultFly一樣。首先,它們意識到,繁殖出十分相關的函數名字污染了類命名空間。但是它們仍然同意将函數接口和預設實作分離。它們如何處理這種看上去沖突的事情呢?通過利用純虛函數必須在具現派生類中重新聲明這個事實,但是它們也有可能有自己的實作。下面的例子展示了Airplane繼承體系是如何利用定義純虛函數的能力的:
1 class Airplane {
2 public:
3 virtual void fly(const Airport& destination) = 0;
4 ...
5 };
6
7 void Airplane::fly(const Airport& destination) // an implementation of
8 { // a pure virtual function
9 default code for flying an airplane to
10 the given destination
11 }
12 class ModelA: public Airplane {
13 public:
14 virtual void fly(const Airport& destination)
15 { Airplane::fly(destination); }
16 ...
17 };
18 class ModelB: public Airplane {
19 public:
20 virtual void fly(const Airport& destination)
21 { Airplane::fly(destination); }
22 ...
23 };
24 class ModelC: public Airplane {
25 public:
26 virtual void fly(const Airport& destination);
27 ...
28 };
29 void ModelC::fly(const Airport& destination)
30 {
31 code for flying a ModelC airplane to the given destination
32 }
這個設計同前面的設計是基本相同的,除了純虛函數體Airplane::fly代替了獨立函數Airplane::defaultFly。從本質上來說,fly已經被分成了兩個基本的元件。它的聲明指定了接口(派生類必須使用它),同時它的定義指定了預設行為(派生類可能會使用,但是隻有在顯示的請求的時候才會使用)。将fly和defaultFly合并到一起,你就會失去為兩個函數提供不同保護級别的能力:過去是protected的代碼(在defaultFly中)現在變成了public的(因為它在fly中)。
1.3 非虛函數
最後,讓我們看一看Shape的非虛函數,objectID:
1 class Shape {
2 public:
3 int objectID() const;
4 ...
5 };
當一個成員函數是非虛的,就不想其在派生類中有不同的行為。事實上,一個非虛成員函數指定了一種超越特化的不變性(invariant over specialization),無論一個派生類被如何特化,它的行為不可改變。
- 聲明一個非虛函數的意圖在于讓派生類繼承一個函數接口,并且有一個強制的實作,
你可以将Shape::objectID的聲明想象成如下,“每一個Shape對象都有一個函數來産生一個對象辨別符,這個對象辨別符以相同的方式計算出來。計算方式由Shape::objectID的定義來決定,任何派生類都不應該嘗試去修改它的定義”。因為一個非虛函數确定了一個超越特化的不變性,它永遠不會在派生類中被定義,這一點将在Item36中進行讨論。
2. 類設計者容易犯的兩種錯誤
對純虛函數,簡單虛函數和非虛函數進行聲明的不同點在于允許你精确的指定派生類會繼承什麼:隻繼承接口,繼承接口和預設實作或者接口和強制實作。因為從根本上來說這些不同的聲明類型意味着不同的東西,在你聲明成員函數的時候你必須在他們之間進行選擇。如果你這麼做了,你就應該能夠避免沒有經驗的類設計者才會犯的兩種普通錯誤。
2.1 錯誤一,将所有函數聲明為非虛
第一種錯誤是将所有函數聲明成非虛。這沒有給派生類的特化留下任何餘地;特别對于非虛析構函數來說是有問題的(
Item 7)。當然,我們有足夠的理由設計一個不被當作基類的類,在這種情況下,隻聲明非虛函數是合适的。然而通常情況下,這些類在下面兩種情況下被建立出來:要麼是忽略了虛函數和非虛函數的差別,要麼就是過度擔心虛函數所花費的開銷。事實是基本上任何被用作基類的類都會使用虛函數。(
)
如果你關心虛函數的開銷,允許我拿出80-20法則(
Item 30也提到了),它表明了在一個典型的程式中,20%的代碼會花費80%的運作時間。這個法則很重要,因為它意味着,平均來說,你的程式中的80%的函數調用可以是虛函數調用,但對你的程式的性能影響卻是很輕微的。在你對能否負擔的起虛函數的開銷進行擔心之前,確定你所關注的代碼是對程式有重大影響的20%的那一部分。
2.1 錯誤二,将所有函數聲明為虛函數
另外一個普通的問題是将所有成員函數聲明成虛函數。有時候這麼做是對的——
Item 31中的接口類就是這麼做的。然而,這也是一個類設計者缺乏堅定立場的标志。一些函數不應該在派生類中被重定義,當碰到這種情況,你就應該把這個函數定義為非虛。不是說隻要花費一點時間對函數進行重定義,就能使使類滿足所有人的需求。如果你需要特化上的不變性,不要害怕說不!
3. 總結
-
- 接口繼承不同于實作繼承。在public繼承下,派生類總是會繼承基類接口。
- 純虛函數隻是指定了接口繼承。
- 簡單虛函數指定了接口繼承外加一個預設實作。
- 非虛函數指定了一個接口繼承外加一個強制實作。
作者:
HarlanC部落格位址:
http://www.cnblogs.com/harlanc/個人部落格:
http://www.harlancn.me/本文版權歸作者和部落格園共有,歡迎轉載,但未經作者同意必須保留此段聲明,且在文章頁面明顯位置給出,
原文連結如果覺的部落客寫的可以,收到您的贊會是很大的動力,如果您覺的不好,您可以投反對票,但麻煩您留言寫下問題在哪裡,這樣才能共同進步。謝謝!