參考:
簡述
還記得我們之前寫的編譯期多態與運行期多態嗎?繼承是運行期多態的基石。下一節則是模板,編譯期多態的基礎。
運行期多態
- 優點
- OO設計中重要的特性,對客觀世界直覺認識。
- 能夠處理同一個繼承體系下的異質類集合。
- 缺點
- 運行期間進行虛函數綁定,提高了程序運行開銷。
- 龐大的類繼承層次,對接口的修改易影響類繼承層次。
- 由於虛函數在運行期在確定,所以編譯器無法對虛函數進行優化。
- 虛表指針增大了對象體積,類也多了一張虛函數表,當然,這是理所應當值得付出的資源消耗,列爲缺點有點勉強。
OOP核心思想在C++中表現是三部分:數據抽象,繼承和動態綁定。通過使用數據抽象,可以將類的接口和實現分離(也就是類);使用繼承,可以定義相似的對象類型,並對其關係建模;使用動態綁定,在一定程度上忽略了相似類型間的區別,統一了相似類的接口。
首先通過小例子來看看爲什麼繼承。
-
通過前面的學習我們得知了類的概念,現在考慮這樣一種應用場景,要編寫一些管理學生的類,分別管理中學生,大學生,研究生。
按照面對對象的思想,我們應該給這幾個不同的學生都要對應一個類。現在問題來了,學生這一個羣體,它有一些共同屬性,例如年齡,姓名,性別等等,這些屬性及其所對應的方法沒必要在每個類中單獨寫一遍,這樣既不利於編寫代碼(多個類萬一有的屬性沒打對),也不利於後期維護(如果發現哪個方法錯誤,要修改的話所有類都需要修改一遍)。
到了使用繼承的時候了,我們可以將這些共同的屬性統一組織到一個學生類(基類),然後其它的中學生,大學生等(派生類)都可以通過繼承這個類,獲得它所有的屬性。這樣修改時只需要修改這個基類,減少代碼量和方便維護。
後面我們會進一步介紹,這種表示一個基類是派生類共同屬性,派生類是基類的擴展 這種繼承關係是public 繼承 ,學生類是 抽象基類
-
還有一些類它們間的關係和上面的例子又不太相似。考慮這樣一種關係:發動機和汽車。直接按上面的思想來繼承的話就會有根本思想的錯誤,發動機不具有汽車的通有屬性,發動機更像是汽車的一個組成部分,實際上對於這種關係我們也採用繼承。
這種繼承關係是private 繼承,表達基類是派生類一部分的概念。
繼承的概念不是很難,但在實際應用中正確使用比較困難,本文只介紹基礎應用,後期會再做補充擴展。
繼承基礎
靜態類型和動態類型
表達式的靜態類型在編譯期間是已知的,它是變量聲明時的類型。動態類型則是內存中對象的類型,動態類型只有運行時才能確定。
只有基類指針或引用的動態類型和靜態類型 纔可能 不一致,從根本說,這纔是C++支持多態的基礎。
靜態綁定,動態綁定 所謂動態綁定是相對靜態綁定而言,靜態綁定就是我們要調用哪個函數是在編譯期就已經綁定決定好,而動態綁定是在運行時決定的。
可以將派生類的指針和引用傳遞給基類的指針和引用,當通過基類的指針或引用調用虛成員函數時,那麼會根據這個指針或引用所指向的對象是 基類對象還是 派生類對象,從而調動基類或派生類的虛成員函數。
引用的底層就是指針,所以在這它和指針的作用等價。
聲明
聲明一個派生類,不需要加上派生列表 class Deriver;
並且如果想繼承某個類,則基類必須在之前已經定義過。沒有定義則爲不完整類型,無法繼承。
繼承與靜態成員
如果基類定義了一個靜態成員,則在整個繼承體系中只有該成員的唯一定義。
拷貝和賦值
當派生類對象賦值給基類對象,或作爲參數拷貝構造基類對象,調用的都是基類的函數。因爲基類對象無法轉化爲派生類對象。而派生類對象可以只使用基類部分。
虛函數
基類必須區分兩類函數,一類是希望派生類直接繼承而不要改變的函數,一類是希望派生類對其進行覆蓋的函數。對於後者,基類應將其聲明爲虛函數(virtual)。當我們使用指針或引用調用虛函數時,調用將會被動態綁定。這樣帶來的好處是我們可以以同一種形式調用不同版本的函數。根據指針或引用的動態類型不同而調用不同的函數,簡化編程。
虛函數適用於公有繼承,因爲跟公有繼承的思想符合。
任何構造函數之外的非靜態函數都可以是虛函數。virtual 聲明只能出現在類內。
如果派生類想覆蓋基類的虛函數,就必須函數名,形參,返回類型 都相同(而函數重載不要求返回類型)。
必須爲每一個虛函數提供定義,因爲編譯器在編譯時不知道將來會運行哪個版本的虛函數。
override 和final 可以控制成員函數在繼承中關係,都是添加在形參後
-
final 指定一個函數(包括虛函數與非虛函數)不可在派生類中被覆蓋
假如有學生類(基類,有年齡屬性),那麼不管是哪個階段的學生(不同的派生類),其查詢年齡的函數都是一樣的,爲了避免派生類不小心覆蓋這個函數,就可以在學生類中將此函數聲明爲final
-
override 指定這個函數是覆蓋基類的虛函數
主要用於排除一些人爲的錯誤,如果指定一個函數爲override 說明想用這個函數覆蓋基類的虛函數,假如此時不小心形參沒寫對,或者返回類型錯誤等等,則編譯器會直接指出該函數未能正確覆蓋。
虛函數的默認實參:通常使用 引用或指針的靜態類型中虛函數默認實參,所以儘量不要有同一個虛函數基類子類默認實參不同的情況。
處於派生體系中間的類即是基類也是派生類,如果指針或引用的靜態類型是該類,則虛函數默認實參是該類指定的默認實參,
class Base { public: virtual void show( int x=10) { cout <<x<< "666" << endl; } }; class D1 :public Base{ public: void show(int x=20 )override { cout << x<<"D1" << endl; } }; class D2 :public D1 { public: void show(int x = 30)override { cout << x<<"D3" << endl; } }; int main( ) { D1 * x = new D1( ); D1 * y = new D2( ); x->show( ); y->show( ); return 0; } /*結果 20D1 30D3 */
如果想避免虛函數動態綁定機制,指定域名訪問父類中的虛函數
BaseClass *p = new DerivedClass(); //基類有fun 虛函數,並且派生類已覆蓋
p->fun(); //調用派生類fun
p->BaseClass::fun(); //調用基類fun
虛析構函數
只要有虛函數,則析構函數應該設置爲虛函數,這樣當delete 動態類型和靜態類型不一致的指針對象時,可以正常先調用派生類的析構函數,再調用基類。
底層實現
其底層實現是通過虛函數指針和虛函數表來實現,具體感興趣可進一步搜索研究
純虛函數
純虛函數就是沒有定義的虛函數,具有純虛函數的類是抽象基類,通過 =0
聲明一個虛函數使其變爲純虛函數。
= 0 實際上將該函數的指針指向NULL
抽象基類
類是描述對象的模板,而有些東西,它是一個抽象的概念,例如汽車,遊戲,電腦,這種表示一類物品的抽象概念。例如遊戲,遊戲都具有發佈時間,運行平臺等屬性(這些屬性就應該放在基類)。當我們說遊戲,世界上所有的遊戲產品都是遊戲這個概念下的實體,例如王者榮耀,它是遊戲這個類的派生類的一個具體對象。當我說創建一個遊戲時,無法直接創建遊戲這兩個字所代表的特定對象。
所以,這些抽象類,它不對應現實世界中的實體,我們無法直接獲得它的對象,只能通過子類進一步描述後再獲取對象。
爲了實現這樣的功能,c++提供了抽象基類,抽象基類不可以定義對象。從實現的角度來說具有純虛函數的類稱爲抽象基類。子類中沒有實現純虛函數的類依然是抽象基類。
一個抽象基類中所有函數都是純虛函數稱爲接口類。
訪問權限與繼承關係
訪問權限
在前面 類與對象的基礎 中我們已經看到了類內兩種訪問權限 public private ,這塊,我們看一個新的訪問限制符 受保護的成員protected,這個訪問限制只有在繼承中表現與private不同。
如果基類中成員定義爲protected 則基類,派生類中都可以訪問它,而限制通過基類對象和派生類對象訪問。
繼承限制
類內的訪問限制符是限制用戶通過對象訪問類 ,而在繼承中訪問限制符是 控制派生類用戶對基類的訪問權限
如果想更改繼承的權限可以在派生類中使用 using
繼承關係
- 公有繼承表示派生類是基類,派生類是對基類更詳細的描述的概念。(如動物類爲基類,人類爲派生類)
- 私有繼承表示基類是派生類一部分的概念。(如發動機類是基類,汽車類是派生類)
- 保護繼承不太常用,它的特點不是很鮮明。
派生類向基類的轉換的可訪問性
當操作者是用戶時,只有派生類公有繼承基類纔可以轉換。
當操作者是派生類時,派生類向基類的轉換都是可以的。
其它相關知識點
類作用域
重載 指的是函數名相同,形參不同。在繼承中,派生類只要和基類中 **函數名相同 **就會發生 同名隱藏 現象。
而通過using 可以解除同名隱藏現象。此時可進行 基類與派生類 函數重載。
class Base {
public:
void show( int x) {
cout << "666" << endl;
}
};
class D1 :public Base{
public:
void show(string str) {
cout << str << endl;
}
};
class D2 :public D1 {
public:
//using Base::show;
void show( ) {
cout << "7777" << endl;
}
};
int main( ) {
D2 d;
d.show(2); //x D2 沒有接受int 的成員函數,Base 和 D1 的show 方法都被隱藏了。如果添加第15行,則正常
D1 dd;
dd.show(); //x D1 沒有無參的show 函數
return 0;
}
除了覆蓋基類的虛函數之外,不要定義和基類同名的成員。
構造函數與拷貝控制
派生類構造函數必須調用基類構造函數
派生類賦值運算符也需要調用基類賦值運算,控制基類的賦值。
派生類析構函數不用調用基類析構函數,其會自動調用。
繼承構造函數
可以通過using 聲明來繼承 基類的構造函數
繼承與智能指針與容器
當在容器中存放派生類或基類對象時,通過存放指針,可以混用。這種集合稱爲異質對象集合。
多重繼承與虛繼承
多重繼承
在派生類的派生類列表中繼承多個基類。例如以下繼承體系,Panda 既繼承Bear 又繼承 Endangered ,就稱爲多重繼承。
class ZooAnimal {
public:
int z = 1;
};
class Bear :public ZooAnimal {
int b = 2;
};
class Endangered {
int e = 4;
};
class Panda :public Bear, public Endangered {
int p = 5;
};
int main( ) {
Panda temp;
return 0;
}
構造順序
在多重繼承的情況下,派生類會在構造函數初始化列表中,按照派生列表中基類出現的順序,初始化所有的基類。(當有虛繼承時,存在例外)
虛繼承
多重繼承體系中,有些繼承體系得到的結果卻不是我們所期望的。假如如下繼承體系:
先看看多重繼承:
class ZooAnimal {
public:
int z = 1;
};
class Bear :public ZooAnimal {
int b = 2;
};
class Raccoon :public ZooAnimal {
int r = 3;
};
class Endangered {
int e = 4;
};
class Panda :public Bear, public Raccoon, public Endangered {
int p = 5;
};
int main( ) {
Panda temp;
temp.z = 6; //error z 不明確
temp.Raccoon::z = 6;
return 0;
}
這樣的思路是沒有問題的,但是實際上實現的過程中有點問題。但爲什麼 z 不明確呢?實際上只要明白這種情況下的內存分佈就很好理解。
可以看到,對於 Panda 對象來說,它實際上有兩個 ZooAnimal 成員,一個是由 Bear 繼承而來,另一個是由Raccoon 繼承而來。兩個ZooAnimal 對象的數據沒有關係。
這顯然不是我們想要表示的繼承關係,在我們想表示的繼承關係中,熊貓 就是屬於 動物園動物,而不是說它屬於兩個動物園動物。這種情況下,就要考慮使用虛繼承。
虛繼承的就是令某個類願意共享它繼承的基類(被共享的基類稱爲虛基類),所以虛繼承一般出現在在繼承體系的中間部分(即除過最底層的基類和最頂層的派生類)。無論一個派生類的派生體系中出現過多少次虛基類,它都只有一份虛基類對象。
//... 其它部分都不改變,只是添加兩個virtual 關鍵字
class Bear :virtual public ZooAnimal { //表示願意與其它類共享ZooAnimal
int b = 2;
};
class Raccoon :virtual public ZooAnimal {
int r = 3;
};
//...
底層實現
其底層是通過虛繼承表 和虛繼承指針 實現,具體感興趣可進一步搜索研究
構造順序
由最底層的派生類,按照繼承列表的順序,先構造所有虛基類,無論是直接還是間接。之後再按照順序構造普通基類,最後再構造自身。而析構順序與構造順序相反。虛基類總會優先於普通基類構造。