關(guān)于C++繼承你可能會忽視的點
前言
繼承是使代碼復(fù)用的一種重要的手段,我們在C語言時期寫的swap函數(shù)邏輯,通常會單獨寫出來再給其他函數(shù)復(fù)用,這個繼承可以理解成是類級別的一個復(fù)用,它允許我們在原有類的基礎(chǔ)上進行擴展,增加新的功能。
一、什么是繼承
舉個例子,當我們使用一個結(jié)構(gòu)體去描述一個學(xué)生的信息時,我們可以用到以下的這樣一個組織方式:
struct Student { char sex[20];//性別 int age;//年齡 char stu_id;//學(xué)號 //..... };
當我們要描述一名老師的時候我們這個時候可能就是更改學(xué)生信息當中的部分的信息,例如上面的性別和年齡是可以通用的,而學(xué)號只需更改成工號即可。那么我們應(yīng)該怎么去達到復(fù)用的邏輯呢?
:我們可以寫一個struct People,讓struct Student和struct Teacher去復(fù)用它。
struct People { char sex[20];//性別 int age;//年齡 }; struct Student { struct People p; char stu_id;//學(xué)號 //..... }; struct Teacher { struct People p; char work_id;//工號 //..... };
這樣子我們用之前的C語言的知識就可以完成一個簡單的復(fù)用,這樣子做會有幾個不好的地方:
- 對于People內(nèi)部的訪問會比起訪問他自己內(nèi)部定義的變量麻煩一點(就是如定義了Teacher t;要訪問age需要 t.p.age)。
- 若基類(父類)想要對于派生類(子類)有所隱藏,即并不想讓所有的成員函數(shù)/成員變量都給子類所繼承的時候,我們用這種方式很難做到。
基于以上的問題,C++給出了一套繼承邏輯。
上述第一個問題就解決了,我們可以直接在People t,直接訪問基類的成員,第二個問題,對于一些我們想要隱藏的成員函數(shù)/成員變量(即不讓子類可見),我們可以通過繼承方式來控制,在此之前先鋪墊一個知識點。
上圖若是開過c++這門課的同學(xué)肯定都有見過,其中的最左列是表明基類的被繼承的成員是什么類型的,第一行則是以哪種形式繼承。
基類的其他成員在子類的訪問方式 == Min(成員在基類的訪問限定符,繼承方式),public > protected > private 。
其中protected成員變量我們在繼承這塊見得會比較多,下面我們比較一下public/protected/private的在繼承當中的作用。
- 若基類的成員當中為public,則說明他支持類內(nèi),類外部都可以去訪問他,而這時候我們常用public繼承,因為基類都已經(jīng)開放了這個成員變量,派生類用其他兩種方式繼承都會讓他在派生類的類外部無法訪問,所以實際上public繼承是最常用的。
- 若基類的成員當中是protected類型,表明基類允許子類當中可以使用這個成員,但是不希望類外部使用,protected的訪問限定符只是對類外部上鎖,而類內(nèi)是可以隨意使用的,這樣實際上也是封裝性的一種體現(xiàn)。
- 若基類的成員是private類型,表明不希望子類和外部訪問,在子類當中不可見,不可見即子類當中無法訪問該成員。但是他是否存在于派生類當中?是存在的。
實踐是檢驗真知的唯一標準,下面我們試試是否private的成員在子類真的存在:
class People { public: char address;//住址 protected: double sex;//性別 private: int age;//年齡 }; class Student : protected People { char stu_id[20];//學(xué)號 //..... }; struct Teacher :public People { double work_id;//工號 //..... }; int main() { Teacher t; cout << sizeof(t) << endl;//對t變量的大小測試 printf("%p,%p", &t.address,&t.work_id); return 0; }
驗證結(jié)果為32,即下圖所示,我們可以得知People的元素是在Teacher之上的。
示意圖:
倘若強行訪問,則會報錯。到此問題2的答案也清晰了。
不推薦protected繼承的原因:
class People { public: char address;//住址 protected: double sex;//性別 private: int age;//年齡 }; struct Student : protected People { char stu_id[20];//學(xué)號 //..... }; int main() { Student s1; People p = s1;//error return 0; }
上面這個protected繼承后子類對象賦值給基類報錯!
其實是因為父類的public對象address在以protected方式繼承時相當于在類外部不可訪問,當賦值給People對象時,權(quán)限被放大,即若支持賦值,子類address是不對外開放的,而父類卻把成員變量公開了!
解決方案:使用public繼承!
二、基類與派生類的賦值轉(zhuǎn)換
2.1天然支持的理解
這個點是一個十分重要的點,在學(xué)習(xí)java語言的時候,經(jīng)常聽到上轉(zhuǎn)型,其實也就是c++當中將子類的對象賦值給父類,即People t = student s;
類似這種,這個過程是天然支持的,接下來敘述一下天然支持的含義。
預(yù)備知識:
int i = 0; double b = i;//1 double& b =i ;//2 error const double& b = i ;//3
從初識c語言的時候,我們就發(fā)現(xiàn)上面代碼的第一條是沒有問題的,這是因為相近類型在精度低給精度高的時候是不會出現(xiàn)問題的。這是因為編譯器會在此期間生成一塊臨時空間(臨時空間具有常性),用i生成一個double類型的i再賦值給b。
在上面的2代碼的時候為什么會出錯呢?原因很簡單,臨時空間具有常性,臨時空間是放到靜態(tài)區(qū)當中的,不可修改,當用double&時相當于會對權(quán)限進行放大(即b可能會更改i的內(nèi)容),所以我們加入const屬性的時候代碼3也就能夠跑過了。
上面的子類給父類為什么就是天然支持的呢?
int main() { Teacher t; People& p = t;//true People* p2 = &t;//true People p3 = t;//true return 0; }
上面的程序正常運行,父類引用子類對象完全沒有問題!所以我們才說這是天然支持的,不像上面例子是通過轉(zhuǎn)換而來的。
這種派生類對象賦值給基類的對象/基類的指針/基類的引用,稱之為切片,這種說法是十分貼切的。通過切去子類的自己定義的部分在給到基類。
這里會有一些值得注意的點:
基類的對象不能賦值給派生類對象??!
基類的指針是可以通過強制類型轉(zhuǎn)換來賦值給派生類的指針,但是基類的指針必須原先是指向派生類對象才是安全的。倘若基類是多態(tài)類型,可以用RTTI當中的dynamic cast來進行識別后進行安全轉(zhuǎn)換。(后序博客會將,這里簡單說明就是使用dynamic cast,他會判斷指針指向的是不是派生類對象,如果是就轉(zhuǎn)換成功,不是就會返回null)
對于上面第二點做一個解釋,就是如果People * pp指向的是一個People的對象,那么當他給到派生類的指針的時候,派生類指針是有可能訪問到未初始化的那部分。因為站在派生類指針的角度
,他并不知道自己的成員是沒有被定義的,倘若它使用了未初始化數(shù)據(jù),就會產(chǎn)生越界報錯??!
相反,如果原先的pp指針指向的是派生類對象(天然支持的),那么當我們pp給到派生類的時候,對于派生類的而言,它的數(shù)據(jù)都是初始化好的,所以這個時候是沒有問題的。
三、繼承當中的作用域
在繼承體系中基類和派生類都有獨立的作用域!!
代碼如下:
class People { public: char address[20] = "chang an";//住址 void func(int i) { cout << "People func\n"; } protected: char sex[20] = "nan";//性別 private: int age = 19;//年齡 }; struct Student : public People { char stu_id[20] = "1010";//學(xué)號 //..... void func() { cout << "Student func\n"; } }; int main() { People p; Student s; s.func(); //true s.func(1);//err return 0; }
從上面的例子可以看出,倘若基類和子類都在同一個作用域,那么func的有參和無參是構(gòu)成重載的,但是編譯器這里報錯,說明重載的一個重要條件不滿足,即函數(shù)不在同一作用域當中。
這種子類成員將父類的成員屏蔽的情況,叫做隱藏,也叫重定義,倘若需要調(diào)用父類的函數(shù)需要在函數(shù)前顯示調(diào)用(指名類域)即可。
注意:
- 繼承體系當中不建議定義同名的成員,因為會引發(fā)誤解。
- 但是在派生類的默認成員函數(shù)當中會用到這種語法,所以這種語法也是必不可少的??!
- 成員函數(shù)和成員變量都如此,基類定義相同名字的都會對父類進行隱藏,調(diào)用都要顯示調(diào)用。
四、派生類的默認構(gòu)造成員函數(shù)
1. 派生類的構(gòu)造函數(shù)必須調(diào)用基類的構(gòu)造函數(shù)初始化基類的那一部分成員。如果基類沒有默認的構(gòu)造函數(shù),則必須在派生類構(gòu)造函數(shù)的初始化列表階段顯示調(diào)用。
2. 派生類的拷貝構(gòu)造函數(shù)必須調(diào)用基類的拷貝構(gòu)造完成基類的拷貝初始化。
3. 派生類的operator=必須要調(diào)用基類的operator=完成基類的復(fù)制。
4. 派生類的析構(gòu)函數(shù)會在被調(diào)用完成后自動調(diào)用基類的析構(gòu)函數(shù)清理基類成員。因為這樣才能保證派生類對象先清理派生類成員再清理基類成員的順序。
5. 派生類對象初始化先調(diào)用基類構(gòu)造再調(diào)派生類構(gòu)造。
6. 派生類對象析構(gòu)清理先調(diào)用派生類析構(gòu)再調(diào)基類的析構(gòu)。
總結(jié)前面的6個規(guī)則,個人的結(jié)論:
- 構(gòu)造函數(shù),拷貝構(gòu)造,operator=三種情況,都要調(diào)用父類對應(yīng)的構(gòu)造函數(shù)/拷貝構(gòu)造/operator=進行對父類的成員變量的初始化,并且倘若父類沒有默認的構(gòu)造函數(shù)的時候(比如父類寫了帶參的構(gòu)造函數(shù)),我們就要顯式調(diào)用(Person(參數(shù)…),Person::operator=(參數(shù)…))
- 析構(gòu)函數(shù)只需要清理子類定義的資源,由于在構(gòu)造函數(shù)當中我們是先對父類的成員先進行構(gòu)造,后對子類的成員進行構(gòu)造。由先構(gòu)造后析構(gòu)的順序,所以我們是在析構(gòu)函數(shù)當中析構(gòu)子類的資源,析構(gòu)函數(shù)調(diào)用完后編譯器自動幫我們調(diào)用父類的析構(gòu)函數(shù)。
在派生類當中基類為一個自定義類型,會自動調(diào)用父類的構(gòu)造函數(shù)進行初始化。在拷貝構(gòu)造當中,由于子類把父類的部分當做自定義類型,倘若沒有顯示調(diào)用拷貝構(gòu)造,就會調(diào)用到構(gòu)造函數(shù)上面對父類的部分進行構(gòu)造。
在子類當中直接對父類單獨的成員初始化是錯誤的,一定要把父類當做一個整體進行初始化!
4.0什么時候需要寫6個默認成員函數(shù)
拋出結(jié)論:
1. 若父類沒有默認構(gòu)造函數(shù)/默認拷貝構(gòu)造函數(shù)或者有需要對成員的初始化(可以在聲明處給缺省值),或者編譯器提供的淺拷貝行為不能滿足我們的需求。
2. 當我們成員變量中采用T*,自己維護在堆上開的空間時,我們往往需要對除取地址重載外的其余默認成員函數(shù)進行編寫。因為我們沒有選擇容器,自己動手維護堆上的資源時,若采用編譯器默認生成的值拷貝的方式,分分鐘出錯!
4.1構(gòu)造函數(shù)
#include<iostream> using namespace std; class People { public: People() { cout << "People()\n"; } //p1(p) People(const People& p) { cout << "People(const People& p)" << endl; } // p1 = p People& operator=(const People& p) { cout << "People& operator=(const People& p)" << endl; return *this; } private: char name[20]; char address[20]; char tele[20]; }; class Student :public People { public: //無寫構(gòu)造 private: int id; }; int main() { Student t; return 0; }
在沒有寫派生類的構(gòu)造函數(shù)時,派生類會在編譯器生成的默認構(gòu)造函數(shù)當中在初始化列表處調(diào)用父類的構(gòu)造函數(shù)對父類的資源進行初始化。
當我們寫了子類的構(gòu)造函數(shù),但是沒有顯示調(diào)用父類的構(gòu)造函數(shù),編譯器依舊會在初始化列表處幫我們調(diào)用父類的構(gòu)造函數(shù)對父類的資源進行初始化。
C++規(guī)定了派生類要先對父類資源進行初始化,所以不管我們有沒有顯示調(diào)用父類的構(gòu)造函數(shù),編譯器都會幫我們調(diào)用。下面展示一下如何顯示調(diào)用
Student() :People() { cout << "Student()" << endl; }
倘若父類沒有寫默認的構(gòu)造函數(shù),這個時候只能用顯示調(diào)用的方法對父類的資源初始化了。
調(diào)用方法看起來有點奇怪,用起來有點像創(chuàng)建匿名對象,但是便于理解,我們可以把他理解成子類當中將父類看做自定義類型,所以會去默認調(diào)用它的構(gòu)造函數(shù),而我們沒有顯示寫出父類的對象,所以初始化父類的形式用的是類名+(參數(shù)...)
4.2拷貝構(gòu)造
1.當我們沒有編寫拷貝構(gòu)造函數(shù)的時候,我們發(fā)現(xiàn)編譯器幫我們默認生成的拷貝構(gòu)造會自動調(diào)用父類的拷貝構(gòu)造。
那么我們是否跟構(gòu)造函數(shù)一樣只拷貝子類的資源即可,編譯器是否會幫我們也在初始化列表處對父類資源進行拷貝?
不會
看下面這張圖,我們發(fā)現(xiàn)拷貝構(gòu)造當中調(diào)用了父類的構(gòu)造函數(shù)
有的同學(xué)就會有疑惑了,實際上拷貝構(gòu)造也是構(gòu)造,在初始化列表處,對于子類而言,父類相當于一個自定義類型對象,子類會調(diào)用父類的構(gòu)造函數(shù)對父類的資源進行初始化。
解決方法:顯示調(diào)用父類的拷貝構(gòu)造即可,所以拷貝構(gòu)造這里我們一定要要寫就一定要顯示調(diào)用父類的拷貝構(gòu)造。
Student(const Student& s) :People(s) { cout << "Student(const Student& s)" << endl; }
4.3賦值重載
老樣子,先看看編譯器生成的默認的operator=是怎樣的。
很顯然,編譯器會自動調(diào)用父類的operator=對父類的部分進行賦值,賦值重載與拷貝構(gòu)造一樣,需要我們顯示調(diào)用父類的賦值重載,否則雖然不會報錯,但是不滿足我們所需要的行為。
所以我們在函數(shù)體內(nèi)調(diào)用父類oeperator=即可:
五、菱形繼承和菱形虛擬繼承
單/多繼承的定義:
單繼承:一種繼承機制。其中每個子類只能繼承單一的超類。
多繼承:多繼承可以看作是單繼承的擴展。所謂多繼承是指派生類具有多個基類,派生類與每個基類之間的關(guān)系仍可看作是一個單繼承。
多繼承本身并沒有問題,但是它的擴展形成菱形繼承出現(xiàn)了問題,讓我們學(xué)習(xí)的過程中需要學(xué)習(xí)更加復(fù)雜的解決方案。
5.1菱形繼承
以下面這張圖為例。
struct Base { int base; }; struct A :public Base { int a; }; struct C :public Base { int c; }; struct D :public A ,public C { int d; };
在沒有虛繼承前,對象模型如下圖,可以看出base在D有出現(xiàn)了兩份,也就是在D所創(chuàng)建的對象當中都會出現(xiàn)二義性和數(shù)據(jù)冗余的問題!
解決方案
虛繼承,在腰部的類繼承時添加virtual關(guān)鍵字。
struct Base { int base; }; struct A :virtual public Base { int a; }; struct C :virtual public Base { int c; }; struct D :public A ,public C { int d; }; int main() { D d; d.c = 1; d.d = 2; d.a = 3; return 0; }
測試平臺:vs2013/32位
虛繼承后的內(nèi)存對象成員模型,其中每個腰部虛繼承的A,C的對象都多了一個指針,由于我們是小端機,所以對應(yīng)過去我們能看到指向的空間當中對應(yīng)8字節(jié),表中頭4字節(jié)00 00 00 00與多態(tài)有關(guān),下面的則是偏移量,由于虛繼承后只有一份A對象的成員變量,并且表結(jié)構(gòu)需要8字節(jié)的空間,所以A,C對象當中存放的是虛基表指針,指向的是虛基表,虛基表一般是放在代碼段 當中的。
為什么C,A需要去找屬于自己的Base?
基類與派生類的賦值轉(zhuǎn)換時,需要進行切片,需要將A,C當中的base變量才能賦值給b。
int main() { Base b = A(); Base b2 = C(); return 0; }
上述圖中B是否虛繼承都可以,只要A,C虛繼承,D中都不會出現(xiàn)二義性了。
六、繼承的總結(jié)
在繼承這塊實際上是c++語法復(fù)雜的一處體現(xiàn)了,有了多繼承,就有了菱形繼承,相對應(yīng)他的解決方案來了,但是我們可以發(fā)現(xiàn)這套解決方案讓他的底層實現(xiàn)必定變得復(fù)雜了起來,所以正常使用的時候我們并不推薦去折騰菱形繼承,在java等語言都把多繼承這一塊砍掉了,使用多繼承的同時就要考慮復(fù)雜度和性能上的問題。
繼承和組合
繼承是一種復(fù)用的方式,但不是唯一方式!
- public繼承是一種is-a的關(guān)系,每一個派生類都是一個基類對象。
- 組合是一種has-a的關(guān)系,假設(shè)B組合了A,則每個B對象都有一個A對象。
- 繼承方式的復(fù)用常稱之為白箱復(fù)用,在繼承方式中,基類的內(nèi)部細節(jié)對子類可見,這一定程度上破壞了基類的封裝,伴隨著基類的改變,對派生類的改變很大。并且兩者依賴關(guān)系強,耦合度大。
- 對象組合式繼承之外的復(fù)用選擇,對象組合要求被組合對象提供良好的接口定義。這種復(fù)用稱之為黑箱復(fù)用,對象的內(nèi)部實現(xiàn)細節(jié)是不可見的。耦合度低。
實際工程中能用繼承和組合就用組合,組合的耦合度低,代碼的維護性好,但是繼承在有些關(guān)系就適合用繼承就用繼承,并且要實現(xiàn)多態(tài)就一定要用繼承。
總結(jié)
繼承作為c++的一塊難點,本篇博客不免有些錯誤,歡迎各位大佬指出批評!
到此這篇關(guān)于關(guān)于C++繼承你可能會忽視的點的文章就介紹到這了,更多相關(guān)C++繼承忽視的點內(nèi)容請搜索腳本之家以前的文章或繼續(xù)瀏覽下面的相關(guān)文章希望大家以后多多支持腳本之家!
相關(guān)文章
C語言編程內(nèi)存分配通訊錄靜態(tài)實現(xiàn)示例代碼教程
這篇文章主要為大家介紹了C語言編程實現(xiàn)靜態(tài)的通訊錄示例代碼教程,有需要的朋友可以借鑒參考下,希望能夠有所幫助,祝大家多多進步,早日升職加薪2021-10-10關(guān)于C++11的統(tǒng)一初始化語法示例詳解
C++之前的初始化語法很亂,有四種初始化方式,而且每種之前甚至不能相互轉(zhuǎn)換,但從C++11出現(xiàn)后就好了,所以這篇文章主要給大家介紹了關(guān)于C++11的統(tǒng)一初始化語法的相關(guān)資料,文中通過示例代碼介紹的非常詳細,需要的朋友可以參考下。2017-10-10C++實現(xiàn)地鐵自動售票系統(tǒng)程序設(shè)計
這篇文章主要為大家詳細介紹了C++實現(xiàn)地鐵自動售票系統(tǒng)程序設(shè)計,文中示例代碼介紹的非常詳細,具有一定的參考價值,感興趣的小伙伴們可以參考一下2022-03-03