目錄
1.繼承的概念及定義
1.1繼承的概念
1.2 繼承定義
1.2.1定義格式
1.2.2繼承關系和訪問限定符
1.2.3繼承基類成員訪問方式的變化
總結:
2.基類和派生類對象賦值轉換
3.繼承中的作用域
4.派生類的默認成員函數
?編輯
默認構造與傳參構造
拷貝構造:
顯示深拷貝的寫法:
賦值
顯式調用賦值:
析構函數
?編輯
5.繼承和友元:
6.繼承與靜態成員
7.復雜的菱形繼承及菱形虛擬繼承
虛擬繼承
虛擬繼承解決數據冗余和二義性的原理
8.繼承的總結和反思
總結:C++繼承機制詳解
1. 繼承基礎
概念:繼承是面向對象的重要特性,允許派生類在復用基類特性的基礎上進行擴展,形成層次結構。
訪問權限:
public
繼承:基類public
→派生類public
,protected
→protected
。
protected
/private
繼承:基類成員在派生類中的訪問權限會被進一步限制。基類
private
成員對所有繼承方式不可見,需通過基類提供的接口訪問。默認繼承方式:
class
默認private
繼承,struct
默認public
繼承(但建議顯式聲明)。2. 對象賦值與轉換
切片(切割):派生類對象可賦值給基類對象、指針或引用(基類僅保留派生類中與自身匹配的部分)。
反向不成立:基類對象不能直接賦值給派生類對象(需強制類型轉換,且需確保安全)。
3. 作用域與成員隱藏
獨立作用域:基類與派生類作用域獨立,同名成員會引發隱藏(重定義)。
解決方法:通過
基類::成員名
顯式訪問基類成員。函數隱藏:僅函數名相同即構成隱藏(與參數無關)。
4. 派生類默認成員函數
構造與析構:
派生類構造函數需顯式調用基類構造函數(若基類無默認構造)。
析構順序:先派生類后基類(編譯器自動調用基類析構,無需顯式)。
拷貝與賦值:
默認調用基類的拷貝構造和賦值運算符,需顯式處理深拷貝問題。
賦值運算符需避免遞歸調用(通過
基類::operator=
)。5. 繼承與友元/靜態成員
友元不可繼承:基類友元無法訪問派生類私有/保護成員。
靜態成員共享:基類靜態成員在整個繼承體系中唯一,所有派生類共用。
6. 菱形繼承與虛擬繼承
問題:菱形繼承導致數據冗余和二義性(如
Assistant
對象包含兩份Person
成員)。解決:使用虛擬繼承(
virtual
),使公共基類(如Person
)在派生類中僅保留一份。原理:通過虛基表指針和偏移量定位唯一基類成員,解決冗余和二義性。
7. 繼承 vs. 組合
繼承(is-a):適合邏輯上的“派生關系”(如學生是人)。
缺點:高耦合,破壞封裝,基類改動影響派生類。
組合(has-a):適合“包含關系”(如汽車有輪胎)。
優點:低耦合,易于維護,優先使用。
8. 關鍵總結
避免菱形繼承:復雜且性能開銷大,優先用組合替代多繼承。
虛擬繼承慎用:僅解決菱形繼承問題,其他場景不推薦。
多繼承限制:C++支持但易引發問題,Java等語言已棄用。
核心思想:合理使用繼承,優先選擇組合;理解作用域、權限和對象模型,避免設計復雜繼承結構。
1.繼承的概念及定義
1.1繼承的概念
繼承(inheritance)機制是面向對象程序設計使代碼可以復用的最重要的手段,它允許程序員在保持原有類特性的基礎上進行擴展,增加功能,這樣產生新的類,稱派生類。繼承呈現了面向對象程序設計的層次結構,體現了由簡單到復雜的認知過程。以前我們接觸的復用都是函數復用,繼承是類設計層次的復用
#include<iostream>
using namespace std;class Person
{
public:void Print(){cout << "name:" << _name << endl;cout << "age:" << _age << endl;}
protected:string _name = "peter"; // 姓名
private:int _age = 18; // 年齡
};class Student : public Person
{
public:void func(){cout << _name << endl;//不能直接訪問父類的私有成員,就像不能直接使用爸爸的私房錢一樣//cout << _age << endl;//可以間接使用父類的函數訪問到父類的私有成員Print();}
protected:int _stuid; // 學號
};class Teacher : public Person
{
protected:int _jobid; // 工號
};int main()
{Student s;s.Print();//s._name += 'x';return 0;
}
1.2 繼承定義
1.2.1定義格式
下面我們看到Person是父類,也稱作基類。Student是子類,也稱作派生類。
1.2.2繼承關系和訪問限定符
?
1.2.3繼承基類成員訪問方式的變化
類成員/繼承方式 | public繼承 | protected繼承 | private繼承 |
基類的public成員 | 派生類的public成員 | 派生類的protected成員 | 派生類的private成員 |
基類的protected成員 | 派生類的protected成員 | 派生類的protected成員 | 派生類的private成員 |
基類的private成員 | 在派生類中不可見 | 在派生類中不可見 | 在派生類中不可見 |
總結:
1. 基類private成員在派生類中無論以什么方式繼承都是不可見的。這里的不可見是指基類的私有成員還是被繼承到了派生類對象中,但是語法上限制派生類對象不管在類里面還是類外面都不能去訪問它。
我們不能直接訪問父類的私有成員:
2. 基類private成員在派生類中是不能被訪問,如果基類成員不想在類外直接被訪問,但需要在派生類中能訪問,就定義為protected。可以看出保護成員限定符是因繼承才出現的。
3. 實際上面的表格我們進行一下總結會發現,基類的私有成員在子類都是不可見。基類的其他成員在子類的訪問方式 == Min(成員在基類的訪問限定符,繼承方式),public > protected
> private。
比如說上面的代碼,Print是基類的公有成員函數,派生類Student類是公有繼承,因此對于Student類來說Print仍然是一個可直接訪問到的公有函數:這個代碼是完全可以運行的。
Student s; s.Print();
4. 使用關鍵字class時默認的繼承方式是private,使用struct時默認的繼承方式是public,不過
最好顯示的寫出繼承方式。·?
5. 在實際運用中一般使用都是public繼承,幾乎很少使用protetced/private繼承,也不提倡
使用protetced/private繼承,因為protetced/private繼承下來的成員都只能在派生類的類里
面使用,實際中擴展維護性不強。
補充:
struct默認繼承方式和訪問限定符都是公有
class默認繼承方式和訪問限定符都是私有
2.基類和派生類對象賦值轉換
- 派生類對象 可以賦值給 基類的對象 / 基類的指針 / 基類的引用。這里有個形象的說法叫切片或者切割。寓意把派生類中父類那部分切來賦值過去。
- 基類對象不能賦值給派生類對象。
- 基類的指針或者引用可以通過強制類型轉換賦值給派生類的指針或者引用。但是必須是基類的指針是指向派生類對象時才是安全的。這里基類如果是多態類型,可以使用RTTI(RunTime Type Information)的dynamic_cast 來進行識別后進行安全轉換。(ps:這個我們后面再講解,這里先了解一下)
int i = 1;double t = i;const string& s = "11111";不相關的類型不能轉換//string str = i;//強行轉換也不行//string str = (string)i;
相關類型支持隱式類型轉換,單參數構造函數支持隱式類型轉換,類型轉換中間會產生臨時變量,臨時變量具有常性,因此給引用(引用的是臨時變量)的時候必須加const。相關的類型,不可以隱式類型轉換,也不可以強轉。(指針是地址本質就是整形,因此可以和整形轉換)
公有繼承的時候,子類能轉為父類
保護和私有繼承的時候,子類不能轉為父類:
public繼承:每個子類對象都是一個特殊的父類對象:is a關系
為什么叫切割/切片?:?將子類中和父類一樣的部分切出來依次賦值(拷貝)給父類
什么叫做賦值兼容(編譯器進行了特殊處理):中間不會產生臨時對象
證明:
//子類能轉為父類Student st;Person p = st;//賦值兼容Person& ref = st;Person* ref = &st;
如果是給予一個引用,ref就變成子類對象當中切割出來的父類那一部分的別名。
如果是指針,ptr就指向了子類對象當中切割出來的父類對象的一部分
?
由此可以知道,父類轉換成子類是不可以的,因為子類有的父類沒有(指針和引用在一些特殊情況可以)
局部域、全局域、命名空間域、類域
局部域和全局域會影響訪問(同一個域類不能有同名成員(特殊情況:同名函數構成重載,但函數不構成重載的時候不能同名))和生命周期,類域和命名空間域不影響生命周期只影響訪問
3.繼承中的作用域
類域的細分:
- ?在繼承體系中基類和派生類都有獨立的作用域。(可以有同名成員)
- 子類和父類中有同名成員,子類成員將屏蔽父類對同名成員的直接訪問,這種情況叫隱藏,也叫重定義。(在子類成員函數中,可以使用 基類::基類 成員 顯示訪問)
想訪問父類的需要加域訪問限定符,指定類域就可以了
- 需要注意的是如果是成員函數的隱藏,只需要函數名相同就構成隱藏。
- 注意在實際中在繼承體系里面最好不要定義同名的成員。
請看下題:以下程序的結果是什么?
A. 編譯報錯????????B. 運行報錯????????C. 兩個func構成重載? ? ? ? D. 兩個func構成隱藏
class A
{
public:void fun(){cout << "func()" << endl;}
};
class B : public A
{
public:void fun(int i){A::fun();cout << "func(int i)->" << i << endl;}
};
void Test()
{B b;b.fun(10);
}
解答:
B中的fun和A中的fun不是構成重載,因為不是在同一作用域
B中的fun和A中的fun構成隱藏,成員函數滿足函數名相同就構成隱藏。
這樣就是錯誤的
4.派生類的默認成員函數
class Person
{
public:Person(const char* name = "peter"): _name(name){cout << "Person()" << endl;}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:protected:int _num; //學號string _str;//將父類成員當成一個整體(或者一個整體自定義類型)//將自己的成員當成一個整體//a、內置類型//b、自定義類型
};int main()
{Student s;return 0;
}
默認構造與傳參構造
對于派生類,沒有寫默認構造函數,那在實例化對象的時候是怎么實例化的?
走的基類的默認構造函數
在派生類寫了默認構造函數:仍然會報錯
因為需要將父類成員當成一個整體,不允許單獨對父類的成員進行初始化,需要像匿名對象一樣來調用(父類有幾個成員就一起傳過去):
class Student : public Person
{
public:Student(int num, const char* str, const char* name):Person(name),_num(),_str(){}protected:int _num; //學號string _str;
};
父類 + 自己,父類的調用父類構造函數初始化Person(name),這里就等于去初始化,顯式的去調用父類的構造,父類的部分只能由父類初始化。
可以看一個不是派生類的感受一下:派生類就相當于將父類當成一個整體(不能單獨初始化)
拷貝構造:
如果派生類不寫默認拷貝構造會發生什么?調用父類的拷貝構造
Student s1(1, "vv", "pupu");Student s2(s1);
運行結果:對內置類型采用值拷貝,自定義類型采用他的拷貝構造,父類會去調用父類的拷貝構造。
派生類什么時候需要顯式的寫他自己的拷貝構造?
如果他存在深拷貝:(有個指針指向某個資源)
顯示深拷貝的寫法:
Student(const Student& s):Person(s),_num(s._num),_str(s._str){}
為什么直接傳s就可以?
父類的拷貝構造:
Person(const Person& p): _name(p._name)
{cout << "Person(const Person& p)" << endl;
}
賦值
沒有寫派生類的賦值構造函數怎么辦?調用父類的賦值函數
顯式調用賦值:
Student& operator=(const Student& s)
{if (this != &s)//先判斷是否是自己給自己賦值{operator=(s); //顯示調用運算符重載_num = s._num;_str = s._str;}return *this;
}
上面的寫法會引起棧溢出:?
派生類的賦值,和父類的賦值構成隱藏(同名函數),因此會調用自己的賦值,因此這里就死循環了,導致了棧溢出
指定作用域:
Student& operator = (const Student& s){if (this != &s)//先判斷是否是自己給自己賦值{Person::operator=(s); //顯示調用運算符重載,發生了棧溢出,派生類的賦值,和父類的賦值構成隱藏(同名函數)_num = s._num;_str = s._str;}return *this;}
析構函數
一個類的析構函數是否能顯示調用:可以
Person p("wakak");p.~Person();
派生類如何顯式調用:
~Student(){~Person();cout << "~Student()" << endl;}
這是因為:子類的析構也會隱藏父類,因為后續多態的需要,析構函數名字會被統一處理成destructor(析構函數),因此解決辦法同賦值:
~Student(){Person::~Person();cout << "~Student()" << endl;}
但是此時卻是這樣的(在Student構造函數代碼中添加了打印Student()): 析構函數多了一次
當子類析構調用結束了以后,又自動調用了一次父類的析構,因為析構函數是自動調用的。
構造需要先保證先父后子,析構需要保證先子后父
更重要的原因:
因為在子類的析構函數里面還需要還會訪問到父類的成員,不能一上來就析構父類就訪問不到了:
~Student()
{Person::~Person();cout << _name << endl;cout << "~Student()" << endl;
}
顯示寫無法保證先子后父
為了析構順序是先子后父,子類析構函數結束后,會自動調用父類析構
運行結果:
總結:
6個默認成員函數,“默認”的意思就是指我們不寫,編譯器會變我們自動生成一個,那么在派生類中,這幾個成員函數是如何生成的呢?
- 派生類的構造函數必須調用基類的構造函數初始化基類的那一部分成員。如果基類沒有默認的構造函數,則必須在派生類構造函數的初始化列表階段顯示調用。
- 派生類的拷貝構造函數必須調用基類的拷貝構造完成基類的拷貝初始化。
- 派生類的operator=必須要調用基類的operator=完成基類的復制。
- 派生類的析構函數會在被調用完成后自動調用基類的析構函數清理基類成員。因為這樣才能保證派生類對象先清理派生類成員再清理基類成員的順序。
- 派生類對象初始化先調用基類構造再調派生類構造。
- 派生類對象析構清理先調用派生類析構再調基類的析構。
- 因為后續一些場景析構函數需要構成重寫,重寫的條件之一是函數名相同(這個我們后面會講解)。那么編譯器會對析構函數名進行特殊處理,處理成destrutor(),所以父類析構函數加
- virtual的情況下,子類析構函數和父類析構函數構成隱藏關系
5.繼承和友元:
友元關系不能繼承,也就是說基類友元不能訪問子類私有和保護成員
class Student;
class Person
{
public:friend void Display(const Person& p, const Student& s);
protected:string _name; // 姓名
};
class Student : public Person
{
protected:int _stuNum; // 學號
};
void Display(const Person& p, const Student& s)
{cout << p._name << endl;//cout << s._stuNum << endl; //這里的_stuNum是子類的保護成員,基類的友元函數不能訪問子類的保護和私有成員
}
void main()
{Person p;Student s;Display(p, s);
}
解決方法:
6.繼承與靜態成員
基類定義了static靜態成員,則整個繼承體系里面只有一個這樣的成員。無論派生出多少個子類,都只有一個static成員實例 。基類和派生類共用靜態成員
class Person
{
public:Person() { ++_count; }Person(const Person& p) { ++_count; }
protected:string _name; // 姓名
public:static int _count; // 統計人的個數。
};
int Person::_count = 0;
class Student : public Person
{
protected:int _stuNum; // 學號
};
class Graduate : public Student
{
protected:string _seminarCourse; // 研究科目
};
int main()
{Student s1;Student s2;Student s3;Graduate s4;cout << &Person::_count << endl;Student::_count = 0;cout << &Student::_count << endl;
}
派生類以及Person一共實例化了多少個:
Person() { ++_count; }Person(const Person& p) { ++_count; }
7.復雜的菱形繼承及菱形虛擬繼承
單繼承:一個子類只有一個直接父類時稱這個繼承關系為單繼承
多繼承:一個子類有兩個或以上直接父類時稱這個繼承關系為多繼承
菱形繼承:菱形繼承是多繼承的一種特殊情況。
菱形繼承的問題:從下面的對象成員模型構造,可以看出菱形繼承有數據冗余和二義性的問題。在Assistant的對象中Person成員會有兩份
會導致的問題:比如說剛才的想計算派生類以及Person一共實例化了多少個,這里實例化一次Assistant就會使count++兩次
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; // 主修課程
};
void Test()
{// 這樣會有二義性無法明確知道訪問的是哪一個Assistant a;a._name = "peter";// 需要顯示指定訪問哪個父類的成員可以解決二義性問題,但是數據冗余問題無法解決a.Student::_name = "xxx";a.Teacher::_name = "yyy";
}
虛擬繼承
虛擬繼承可以解決菱形繼承的二義性和數據冗余的問題。如上面的繼承關系,在Student和Teacher的繼承Person時使用虛擬繼承,即可解決問題。需要注意的是,虛擬繼承不要在其他地方去使用。
virtual加在直接繼承公共基類的派生類:
相當于只有一份a,Student和Teacher共用
using namespace std;class Person
{
public:string _name; // 姓名
};
class Student : virtual public Person
{
protected:int _num; //學號
};
class Teacher : virtual public Person
{
protected:int _id; // 職工編號
};
class Assistant : public Student, public Teacher
{
protected:string _majorCourse; // 主修課程
};
void Test()
{Assistant a;a._name = "peter";
}
虛擬繼承解決數據冗余和二義性的原理
為了研究虛擬繼承原理,我們給出了一個簡化的菱形繼承繼承體系,再借助內存窗口觀察對象成員的模型
class A
{
public:int _a;
};
class B : public A//class B : virtual public A
{
public:int _b;
};
class C : public A//class C : virtual public A
{
public:int _c;
};
class D : public B, public C
{
public:int _d;
};
int main()
{D d;d.B::_a = 1;d.C::_a = 2;d._b = 3;d._c = 4;d._d = 5;return 0;
}
使用內存窗口來觀察原理:
1.菱形繼承對象模型
2.菱形虛擬繼承對象模型
int main()
{D d;d.B::_a = 1;d.C::_a = 2;d._a = 0;d._b = 3;d._c = 4;d._d = 5;return 0;
}
a不再在BC內部,BC多出來的地址(指針)存0,且多出來地址的下一個指針所存的為他們到A的偏移量。因此需要找到A就用自己的地址再加偏移量。(為了切片用A)
注意pc指向的是中間位置:
對象模型當中,在內存中的存儲是按照聲明的順序。
ostream也是一個菱形繼承。
總結:實踐中可以設計多繼承,但是切記不要設計菱形繼承,因為太復雜,容易出各種問題。
虛擬繼承后,對象模型都需要保持一致,無論這個指針指向哪,都是使用這個指針找到偏移量,找到a的位置。
8.繼承的總結和反思
1. 很多人說C++語法復雜,其實多繼承就是一個體現。有了多繼承,就存在菱形繼承,有了菱形繼承就有菱形虛擬繼承,底層實現就很復雜。所以一般不建議設計出多繼承,一定不要設計出菱形繼承。否則在復雜度及性能上都有問題。
2. 多繼承可以認為是C++的缺陷之一,很多后來的許多語言都沒有多繼承,如Java。
3. 繼承和組合
- public繼承是一種is-a的關系。也就是說每個派生類對象都是一個基類對象。
- 組合是一種has-a的關系。假設B組合了A,每個B對象中都有一個A對象。
- 優先使用對象組合,而不是類繼承 。
- 繼承允許你根據基類的實現來定義派生類的實現。這種通過生成派生類的復用通常被稱為白箱復用(white-box reuse)。術語“白箱”是相對可視性而言:在繼承方式中,基類的內部細節對子類可見 。繼承一定程度破壞了基類的封裝,基類的改變,對派生類有很大的影響。派生類和基類間的依賴關系很強,耦合度高。
內部細節可見:白,內部細節不可見:黑
????????高耦合(模塊(類與類之間)之間的關系很緊密,關聯很密切,要改動一個地方,所有地方都需要改變,需要重構)
????????如何開發設計軟件更好:低耦合(方便維護),高內聚(當前類里面的成員關系緊密)
- 對象組合是類繼承之外的另一種復用選擇。新的更復雜的功能可以通過組裝或組合對象來獲得。對象組合要求被組合的對象具有良好定義的接口。這種復用風格被稱為黑箱復用(black-box reuse),因為對象的內部細節是不可見的。對象只以“黑箱”的形式出現。組合類之間沒有很強的依賴關系,耦合度低。優先使用對象組合有助于你保持每個類被封裝。
- 實際盡量多去用組合。組合的耦合度低,代碼維護性好。不過繼承也有用武之地的,有些關系就適合繼承那就用繼承,另外要實現多態,也必須要繼承。類之間的關系可以用繼承,可以用組合,就用組合
簡單來說怎么選擇使用繼承還是組合:
- 適合is - a 的關系就用繼承
- 適合has - a的關系就用組合
- 優先使用組合
經典的is - a問題:
- 人 --- 學生: 學生是人,而不是學生有一個人
- 動物 --- 狗:?狗是動物,而不是狗里有一個動物
經典的has -?a問題:
- 輪胎 --- 汽車:汽車有輪胎,而不是汽車是輪胎
兩者都可以使用:
- 鏈表 --- 棧: 棧里有鏈表,棧是特殊的鏈表
1.將A作為B的成員變量(組合也相當于一種復用)
class A
{
public:int _a;
};
//組合:將A作為B的成員變量
class B
{
public:A _aa;int _b;
};
9.筆試面試題
1. 什么是菱形繼承?菱形繼承的問題是什么?
2. 什么是菱形虛擬繼承?底層角度是如何解決數據冗余和二義性的
3. 繼承和組合的區別?什么時候用繼承?什么時候用組合
4.C++有多繼承?為什么?JAVA為什么沒有
5.多繼承問題是什么?本身沒啥問題,有多繼承就一定會可能寫出菱形繼承
結語:
? ? ? ?隨著這篇關于題目解析的博客接近尾聲,我衷心希望我所分享的內容能為你帶來一些啟發和幫助。學習和理解的過程往往充滿挑戰,但正是這些挑戰讓我們不斷成長和進步。我在準備這篇文章時,也深刻體會到了學習與分享的樂趣。 ?
? ? ? ? ?在此,我要特別感謝每一位閱讀到這里的你。是你的關注和支持,給予了我持續寫作和分享的動力。我深知,無論我在某個領域有多少見解,都離不開大家的鼓勵與指正。因此,如果你在閱讀過程中有任何疑問、建議或是發現了文章中的不足之處,都歡迎你慷慨賜教。 ? ? ? ? ? ? ??
? ? ? ? 你的每一條反饋都是我前進路上的寶貴財富。同時,我也非常期待能夠得到你的點贊、收藏,關注這將是對我莫大的支持和鼓勵。當然,我更期待的是能夠持續為你帶來有價值的內容,讓我們在知識的道路上共同前行。