文章目錄
- 繼承
- 繼承的基本概念
- 繼承的基本定義
- 繼承方式
- 繼承的一些注意事項
- 繼承類模板
- 基類和派生類之間的轉換
- 繼承中的作用域
- 派生類的默認成員函數
- 默認構造函數
- 拷貝構造
- 賦值重載
- 析構函數
- 默認成員函數總結
- 不能被繼承的類
- 繼承和友元
- 繼承與靜態成員
- 多繼承及其菱形繼承問題
- 繼承模型
- 多繼承
- 菱形繼承
- 菱形繼承解決方案之——虛繼承
- 菱形繼承的一個實例
- 多繼承中的指針偏移
- 繼承和組合
繼承
本篇文章將進入c++學習地進階部分。相比以往學的基礎語法和基本概念會有所提升,且需要以往的概念掌握較為扎實。第一個部分就從繼承開始講起。
繼承的基本概念
c++面向對象有三大特性:封裝、繼承、多態。我們今天要講的正是三大特性之一——繼承。
繼承(inheritance)機制是面向對象程序設計使代碼可以復用的最重要的手段,它允許我們在保持原有類特性的基礎上進行擴展,增加方法(成員函數)和屬性(成員變量),這樣產生新的類,稱派?類。繼承呈現了面向對象程序設計的層次結構,體現了由簡單到復雜的認知過程。以前我們接觸的函數層次的復用,繼承是類設計層次的復用。
我個人看來,可以這么理解:一個類繼承一個類,就是將被繼承的類的東西放在繼承的類上,但是又可以在此基礎上衍生新的成員變量和函數。這不就很像繼承先輩遺產嘛?將先輩遺產繼承過來,但是我們可能還有自己的財產。那加在一起才是我的總財產。
我們下面來看一段代碼就能理解了:
比如我們想要設計兩個類,叫老師和學生。那這兩個類都會有基本的成員變量比如:年齡、姓名、電話等。可能針對于老師會有一些特殊的函數如教書,學生會有學習的函數:
class Student
{
public:// 進?校園/圖書館/實驗室刷?維碼等?份認證void identity(){// ...}// 學習void study(){// ...}
protected:string _name = "peter"; // 姓名string _address; // 地址string _tel; // 電話int _age = 18; // 年齡int _stuid; // 學號
};class Teacher
{
public:// 進?校園/圖書館/實驗室刷?維碼等?份認證void identity(){// ...}// 授課void teaching(){//...}
protected:string _name = "張三"; // 姓名int _age = 18; // 年齡string _address; // 地址string _tel; // 電話string _title; // 職稱
};
如果我們分別實現兩個類,其實是很麻煩的。最主要的就是代碼邏輯會有冗余,因為有些成員是重復的。在以前函數或者某些類在功能上重復,數據類型不同時,衍生了一個叫模板的概念,是一種效率比較高的代碼復用手段。但是現在是內部的代碼有些相同,有些不同,應當如何復用呢?答案是使用繼承。
我們先來簡單看看是怎么實現的,有一些概念我們后續會提及:
class Preson {
public:void identity() {cout << "identity: " << _id << endl;}protected:string _name = "peter"; // 姓名string _address; // 地址string _tel; // 電話string _id;//身份int _age = 18; // 年齡
};class Student : public Preson {
public:void study(){}
protected:int _stuid;//學號
};class Teacher : public Preson {
public:void teach() {}
protected:string _title;//職稱
};
我們先不管繼承的方式和為什么使用protected限定符,我們現在只需看看邏輯是怎么走的即可。
我們發現,對于重復的信息(即相同的成員變量和成員函數),我們都放在了一個叫Person的類內。那對于Student和Teacher的定義,不正好是在Person類的基礎上衍生而來的嗎?那可以進行繼承,就是把Person類中的內容繼承下來,再配合自己需要使用的成員,這不構造好了嗎?這里代碼量并不大,但是已經能體現出代碼復用的優勢了。
我們來看看子類內部是個什么結構:
符合我們之前說的,內部就是包含了父類的內容。而且在監視窗口下,會把父類當成一個整體。我們也最好是這么理解的。具體原因后續來說。
然后現在就該來講,繼承是如何使用的。
繼承的基本定義
對于很多剛剛出現的新的內容,我們現在來一一講解。
繼承是需要有父類(被繼承類) 的,就和我們模板特化一樣,需要有主模板。在有了父類的情況下,繼承類的用法為:class 繼承類名 : 繼承方式 父類(被繼承類)
被繼承的類就是父類,那類比人類父子關系,繼承類就是叫子類。當然有些教材或者書籍上可能會稱父類為基類,子類為派生類。這個其實也很好理解。基類對應的就是基本的概念,就代表被繼承。派生類就是在基礎的方式上進行衍生。
所以我們最后得到繼承類的定義:class 子類名 : 繼承方式 父類(被繼承類)
代碼示例:
class Student : public Preson {
protected:int _stuid;//學號
};class Teacher : public Preson {
protected:string _title;//職稱
};
繼承方式
我們重點講講這里的繼承方式:
我們發現,繼承方式對應的那個空填的的是public,這是不是意味著這個空還能填
private或者protected這兩個類作用域限定符呢?
很聰明,是可以,只不過一般情況下用的都是public而已。
那它們分別代表著什么意思呢?我們來看看下面這個表格:
父類中成員變量屬性 / 繼承方式 | public繼承 | protected繼承 | private繼承 |
---|---|---|---|
父類的public成員 | 子類的public成員 | 子類的protected成員 | 子類的private成員 |
父類的protected成員 | 子類的protected成員 | 子類的protected成員 | 子類的private成員 |
父類的private成員 | 不可見 | 不可見 | 不可見 |
這個表格看著很多情況,其實很好記憶,我們將其分為兩大類:
- 父類的private成員無論以什么方式繼承都是不可見的。這里的不可見是指基類的私有成員
還是被繼承到了派生類對象中,但是語法上限制派生類對象不管在類里面還是類外面都不能去訪問它。
我們來看看是怎么個事:
至于在類外面使用就肯定是不可能的,因為有限定符private的限制。這是我們早已知道的。但是我們發現,繼承過后,父類A中的私有成員在子類a中也是無法使用的。至于其他繼承方式就不再演示了,也都是一樣的效果。
我們現在來看看是怎么樣繼承的:
其實這個看不見并不是說不繼承過來,而是語法規定,基類的私有成員繼承到派生類也是無法在派生類中進行使用。那類外面就更不可能了。
- 對于基類中public和protected成員來講,繼承到派生類的限定方式是Min(基類中成員的訪問方式, 繼承方式),其中:public > protected > private
這里我們就來講講限定符protected的作用是什么:
在之前剛進入類的學習的時候,我們并沒有對這個限定符進行過多介紹,只是說知道那時候它和private的用法差不多就ok了。確實如此,因為protected正常情況下確實也是限制了類外不能使用其限定的成員。
但是我們倒回繼承方式表格來看:
基類本身也是類。很可能有時候也會直接使用基類。如果基類中想控制在類中使用而不能在外界使用,用private來修飾確實可以。但是無論你以何種方式繼承到派生類,都是 “看不見” 的,也就是不能在派生類中使用。
我們此時就來想,有沒有這么一種場景,我需要控制只能在類中使用,但是又希望繼承到派生類的時候在派生類里面也能使用呢?這個時候protected的起到這個作用了。所以可以看出保護成員限定符是因繼承才出現的,我們也就很好的理解三個限定符的大小關系了。
所以對于這個表格記憶其實很簡單,就這兩個大點進行理解。
繼承的一些注意事項
當然,我們還需要了解一些關于繼承這個概念的注意事項。
其實對于繼承方式的那個選項是可以不寫的,甚至繼承的基類可以是struct。
只不過使用對于繼承的是struct還是class是有區別的。
區別就是:繼承class默認是private繼承,而繼承struct默認是public繼承,我們驗證一下class的即可:
很明顯,使用私有繼承,對于A類中的public成員 _b在報錯界面顯示的是無法訪問在a中的private成員。如果默認是private繼承,那么基類中什么成員繼承下來都是private成員。所以很好理解這個報錯。而至于變量_a則是因為本身就是在基類中的私有變量,所以繼承下來是看不見的。
對于繼承struct的情況我就不再演示了,感興趣的讀者可以自行前往嘗試。
但是還需要注意的是:
在實際運用中?般使用都是public繼承,幾乎很少使用protetced/private繼承,也不提倡使用protetced/private繼承,因為protetced/private繼承下來的成員都只能在派生類的類里面使用,實際中擴展維護性不強。
所以如果實在記憶不下那么多知識點就專門記一下public繼承的要點即可。當然充分理解上面說的每一點進行記憶還是不難的。
繼承類模板
現在我們再來看一個情況,即繼承的類是個類模板。
之所以要提及繼承類是個類模板是因為編譯器的按需實例化處理。
在講模板進階的時候就已經說到過,編譯器為了提高效率以及節省資源,對于類模板的處理都是按需實例化的。只會檢查一下基本的語法是否有問題(如缺失分號、括號是否匹配,或者檢查不依賴于模板參數的函數是否存在)。老一點的編譯器可能在沒有調用某個接口的情況下對于內部語法檢查都不會做(如vs 2013)。這是模板的知識,我們先簡單回顧一下做鋪墊。
我們現在來看下面這樣一個例子:
這里實現棧是使用繼承來實現的。和適配器方式的區別后面再說。當前只需要知道,棧也可以使用繼承方式來實現。
我們來看這個場景,發現報錯了。正常來講,這些接口在基類中是public 成員,按道理public繼承下來在派生類中應該是可以使用的。為什么找不到標識符呢?
這時候就需要深刻理解按需實例化了。我們說過,編譯器對于模板的處理是按需實例化。即調用了才會進行類型推演。此時我們定義一個stack< int > st,很多人會覺得,我不是把int傳過去了嗎,怎么還不能識別呢?
這是因為,定義模板類對象的時候傳入的參數類型是給默認構造函數/構造函數使用的。我們并沒有調用它的內部接口啊,所以那些接口都是沒有實例化的。
我們可以嘗試驗證一下:
我們把默認構造寫成private成員,編譯器發現我們寫了就不會自己寫。但是我們在外界定義類對象的時候很明顯報錯了。無法訪問默認構造函數。這就很好證明了,模板類定義的時候是只先實例化構造函數的。
基于此我們應該改進一下我們的代碼,并且將功能完善一下:
#include<vector>
template<class T>
class stack : public std::vector<T>{
public:void push(const T& val) {std::vector<T>::push_back(val);}void pop() {std::vector<T>::pop_back();}size_t size() const{return std::vector<T>::size();}bool empty() const {return std::vector<T>::empty();}T& top() {return std::vector<T>::back();}const T& top() const{return std::vector<T>::back();}protected:
};
我們發現是可以正常使用的。
所以基類為類模板的時候需要我們特別的注意。
基類和派生類之間的轉換
我們直接來看看要點:
? public繼承的派生類對象可以賦值給(基類的指針 / 基類的引?)。有個形象的說法叫切?或者切
割。寓意把派?類中基類那部分切出來,基類指針或引?指向的是派?類中切出來的基類那部分。
? 基類對象不能賦值給派生類對象。
? 基類的指針或者引用可以通過強制類型轉換賦值給派?類的指針或者引?。但是必須是基類的指針
是指向派?類對象時才是安全的。這?基類如果是多態類型,可以使?RTTI(Run-Time Type
Information)的dynamic_cast 來進?識別后進?安全轉換。(ps:這個目前僅作了解)。
我們來舉個例子看看就明白了:
class Person
{
protected:string _name; // 姓名string _sex; // 性別int _age; // 年齡
};
class Student : public Person
{
public:int _No; // 學號
};
int main()
{Student sobj;// 1.派?類對象可以賦值給基類的指針/引?Person* pp = &sobj;Person& rp = sobj;// 派?類對象可以賦值給基類的對象是通過調?后?會講解的基類的拷?構造完成的Person pobj = sobj;return 0;
}
我們進入監視窗口查看一下:
很明顯能發現,確實是只將派生類切片分割出基類有的成員,并且將這一個部分轉化成了基類對應的對象/指針/引用。
其實是很形象的。注意,繼承后的派生類中,基類的成員是放在前面存儲的。編譯器當識別到是派生類對應的內容向基類轉化時,會做特殊處理。也就是會進行自動切割出從基類繼承出來的內容,然后再轉化。
但是需要注意的是,基類對象不能轉化為派生類對象:
一是編譯器沒有對這種情況重載,二是根本就不符合語法規定。
其實很好理解,要執行切片分割,基類和派生類都有的當然好辦,但是派生類中可能會有基類中不存在的成員呢?這該怎么處理呢?所以對此c++直接明確規定了基類對象不能轉化為派生類的對象。
但是c++又規定經過強制轉換,基類的指針/引用可以轉化為派生類的指針/引用:
這里發現編譯是不會報錯的。至于引用的我就不再展示了。效果類似。
至于最后一個對于dynamic_cast來判斷指針轉換類型的方法,由于其涉及到多態等未學到的概念,這就后續再說了。當前了解即可。
繼承中的作用域
首先我們得知道,基類和派生類本質都是類。c++中有四大作用域,即局部域、全局域、命名空間域、類域。類域也是獨立的域。
不同的類對象構成的都是自己的域,所以不同的類對象內的變量是需要通過類域作用限定符來訪問的。基類和派生類本質都是類,所有都是有自己的獨立的作用域的。
既然是不同的域,就可以在不同的域中寫相同的函數名或者變量名。這一定是不會構成命名歧義的。但是對于繼承來講,是一個新的現象。如果基類和派生類有同名成員是怎么辦呢?
我們先說結論,無論是在類內中使用或是類外調用,都是將基類的同名成員隱藏起來,默認訪問的是派生類中的那個。如若要訪問基類中的那個需要使用域作用限定符來指定訪問。
class A {
public:void test() {cout << "class A: test()" << endl;}
protected:int _a = 0;int _b = 1;
};class AA : public A {
public:void test() {cout << "class AA: test()" << endl;}
protected:int _a = 2;int _b = 3;
};
我們以這一段代碼為例:
我們發現基類A和派生類AA中的內容均是同名的。我們來看看在派生類使用是如何使用呢?
很明顯,默認使用的就是派生類中的那個。因為從基類繼承下來的那一部分被隱藏了。但并不是說派生類中就沒有從基類繼承下來的同名成員了。我們通過調試還是會發現被繼承下來的。只不過默認調用的是派生類的,要用基類中的需要通過域作用限定符進行訪問:
這是我們需要注意的一點。當然對于成員變量也是一樣的。我在這里就不進行演示了。原理都是一樣的。
那如果在類外面直接調用這個test函數呢?
默認使用的依然是派生類中的那一份。想要使用到基類中的也需要指定訪問:
當然還是有細節要注意的,我們通過下面這個例子來看看:
1.A和B類中的兩個func構成什么關系()
A. 重載 B. 隱藏 C.沒關系2.下?程序的編譯運?結果是什么()
A. 編譯報錯 B. 運?報錯 C. 正常運?
class A{
public:void fun(){cout << "func()" << endl;}
};class B : public A{
public:void fun(int i){cout << "func(int i)" << i << endl;}
};
int main(){B b;b.fun(10);b.fun();return 0;
};
先來看第一題,很多人直接就不假思索地說是函數重載了。看到函數名相同,參數不同。這是錯的。這是沒有對函數重載的定義深刻理解。
函數重載是對一個函數在有不同的參數(個數,順序,類型不同)可以在同一個作用域內進行編寫同名函數。**我們需要注意,函數重載是在一個作用域內的!**不同作用域本來就可以寫同名函數,甚至一模一樣也是可以。因為在兩個獨立的作用域內。
現在這兩個func函數是隸屬于兩個獨立的類中的,都具有自己的獨立的作用域。所以一定不是函數重載。實際上是構成隱藏關系。
所以引出了第三點:繼承中只要函數名相同就構成隱藏關系,無論參數是否相同。
所以第二題來說,會直接編譯報錯。因為就沒辦法使用到那個不帶參數的func,因為它在基類中,是需要通過類作用域限定符來訪問:
而且報錯的是因為不接受0個參數,說明默認調用的都是派生類中的那個,也就驗證了在繼承中函數名只要相同就構成隱藏關系。
但是還需要注意的是,在實際中在繼承體系里面最好不要定義同名的成員。因為這樣子使用起來會非常麻煩,對于同名的還需要去指定訪問。所以一般建議是不要定義同名成員。
派生類的默認成員函數
現在我們再來一起回憶一下默認成員函數有哪些:
就是這六種,其中對于取地址重載的兩個默認成員函數一般是不需要我們自己寫的,編譯器默認生成的就夠用了,但是其他的四個我們是需要看情況來寫的。
那對與派生類來講,由于其繼承了基類的成員,那對于派生類的幾個默認成員函數是否需要寫呢?是否需要對基類的部分進行構造呢?下面我們一起來探討一下。
我們主要來看看前四個默認成員函數。
默認構造函數
#include<string>
class Person {
public://Person的默認構造Person():_name(""),_gender(""),_age(0){}
protected:string _name;string _gender;size_t _age;
};class Teacher : public Person {
protected:int _id;//工號string _title;//身份
};int main() {Teacher t1;return 0;
}
我們先來看在基類中有寫構造函數,但派生類中不寫:
很明顯發現,這個派生類對象還是能夠正常構造出來的,為什么?
因為對于一個類來講,無論是否寫了初始化列表,內部的成員變量都要走初始化列表。因為初始化列表可以看作是成員變量定義的地方(即開空間)。
我們需要分三個部分來看:
1.內置類型
2.自定義類型
3.基類(把基類當作一個整體對象存儲在派生類中)
對于內置類型,有傳參用傳參,沒傳參用缺省,沒有缺省值就是隨機值。
對于自定義類型,編譯器會自動調用其默認構造函數,如果沒有就會報錯:
只要我們任意寫了一個構造函數,編譯器將不在生成默認構造。那此時報錯的是無法使用Teacher(void),其實就是Teacher的默認構造。
前面兩個我們以前就講過,應當需要清楚。我們現在最需要知道的是對于基類的部分是如何操作的。對于上面的情況,基類中有默認構造(不傳參就可以構造),所以我們在派生類中不需要寫也是可以的。因為編譯器自動調用了基類的默認構造。對于派生類內部的成員,就按照以往認識的規律來走。內置類型最次也是隨機值,而自定義類型會自動調用其默認構造。很顯然對Teacher類來講,string這個自定義類型肯定是有默認構造的。
其實絕大部分情況下確實不用寫,但是如果當派生類中的成員變量有指向資源呢?那就需要寫了。這種情況我就不再多說了,詳細的參考一下string的實現即可。
但是如果基類中沒有默認構造呢?比如下面這種情況:
發現又報錯了。這個時候我們就必須自行為基類的成員變量進行構造,也就是我們需要寫對Teacher類的默認構造。但是這個默認構造不是亂寫的。
我們需要記住的是,當基類不提供默認構造函數的時候,那派生類中對基類的構造就必須通過派生類的初始化列表進行顯示調用,具體操作如下:
對于派生類中自己的成員變量我并沒有寫到初始化列表去,因為就算不寫編譯器也會讓它們走初始化列表那一套,且規律就是以往認知的那個。所以我就不寫了。
對基類的構造函數顯示調用是很有趣的,就是在初始化列表內顯示調用,就好像再構造一個基類的匿名對象一樣。注意:這個顯示調用只能在初始化列表走。
不走初始化列表就會報錯。這點需要格外注意。
拷貝構造
直接看結論:
派?類的拷?構造函數必須調?基類的拷?構造完成基類的拷?初始化。
#include<string>
class Person {
public://Person的默認構造Person():_name(""),_gender(""),_age(0){}Person(string name, string gender, size_t age):_name(name),_gender(gender),_age(0){}Person(const Person& per) {_name = per._name;_gender = per._gender;_age = per._age;}protected:string _name;string _gender;size_t _age;
};class Student : public Person {
public:Student():Person("zhangsan", "male", 18),_pos("班長"),_stuid(2301){}
protected:string _pos;//班級職務int _stuid;//學號
};int main() {Student stuA; Student stuB = stuA; return 0;
}
我們來看看這個情形是否能夠與正確使用:
我們并沒有再派生類中寫拷貝構造,所以用的就是默認生成的那個拷貝構造函數。
還是分為三類。基類部分的會去調用基類的拷貝構造。對于內置類型會使用值拷貝。對于自定義類型,如果沒有寫拷貝構造,那就是淺拷貝。如果寫了,就自動調用其拷貝構造(如string)。
但是如果基類中不寫拷貝構造呢?對于我上述舉的例子也是可以的。為什么?
因為基類中的對象string是有拷貝構造函數的。那編譯器生成Person類的拷貝構造的時候,勢必要調用到string類的拷貝構造。
本質上來講,還是依賴于基類中的拷貝構造。
但是再反過來想想,如果基類中有一個自定義類A,指向了資源,但是并沒有A的拷貝構造。如果在基類中也不寫拷貝構造,那么不就出問題了嗎?因為A沒有合適的拷貝構造,用A默認生成的那個是值拷貝,在有指向資源的時候肯定是不符合要求的:
我們在基類Person中加多這么一個類A進行驗證,確實如我們所說。所以盡量還是給基類寫一下拷貝構造。這樣子在派生類沒有額外變量指向資源的情況下我們就可以不用給派生類寫拷貝構造函數了。對于默認構造也是一樣的,也是推薦基類寫好。
當然,我們也是可以在派生類中的拷貝構造函數內顯示調用基類的拷貝構造:
還是需要在初始化列表內顯示調用,但是我們發現傳的竟然是將stu作為參數傳入。這就用到了前面部分講的派生類向基類的轉換,也就是切片分割。編譯器會自動地將屬于基類的切割出來賦值。
賦值重載
至于賦值重載,其實它的行為和拷貝構造很類似。只不過一個是針對對象定義的時候,一個是針對于兩個變量都已經存在的賦值情況。
再講完前面兩個默認成員函數后,我們其實已經很熟悉了,對于賦值重載只簡單帶過一下。
其實派生類的賦值重載也是依賴于基類的賦值重載的。這點的理由已經在拷貝構造部分講了,這里是類似的,就不再多說。
我們來看看下面一段代碼:
class Person{public:Person(const char* name = "peter"): _name(name){}Person(const Person& p): _name(p._name){cout << "Person(const Person& p)" << endl;}Person& operator=(const Person& p){cout << "Person operator=(const Person& p)" << endl;if (this != &p)_name = p._name;return *this;}
protected:string _name; // 姓名
};
class Student : public Person
{
public:Student(const char* name, int num): Person(name), _num(num){cout << "Student()" << endl;}Student(const Student& s): Person(s), _num(s._num){cout << "Student(const Student& s)" << endl;}Student& operator= (const Student& s){cout << "Student& operator= (const Student& s)" << endl;if (this != &s){// 構成隱藏,所以需要顯?調?Person::operator =(s);_num = s._num;}return *this;}
protected:int _num; //學號
};
我們會發現對于operator=這個函數在基類和派生類中是構成隱藏關系的,所以是需要指定訪問的基類的賦值重載函數的。
析構函數
這里我們先講結論,對于析構函數~類名,在底層都是會經過封裝稱函數destructor的,所以基類和派生類中的析構函數也是構成隱藏關系。
還有就是編譯器會自動調用派生類的析構函數再自動調用基類的析構函數。這就符合我們之前講的類的構造和析構順序了。因為基類都是比派生類先構造的。
class Person{public:Person(const char* name = "peter"): _name(name){}Person(const Person& p): _name(p._name){cout << "Person(const Person& p)" << endl;}Person& operator=(const Person& p){cout << "Person operator=(const Person& p)" << endl;if (this != &p)_name = p._name;return *this;}~Person(){cout << "~Person()" << endl;}
protected:string _name; // 姓名
};
class Student : public Person
{
public:Student(const char* name, int num): Person(name), _num(num){cout << "Student()" << endl;}Student(const Student& s): Person(s), _num(s._num){cout << "Student(const Student& s)" << endl;}Student& operator= (const Student& s){cout << "Student& operator= (const Student& s)" << endl;if (this != &s){// 構成隱藏,所以需要顯?調?Person::operator =(s);_num = s._num;}return *this;}~Student(){cout << "~Student()" << endl;}
protected:int _num; //學號
};
我們之前也講過,對于自定義類型,就算我們的析構函數里面什么也沒寫,編譯器也會自行調用其析構函數。所以里面是可以不用寫的。內置類型是否析構其實是無所謂的。
我們發現確實是先析構了派生類再析構基類。注意我們不需要自行顯示調用基類的析構函數。因為編譯器會自己調用,就是為了保證后構造的先析構。如果我們顯式調用了就沒辦法做到這樣的保證了,這點需要格外注意。
默認成員函數總結
經過一大段的分析,相比我們對類的默認成員函數有了更深的理解了。
我們最后來總結一下:
1. 派?類的構造函數必須調?基類的構造函數初始化基類的那?部分成員。如果基類沒有默認的構造
函數,則必須在派?類構造函數的初始化列表階段顯?調?。
2. 派?類的拷貝構造函數必須調用基類的拷貝構造完成基類的拷?初始化。
3. 派?類的operator=必須調用基類的operator=完成基類的復制。需注意的是派?類的operator=
隱藏了基類的operator=,所以顯?調?基類的operator=,需要指定基類作?域
4. 派?類的析構函數會在被調用完成后自動調用基類的析構函數清理基類成員。因為這樣才能保證派
?類對象先清理派生類成員再清理基類成員的順序。
5. 派?類對象初始化先調用基類構造再調派?類構造。
6. 派?類對象析構清理先調用派?類析構再調基類的析構。
7. 因為多態中?些場景析構函數需要構成重寫,重寫的條件之?是函數名相同(這個我們多態章節會講
解)。那么編譯器會對析構函數名進?特殊處理,處理成destructor(),所以基類析構函數不加
virtual的情況下,派?類析構函數和基類析構函數構成隱藏關系。
不能被繼承的類
在c++11以后,引入了一個新的關鍵字final,加在某個類的定義后就代表這個類是不能被繼承的。
在了解這個知識前,我們來看看c++98是怎么做的:
c++98的方法就是將類的構造函數放在私有域,就能使得繼承下來后的派生類無法調用基類的構造函數,這樣子就沒辦法繼承了。但是這個方法是不太好的,構造函數都不能直接使用了。
所以c++11引入了關鍵字final,使用這個關鍵字后,直接代表該類不能被繼承:
這里就直接報錯了,不能將final類作為基類。
繼承和友元
友元關系不能繼承,也就是說基類友元不能訪問派?類私有和保護成員
這個其實很好的符合了以往講的,友元是具有單向性的:
至于為什么前置要先寫一個聲名class Student這早已講過。因為在person類中聲名友元函數Display中用到了Student類,但是此時有還沒定義。所以先進行聲名(編譯器只會向上查找),意思差不多是告訴編譯器后面會對這個類進行定義。
那如果想要讓函數Display也能夠訪問派生類中的私有變量呢?很簡單,在派生類中派生類中再聲名一次友元即可:
這樣就可以了,說明友元是嚴格的單向性的。
繼承與靜態成員
我們之前也講過,如果在類中放一個靜態成員變量,需要在類外進行初始化。而且它存儲在靜態區,并不隸屬于某個類對象。
也就是說,加入定義一個靜態變量static int i,如果不是靜態變量,是普通的變量,那么每個實例化后的對象都有一個獨屬于自己的變量i,雖然名字一樣,但是屬于不同的類對象的。如果是靜態變量,那就是大家公用一份,這個值雖然也能通過對象訪問,但是大家訪問的是同一份在靜態區上的。如果可以修改的話那所有的對象再訪問就會被修改了。靜態變量的使用就參考string中的static size_t npos。
那對于繼承是如何呢?
仍是一樣的,繼承下來給派生類,派生類去訪問那個靜態成員是和在基類中訪問的那個是一樣的,也就是地址是一樣的,我們舉個例子驗證一下即可:
class Person
{
public:string _name;static int _count;
};
int Person::_count = 0;
class Student : public Person
{
protected:int _stuNum;
};
int main()
{Person p;Student s;// 這?的運?結果可以看到?靜態成員_name的地址是不?樣的// 說明派?類繼承下來了,?派?類對象各有?份cout << &p._name << endl;cout << &s._name << endl;// 這?的運?結果可以看到靜態成員_count的地址是?樣的// 說明派?類和基類共?同?份靜態成員cout << &p._count << endl;cout << &s._count << endl;// 公有的情況下,?派?類指定類域都可以訪問靜態成員cout << Person::_count << endl;cout << Student::_count << endl;return 0;
}
注意,如果成員變量定義為私有成員,就沒辦法在類外面指定訪問了。
多繼承及其菱形繼承問題
這個部分我們將重點來將一下多繼承
繼承模型
單繼承:?個派?類只有?個直接基類時稱這個繼承關系為單繼承
多繼承:?個派?類有兩個或以上直接基類時稱這個繼承關系為多繼承,多繼承對象在內存中的模型
是,先繼承的基類在前?,后?繼承的基類在后?,派?類成員在放到最后?。
菱形繼承:菱形繼承是多繼承的?種特殊情況。菱形繼承的問題,從下?的對象成員模型構造,可以
看出菱形繼承有數據冗余和?義性的問題,在Assistant的對象中Person成員會有兩份。?持多繼承就
?定會有菱形繼承,像Java就直接不?持多繼承,規避掉了這?的問題,所以實踐中我們也是不建議
設計出菱形繼承這樣的模型的。
單繼承:意思就是一個類只有一個直接的基類:
如圖所示,PostGraduate的直接基類是Student,Student的直接基類是Person,從圖來看就是單向繼承。
多繼承:意思就是一個類其實是從多個類繼承下來的,也就是說不止有一個基類(父類):
如圖所示,Assistant的基類有兩個,分別為Student和Teacher,這就是多繼承。當然多繼承可以是繼承多個下來。
菱形繼承:這個是特別需要注意的,只要有多繼承的概念就一定會有菱形繼承的出現:
也就是說,Assistant從Student和Teacher繼承來,但是Student和Teacher又分別繼承了Person。這就好像構成了一個菱形的關系。
菱形繼承只是一個普適模型,并不是一定要規規整整的菱形才是菱形繼承。如上面這個圖也是,菱形繼承的本質就是某個類的前繼基類中有兩個是從同一個類繼承下來的,我們簡單可以認為是封閉圖形時即為菱形繼承。
對于單繼承就不需要講太多了,前面都是以單繼承作為例子講解的。
多繼承
對于多繼承來說,還是很常用的。因為總有一些類會同時滿足另外兩個類的特性,舉一個生活中的例子: 如水果黃瓜/小番茄,它們既是蔬菜,又是水果。所以我們可以認為它是由水果和蔬菜繼承下來的。
class Student {
protected:int _stuid;string _name;
};class Teacher {
protected:int _id;string _name;
};class Assistant : public Student, public Teacher {
protected:int _num;
};
但我們需要注意的是,如果使用多繼承的話,就得控制好一下邏輯,就比如此時Assist這個類繼承Student和Teacher,對于_name這個變量很明顯繼承下來就重復了。所以最好的方式就是將_name這個變量放在派生類中。
如圖所示:
這點我們需要特別注意一下。
菱形繼承
當然在此我們先說一個結論,盡量不要玩菱形繼承,會非常麻煩!
首先對于菱形繼承來講,最頂上的那個基類會把成員分別繼承到它的直接派生類,如果有一個類又使用多繼承來繼承這兩個類,那么就會導致最頂上那個類的信息復制了兩份:
class Person {
public:string _name; // 姓名
};
class Student : public Person{
protected:int _num; //學號
};
class Teacher : public Person{
protected:int _id; // 職?編號
};
class Assistant : public Student, public Teacher{
protected:string _majorCourse; // 主修課程
};
int main()
{// 編譯報錯:error C2385: 對“_name”的訪問不明確Assistant a;a._name = "peter";// 需要顯?指定訪問哪個基類的成員可以解決?義性問題,但是數據冗余問題?法解決a.Student::_name = "xxx";a.Teacher::_name = "yyy";return 0;
}
我們來看看這一段代碼就知道了:
會報錯,發現調用_name變量不明確,這是為什么?
我們分析一下就可以知道,這幾個類之間的關系如下圖所示:
先繼承了Student,再繼承了Teacher,所以對于Assistant這個派生類來講,其內部結構大致是這樣的。先繼承的放前面。自己的變量放最后。然后對于Student和Teacher兩個類來說,又是繼承了Person,它們兩個就有共同的繼承下來的變量_name,這會導致歧義。調用的時候到底是哪個呢?這是無法說清楚的。
就算沒有這個問題,更令人頭大的問題是占用可空間的問題。因為Person的東西會有兩份在菱形繼承后的派生類中,所以這個派生類占用的空間將會變得特別大。
所以我們盡量還是不要玩菱形繼承這一套。
菱形繼承解決方案之——虛繼承
但是菱形繼承還是有解決辦法的,就是使用虛繼承。需要用到關鍵字virtual。
使用方法直接記住即可,即在菱形繼承中找到誰會在菱形繼承后的派生類中有二義性,然后再這個類的直接派生類中加上關鍵字virtual即可。注意,不能加多。
這個看著很奇怪,我們直接舉例子就知道了:
對于剛剛那個例子,直接在Student和Teacher類加關鍵字virtual即可。因為Person會有兩份在Assistant中,所以在Person的直接繼承(Student和Teacher)上直接加入關鍵字virtual,這樣子編譯器就會在最后繼承的時候,自動識別其基類的內容合并為一份給最后的派生類,這樣子就不會產生歧義了。
其背后的原理很復雜,在這里就不進行過多贅述了。
再舉這個例子,我們的virtual應該加在哪里呢?答案是B和C。因為A會在E中產生二義性,所以要在A的直接繼承處加關鍵字virtual。
菱形繼承的一個實例
一般是不建議玩菱形繼承,但是在我們的程序中我們確實天天接觸菱形繼承,在這里就稍微做一下了解即可。
即我們常用的輸入流和輸出流,本質也是類:
template<class CharT, class Traits = std::char_traits<CharT>>
class basic_ostream : virtual public std::basic_ios<CharT, Traits>
{};
template<class CharT, class Traits = std::char_traits<CharT>>
class basic_istream : virtual public std::basic_ios<CharT, Traits>
{};
我們會發現中間的那個basic_iostream的直接基類是basic_ostream和basic_istream,這兩個都是虛繼承了ios_base。就是為了防止二義性的。
多繼承中的指針偏移
然后我們現在來看一個指針偏移的問題:
class Base1 { public: int _b1; };
class Base2 { public: int _b2; };
class Derive : public Base1, public Base2 { public: int _d; };
int main(){Derive d;Base1* p1 = &d;Base2* p2 = &d;Derive* p3 = &d;return 0;
}
現在要問的是:p1、p2、p3的關系是:
A:p1 == p2 == p3
B:p1 < p2 < p3
C:p1 == p3 != p2
D:p1 != p2 != p3
我們畫出Derive的結構就知道了:
注意,先繼承的在前面。
將子類的指針轉化給父類是可以的,會做切片分割。
p3指針沒得說,當然指向整個空間的開頭。p1也是,因為做了切割,base1的內容又是存在Derive的空間的最前面,所以p1和p3指向一樣。
p2指向的是圖中base2開始的位置,因為編譯器將內容切割出來。
最后我們看這個指向:
所以最后的答案是C。
繼承和組合
最后來講講繼承和組合的方式。
在前面講到,棧的實現方式可以用適配器模式,也可以像這篇文章寫的繼承模式。
這兩種方式各有優勢,前者通常稱為has_a,后者是is_a。這很好理解。
因為:
public繼承是?種is_a的關系。也就是說每個派生類對象都是?個基類對象。
組合是?種has_a的關系。假設B組合了A,每個B對象中都有?個A對象。
繼承允許你根據基類的實現來定義派?類的實現。這種通過?成派?類的復?通常被稱為?箱復?
(white-box reuse)。術語“?箱”是相對可視性??:在繼承?式中,基類的內部細節對派?類可
? 。繼承?定程度破壞了基類的封裝,基類的改變,對派?類有很?的影響。派?類和基類間的依
賴關系很強,耦合度?。對象組合是類繼承之外的另?種復?選擇。新的更復雜的功能可以通過組裝或組合對象來獲得。對
象組合要求被組合的對象具有良好定義的接?。這種復??格被稱為?箱復?(black-box reuse),
因為對象的內部細節是不可?的。對象只以“?箱”的形式出現。 組合類之間沒有很強的依賴關
系,耦合度低。優先使?對象組合有助于你保持每個類被封裝。優先使?組合,?不是繼承。實際盡量多去?組合,組合的耦合度低,代碼維護性好。不過也不太
那么絕對,類之間的關系就適合繼承(is-a)那就?繼承,另外要實現多態,也必須要繼承。類之間的
關系既適合?繼承(is-a)也適合組合(has-a),就?組合。
黑箱復用是更簡單的,因為不需要太關注其底層原理,且代碼耦合度不會太高。一旦代碼耦合度太高,就會導致修改涉及范圍變大,這在工程上是很避諱的。
所以優先使用組合,因為生活中大多食物還是滿足組合關系的,如車里面有輪胎。不可能說輪胎繼車,那就是輪胎是車。這是不對的。
當然也不是說繼承一無是處,既然有而且那么大篇幅來講,肯定是有其重要意義的。特別是在后續講到多態中。且有些場景確實只能用繼承。
所以我們應當適當選擇方法,只不過說在能用組合的情況下盡量用組合而已。