文章目錄
- 繼承
- 繼承的概念
- 繼承方式及權限
- using改變成員的訪問權限
- 基類與派生類的賦值轉換
- 回避虛函數機制
- 派生類的默認成員函數
- 友元與靜態成員
- 多繼承
- 菱形繼承
- 虛繼承
- 組合
繼承
繼承的概念
繼承可以使得子類具有父類的屬性和方法或者重新定義、追加屬性和方法等。
當創建一個類時,我們可以繼承一個已有類的成員和方法,并且在原有的基礎上進行提升,這個被繼承的類叫做基類,而這個繼承后新建的類叫做派生類。基類必須是已經定義而非僅僅聲明,因此,一個類不能派生它本身。
繼承這種通過生成子類的復用通常被稱為 白箱復用(white-box reuse)
。術語 白箱 是相對可視性而言:在繼承方式中,父類的內部細節對子類可見。
派生類的作用域嵌套在基類的作用域之內。
class [派生類名] : [繼承類型] [基類名]
[繼承類型] [基類名]
的組合被稱為派生列表,值得注意的是,派生列表僅出現在定義中,而不能出現在聲明中:
class A : public B; // ERROR:派生列表不能出現在聲明中
正確實現如下:
class Human
{
public:Human(string name = "張三", int age = 18) : _name(name), _age(age){}void Print(){cout << "name:" << _name << endl;cout << "age:" << _age << endl;}protected:string _name;int _age;
};class Student : public Human
{
public:Student(string stuNum = "123456") : Human(), _stuNum(stuNum){}void Print() // 將父類的Print函數重定向成自己的Print函數{Human::Print();cout << "stuNum:" << _stuNum << endl;}protected:string _stuNum; // 增加的成員變量
};int main()
{Human h1;Student s1;h1.Print();cout << endl;s1.Print();return 0;
}
基類和派生類都具有他們各自的作用域,那如果出現同名的成員(如上面的 Print函數
),此時會怎么樣呢?這里就要牽扯到一個概念——隱藏。(隱藏而非 重載 ,方法名雖然相同,但處于不同的作用域中。)
隱藏:也叫做重定義,當基類和派生類中出現重名的成員時,派生類就會將基類的同名成員給隱藏起來,然后使用自己的。(但是隱藏并不意味著就無法訪問,可以通過聲明基類作用域來訪問到隱藏成員。)
因此 s1
調用 Print函數
時不會調用父類的,而是調用自己的。
繼承方式及權限
繼承的方式和類的訪問限定符一樣,分為
public(公有繼承)
、private(私有繼承)
、protected(保護繼承)
三種。
關于 protected
:
- 對于類的對象來說是不可訪問的。
class Human
{
private:int pri;
protected:int pro;
public:int pub;
};
- 對于派生類的成員(數據成員or成員函數)和基類的友元來說是可以訪問的。
- 子類/子類的友元不能直接訪問父類的受保護成員,只能通過子類對象來訪問。 如果子類(及其友元)能直接訪問父類的受保護成員,那么
protected
提供的訪問保護也就太不安全了。
class Human
{
protected:int age;
};class Student : public Human
{int stu_id;friend void get(Human&); // 不能訪問Human::agefriend void get(Student&); // 可以訪問Student:age
};
基類成員的訪問說明符/派生類的繼承方式
- 對基類成員的訪問權限只與基類中的訪問說明符有關,與派生類的繼承方式無關。
class Human
{
private:int pri;
protected:int pro;
public:int pub;friend int f(Human h) { return h.pro; }
};class Student : public Human
{int f1() { return pri; } // ERROR:縱然是public繼承也不可以訪問private成員int f2() { return pro; } // 正確:protected成員可以被派生類訪問
};class Teacher : private Human {int f1() { return pro; } // 正確:protected成員可以被派生類訪問,即使繼承方式是private
};
- 繼承方式控制派生類對象(包括派生類的派生類)對于基類成員的訪問權限。
總結來講父類成員的訪問權限決定了子類是否能訪問該成員,而繼承方式決定了父類成員在子類中的新權限是怎樣的:
public
:繼承自父類的成員在父類中是什么權限,子類中就是什么權限。protected
:繼承自父類的成員其訪問權限都變成protected
。private
:繼承自父類的成員其訪問權限都變成private
。
派生類向基類轉換的可訪問性
假設 D
繼承自 B
:
- 只有當
D
公有地繼承B
時,派生類對象才能使用派生類向基類的轉換;如果繼承方式是受保護的或者私有的,則不能使用該轉換。 - 不論
D
以什么方式繼承B
,D
的成員函數和友元都能使用派生類向基類的轉換。換言之,派生類向其直接基類的類型轉換對于派生類的成員和友元來說永遠是可訪問的。 - 如果繼承方式是公有的或者受保護的,則
D的子類
(第二點說的是D
本身)的成員和友元可以使用D
向B
的類型轉換;反之,如果繼承方式是私有的,則不能使用。
默認的繼承方式
默認情況下:
- 使用
class
關鍵字定義的派生類是私有繼承的 - 使用
struct
關鍵字定義的派生類是公有繼承的
using改變成員的訪問權限
我們說繼承方式決定了派生類的對象及派生類的子類對繼承來的成員的訪問權限,但這不是絕對的,我們可以通過 using
改變成員的訪問權限,但只能改變派生類能訪問的成員,即基類中的 protected
和 public
成員。
class Human
{
private:int pri;
protected:int pro;
public:int pub;
};class Teacher : private Human { // 私有繼承
private:// 只能被類的成員or友元訪問
public:using Human::pri; // 錯誤:using只能為派生類可訪問的成員提供聲明using Human::pro; // Teacher的對象、成員、友元、子類都可以訪問
protected:using Human::pro; // Teacher的對象、成員、友元可以訪問using Human::pub;
};
基類與派生類的賦值轉換
我們在 四種強制轉換類型中的 dynamic_cast 部分 提到過父類與子類的賦值轉換
派生類可以賦值給基類的對象、指針或者引用,這樣的賦值也叫做 對象切割 。
當把派生類賦值給基類時,可以通過切割掉多出來的成員如 _stuNum
來完成賦值。
但是 基類對象 不可以賦值給 派生類 ,因為他不能憑空多一個 _stuNum
成員出來。
但是 基類的指針卻可以通過強制類型轉換賦值給派生類對象 , 如:
int main()
{Human h1;Student s1;Human* hPtrs = &s1; // 指向派生類對象Human* hPtrh = &h1; // 指向基類對象// 傳統方法Student* pPtr = (Student*)hPtrs; // 沒問題Student* pPtr = (Student*)hPtrh; // 有時候沒有問題,但是會存在越界風險// 如果父類之中包含虛函數,可以使用dynamic_castStudent* pPtr = dynamic_cast<Student*>(hPtrh);// 如果確認基類向派生類的轉換是安全的,可以使用static_castStudent* pPtr = static_cast<Student*>(hPtrs);return 0;
}
總結來講:
- 派生類可以賦值給基類的對象、指針或者引用
- 基類對象不能賦值給派生類對象
- 基類的指針可以通過強制類型轉換賦值給派生類的指針。但是必須是基類的指針是指向派生類對象時才是安全的,否則會存在越界的風險。但基類如果是多態類型(父類之中包含虛函數),則可以使用
RTTI
的dynamic_cast
來實現 指向基類的基類指針 到 派生類對象 的安全轉換。
回避虛函數機制
我們說多態是為了實現子類對于同一操作的不同結果,但有時候,派生類需要調用其父類的虛函數版本,而非自己的虛函數版本:
int main()
{Human h1;Student s1;Human* hPtrs = &s1; // 指向派生類對象hPtrs->print(); // 由于hPtrs指向子類對象,因此調用子類的虛函數hPtrs->Human::print(); // 強行調用Human中的虛函數,而不在意hPtrs的動態類型return 0;
}
派生類的默認成員函數
之前有寫過 類的默認六個成員函數
class Human
{
public:Human(){cout << "Human 構造函數" << endl;}~Human(){cout << "Human 析構函數" << endl;}protected:string _name;int _age;
};class Student : public Human
{
public:Student(){cout << "Student 構造函數" << _name << endl;}~Student(){//~Human(); 不需要手動調用父類的析構函數,編譯器會在子類析構函數結束后自動調用。cout << "Student 析構函數" << endl;}
protected:string _stuNum;
};int main()
{Student s1;return 0;
}
可以看到,調用派生類的默認成員函數時都會調用基類的默認構造函數。
- 派生類的構造函數必須調用基類的構造函數初始化基類的那一部分成員。如果基類沒有默認的構造函數,則必須在派生類構造函數的初始化列表階段顯示調用。
- 派生類的拷貝構造函數必須調用基類的拷貝構造完成基類的拷貝初始化。
- 派生類的
operator=
必須要調用基類的operator=
完成基類的復制。 - 派生類的析構函數會在被調用完成后自動調用基類的析構函數清理基類成員。
在派生類的析構函數中,基類的析構函數會被隱藏,為了實現多態,它們都會被編譯器重命名為 destructor
。
友元與靜態成員
友元
友元關系是不會繼承的(友元關系不具有傳遞性),可以這樣理解,你長輩的朋友并不是你的朋友。
- 基類的友元能訪問基類的私有/保護成員,但不能訪問子類的私有/保護成員。(當然基類本身也無法訪問子類的私有成員。)
- 子類訪問父類友元的私有成員就更不用想了:
- 一是友元關系并不對稱,A 是 B 的友元,B 不一定是 A 的友元,也就是說父類本身都不一定能訪問父類友元的私有成員(父類不一定是其友元的友元),何況子類;
- 二是就算父類是其友元的友元,但友元關系不具有傳遞性,子類不一定是父類友元的友元。
- 子類的友元無法訪問父類的私有/保護成員。
靜態成員
無論繼承了多少次,派生了多少子類,靜態成員在這整個繼承體系中有且只有一個。靜態成員不再單獨屬于某一個類亦或者是某一個對象,而是屬于這一整個繼承體系。
多繼承
如果一個子類同時繼承兩個或以上的父類時,此時就是多繼承。
多繼承雖然能很好的繼承多個父類的特性,達到復用代碼的效果,但是他也有著很多的隱患,例如菱形繼承的問題,這也就是為什么后期的一些語言如 java
把多繼承去掉的原因。
菱形繼承
class Human
{
public:int _age;
};class Student : public Human
{
public:int _stuNum;
};class Teacher : public Human
{
public:int _teaNum;
};
這里有著人類、學生類、老師類。在學校中,還存在著同時具有老師和學生這兩個屬性的人,也就是助教。所以我們可以讓他同時繼承 teacher類
和 student類
。
class Assistant : public Teacher, public Student
{
};
按照道理來說,各個類的大小應該是這樣的。Human
類4個字節,Teacher
和 Student
都是8個字節,而 Assistant
是12個字節。但是實際上 Assistant
卻是16字節。
這就是菱形繼承的 數據冗余 和 二義性 問題的體現。
這里的 Teacher
和 Student
都從 Human
中繼承了相同的成員 _age
。但是 Assistant
再從 Teacher
和 Student
繼承時,就分別把這兩個 _age
都給繼承了過來。
這就是數據冗余問題。
倘若我們想要給那個 _age
賦值:
因為里面存在兩個一樣的 _age
,因此需要指定作用域:
這也就是二義性問題。
虛繼承
想解決二義性很簡單,當多個類繼承同一個類時,就在繼承這個類時,為其添加一個虛擬繼承的屬性。
class Student : virtual public Human
{
public:int _stuNum;
};class Teacher : virtual public Human
{
public:int _teaNum;
};
這時就可以看到,它只繼承了一次。
接下來看看大小:
按照道理來說,a
應該是 12字節
,t
、 s
應該是 8字節
啊?這里就牽扯到了C++的對象模型,先推薦一篇博客: C++中的虛函數(表)實現機制以及用C語言對其進行的模擬實現
這里多出來的 8個字節
,其實是兩個虛基表指針(vbptr)。同理,s
、t
多出來的 4字節
,是 一個vbptr
。
因為這里 Human
中的 _age
是 teacher
和 student
共有的,所以為了能夠方便處理,在內存中分布的時候,就會把這個共有成員 _age
放到對象組成的最末尾的位置。然后在建立一個虛基表,這個表記錄了各個虛繼承的類在找到這個共有的元素時,在內存中偏移量的大小,而虛基表指針則指向了各自的偏移量。
這里打個比方:
通過這個偏移量,他們能夠找到自己的 _age
的位置。
為什么需要這個偏移量呢?
int main()
{Assistant a;Teacher t = a; Student s = a;return 0;
}
如上,當把對象 a
賦值給 t
和 s
的時候,因為他們互相沒有對方的 _stuNum
和 _teaNum
,所以他們需要進行對象的切割,但是又因為 _age
存放在對象的最尾部,所以只有知道了自己的偏移量,才能夠成功的在切割了沒有的元素時,還能找到自己的 _age
。
組合
那除了繼承還有什么好的代碼復用方式嗎?那答案肯定是有的,就是組合。組合就是將多個類組合在一起,實現代碼復用。
繼承和組合又有什么區別呢?
- 繼承是一種
is a
的關系,基類是一個大類,而派生類則是這個大類中細分出來的一個子類,但是他們本質上其實是一種東西。正如:學生也是人,所以他可以很好的繼承人的所有屬性,并增加學生獨有的屬性。 - 組合是一種
has a
的關系,就是一種包含關系。對象a
是對象b
中的一部分,對象b
包含對象a
。
組合這種通過對方開放接口來實現的復用被稱為 黑箱復用(black-box reuse)
,因為對象的內部細節是不可見的。對象只以 黑箱 的形式出現。
class Study
{
public:void ToStudy(){cout << "Study" << endl;}
};class Student : public Human
{
public:Study _s;int _stuNum;
};
這里的 Student類
中包含了一個 Study類
,學習是學生日常生活中不可缺少的一部分。
比較組合和繼承:
- 組合的依賴關系弱,耦合度低。保證了代碼具有良好的封裝性和可維護性。 在組合中,幾個類的關聯不大,我只需要用到你那部分的某個功能,我并不需要了解你的實現細節,只需要你開放對應的接口即可,并且如果我要修改,只修改那一部分功能即可。
- 繼承的依賴關系就非常的強,耦合度非常高。 因為你要想在子類中修改和增加某些功能,就必須要了解父類的某些細節,并且有時候甚至會修改到父類,父類的內部細節在子類中也一覽無余,嚴重的破壞了封裝性。并且一旦基類發生變化時,牽一發而動全身,所有的派生類都會有影響,這樣的代碼維護性會非常的差。一個可用的解決方法就是只繼承抽象類,因為抽象類通常提供較少的實現。
但是大部分場景下,如果繼承和組合都可以選擇,那么 優先使用對象組合,而不是類繼承 。