C++ 繼承:從基礎到實戰,徹底搞懂面向對象的 “代碼復用術”
在面向對象編程(OOP)的世界里,“繼承” 是實現代碼復用的核心機制 —— 就像現實中孩子會繼承父母的特征,C++ 的子類也能 “繼承” 父類的成員(變量 + 函數),再添加自己的獨特功能。對于剛接觸 OOP 的開發者來說,繼承既是 “利器”,也藏著不少容易踩坑的細節(比如菱形繼承、隱藏與重載的區別)。
這篇文章會從 “概念→實戰→避坑” 逐步拆解 C++ 繼承,用通俗的語言 + 完整代碼示例,幫你徹底掌握這一知識點,甚至應對筆試面試中的高頻問題。
一、繼承的基礎:什么是繼承?怎么用?
1.1 先搞懂:繼承的 “本質” 是什么?
繼承的核心是 **“復用已有類的代碼,擴展新功能”**。
比如我們有一個Person
類(包含姓名、年齡和打印信息的函數),現在要定義Student
和Teacher
類 —— 這兩個類都需要 “姓名、年齡”,沒必要重復寫,直接 “繼承”Person
即可,再補充自己的獨特成員(如學號、工號)。
看代碼更直觀:
// 父類(基類):Person
class Person {
public:// 父類的成員函數:復用給子類void Print() {cout << "姓名:" << _name << endl;cout << "年齡:" << _age << endl;}protected:// 父類的成員變量:復用給子類string _name = "peter"; // 姓名int _age = 18; // 年齡
};// 子類(派生類):Student,繼承自Person
class Student : public Person {
protected:int _stuid; // 子類新增的成員:學號
};// 子類(派生類):Teacher,繼承自Person
class Teacher : public Person {
protected:int _jobid; // 子類新增的成員:工號
};// 測試:子類能直接用父類的Print函數
int main() {Student s;Teacher t;s.Print(); // 輸出:姓名:peter,年齡:18(復用Person的Print)t.Print(); // 同樣復用,無需重復寫代碼return 0;
}
1.2 繼承的 “語法規則”:3 個關鍵要素
要正確使用繼承,必須掌握「繼承方式」和「訪問限定符」的搭配 —— 這決定了父類成員在子類中的 “訪問權限”。
(1)基本語法格式
class 子類名 : 繼承方式 父類名 {// 子類的成員(新增/重定義)
};
-
父類(基類):被繼承的已有類(如
Person
); -
子類(派生類):新定義的類(如
Student
); -
繼承方式:
public
(公有的)、protected
(保護的)、private
(私有的),默認繼承方式:class
是private
,struct
是public
(建議顯式寫清,避免混淆)。
(2)訪問權限的 “黃金表格”
父類成員(public
/protected
/private
)在子類中的權限,由「父類訪問限定符」和「繼承方式」共同決定,核心規則是:子類訪問權限 = min (父類訪問限定符,繼承方式)(優先級:public > protected > private
)。
直接看表格更清晰:
父類成員類型 | public 繼承 | protected 繼承 | private 繼承 |
---|---|---|---|
public 成員 | 子類 public | 子類 protected | 子類 private |
protected 成員 | 子類 protected | 子類 protected | 子類 private |
private 成員 | 子類中不可見 | 子類中不可見 | 子類中不可見 |
(3)3 個必須記住的結論
-
父類 private 成員永遠 “不可見”:不是沒繼承,而是語法禁止子類(無論類內還是類外)訪問,相當于 “繼承了但用不了”;
-
protected 是為繼承設計的:如果父類成員不想被類外訪問,但想讓子類用,就定義為
protected
(這是protected
和private
的核心區別); -
實際開發優先用 public 繼承:
protected/private
繼承的子類成員只能在類內用,擴展維護性差,幾乎不用。
二、基類與派生類:對象的 “賦值轉換” 規則
子類對象和父類對象之間能不能互相賦值?這里有個形象的說法叫 **“切片”(切割)** —— 把子類中 “屬于父類的部分” 切下來,賦值給父類對象 / 指針 / 引用。
2.1 允許的轉換:子類 → 父類(切片)
子類對象可以直接賦值給父類的對象、指針、引用,無需強制轉換:
class Person {
protected:string _name; // 姓名int _age; // 年齡
};class Student : public Person {
public:int _stuid; // 學號
};void Test() {Student sobj; // 子類對象// 1. 子類對象 → 父類對象(切片:只賦值父類部分)Person pobj = sobj;// 2. 子類對象地址 → 父類指針(指向子類的父類部分)Person* pp = &sobj;// 3. 子類對象 → 父類引用(引用子類的父類部分)Person& rp = sobj;
}
2.2 禁止的轉換:父類 → 子類
父類對象不能直接賦值給子類對象 —— 因為子類比父類多了成員(如_stuid
),父類沒有這部分數據,無法填充子類的新增成員,語法直接禁止:
void Test() {Person pobj;Student sobj;// sobj = pobj; // 報錯:父類不能賦值給子類
}
2.3 危險的轉換:父類指針 → 子類指針
父類指針可以通過強制轉換賦值給子類指針,但只有一種情況安全:父類指針原本指向的是子類對象(此時指針實際指向的是子類的父類部分,強制轉換后能訪問子類新增成員)。
如果父類指針指向的是父類對象,強制轉換后訪問子類成員會導致越界訪問(父類對象沒有子類成員的內存),非常危險:
void Test() {Student sobj;Person pobj;Person* pp;// 情況1:父類指針指向子類對象 → 強制轉換安全pp = &sobj;Student* ps1 = (Student*)pp;ps1->_stuid = 10; // 安全:pp實際指向子類,有_stuid內存// 情況2:父類指針指向父類對象 → 強制轉換危險(越界)pp = &pobj;Student* ps2 = (Student*)pp;ps2->_stuid = 10; // 危險:pobj沒有_stuid,越界訪問內存
}
三、繼承中的 “作用域”:小心 “隱藏” 陷阱
基類和子類是獨立的作用域,這會導致一個常見問題:隱藏(重定義) —— 子類和父類有同名成員時,子類成員會 “屏蔽” 父類成員的直接訪問。
3.1 成員變量的隱藏
子類和父類有同名成員變量時,子類中直接訪問該變量,默認是子類的,父類的需要用父類名::
顯式訪問:
class Person {
protected:string _name = "小李子";int _num = 111; // 父類:身份證號
};class Student : public Person {
public:void Print() {cout << "姓名:" << _name << endl; // 子類繼承的_namecout << "身份證號:" << Person::_num << endl;// 顯式訪問父類_numcout << "學號:" << _num << endl; // 子類自己的_num}
protected:int _num = 999; // 子類:學號(與父類_num同名,隱藏父類)
};void Test() {Student s1;s1.Print(); // 輸出:姓名:小李子;身份證號:111;學號:999
}
3.2 成員函數的隱藏(易混淆點)
成員函數的隱藏規則更 “嚴格”:只要函數名相同,就構成隱藏,不管參數列表、返回值是否相同(這和 “重載” 完全不同 —— 重載要求同一作用域、參數列表不同)。
比如父類A
有fun()
,子類B
有fun(int)
,這兩個函數是隱藏關系,不是重載:
class A {
public:void fun() {cout << "fun()" << endl;}
};class B : public A {
public:// 函數名相同,構成隱藏(不管參數)void fun(int i) {A::fun(); // 顯式訪問父類fun()cout << "fun(int i) → " << i << endl;}
};void Test() {B b;b.fun(10); // 調用子類fun(int),輸出:fun();fun(int i) → 10// b.fun(); // 報錯:父類fun()被隱藏,需用A::fun()訪問
}
3.3 避坑建議
實際開發中,永遠不要在繼承體系中定義同名成員—— 隱藏會導致代碼可讀性差、容易誤調用,排查 bug 成本高。
四、派生類的 “默認成員函數”:規則要記牢
C++ 類有 6 個默認成員函數(編譯器會自動生成的函數),但派生類的默認成員函數有特殊規則 ——必須先初始化 / 清理父類部分,再處理子類部分。
重點關注 4 個核心函數:構造、拷貝構造、賦值重載、析構(取地址重載幾乎不用,忽略)。
4.1 派生類的構造函數
-
規則:派生類構造函數必須調用父類構造函數,初始化父類部分;
-
特殊情況:如果父類沒有 “默認構造函數”(無參、全缺省),必須在派生類構造函數的初始化列表中顯式調用父類構造函數。
示例:
class Person {
public:// 父類:帶參構造(無默認構造)Person(const char* name) : _name(name) {cout << "Person(const char*)" << endl;}
protected:string _name;
};class Student : public Person {
public:// 子類構造:必須在初始化列表顯式調用父類構造Student(const char* name, int stuid) : Person(name) // 先初始化父類, _stuid(stuid) // 再初始化子類{cout << "Student(const char*, int)" << endl;}
protected:int _stuid;
};void Test() {Student s("jack", 1001); // 輸出順序:Person(const char*) → Student(const char*, int)
}
4.2 派生類的拷貝構造函數
-
規則:派生類拷貝構造必須調用父類拷貝構造函數,拷貝父類部分的數據;
-
注意:默認生成的派生類拷貝構造會自動調用父類拷貝構造,但如果自己實現,必須顯式調用。
示例:
class Person {
public:Person(const Person& p) : _name(p._name) {cout << "Person(const Person&)" << endl;}
protected:string _name;
};class Student : public Person {
public:// 子類拷貝構造:顯式調用父類拷貝構造Student(const Student& s): Person(s) // 父類拷貝構造(s切片給Person), _stuid(s._stuid){cout << "Student(const Student&)" << endl;}
protected:int _stuid;
};void Test() {Student s1("jack", 1001);Student s2(s1); // 拷貝構造// 輸出:Person(const Person&) → Student(const Student&)
}
4.3 派生類的賦值重載(operator=)
-
規則:派生類賦值重載必須調用父類賦值重載,否則父類部分的數據不會被賦值(淺拷貝問題);
-
注意:賦值重載不會自動調用父類的,必須顯式用
父類名::operator=
調用。
示例:
class Person {
public:Person& operator=(const Person& p) {if (this != &p) { // 防止自賦值_name = p._name;}cout << "Person::operator=" << endl;return *this;}
protected:string _name;
};class Student : public Person {
public:Student& operator=(const Student& s) {if (this != &s) {Person::operator=(s); // 顯式調用父類賦值重載_stuid = s._stuid;}cout << "Student::operator=" << endl;return *this;}
protected:int _stuid;
};
4.4 派生類的析構函數
-
規則 1:派生類析構函數會在自己執行完后,自動調用父類析構函數,保證 “先清理子類,再清理父類”(和構造順序相反);
-
規則 2:析構函數名會被編譯器統一處理成
destructor()
,所以父類和子類的析構函數構成隱藏(不加virtual
的情況下,后面多態會講virtual
的作用)。
示例:
class Person {
public:~Person() {cout << "~Person()" << endl;}
};class Student : public Person {
public:~Student() {cout << "~Student()" << endl;}
};void Test() {Student s;// 析構順序:~Student() → ~Person()(自動調用父類析構)
}
五、繼承的 “特殊情況”:友元和靜態成員
5.1 友元不能繼承
父類的友元函數 / 類,不能訪問子類的私有 / 保護成員—— 友元關系是 “單向的”,只針對父類,不傳遞給子類。
示例:
class Student; // 前置聲明class Person {// 父類友元:Display可以訪問Person的私有/保護成員friend void Display(const Person& p, const Student& s);
protected:string _name = "peter";
};class Student : public Person {
protected:int _stuid = 1001; // 子類保護成員
};void Display(const Person& p, const Student& s) {cout << p._name << endl; // 可以:訪問父類保護成員// cout << s._stuid << endl; // 報錯:友元不能繼承,無法訪問子類保護成員
}
5.2 靜態成員在繼承體系中 “唯一”
父類定義的靜態成員(static
),整個繼承體系中只有一個實例—— 不管派生出多少子類,所有類和對象共用這一個靜態成員(相當于 “全局變量”,但屬于類)。
示例:統計繼承體系中對象的總數:
class Person {
public:Person() { ++_count; } // 構造時計數+1
public:static int _count; // 靜態成員:統計對象總數
protected:string _name;
};// 靜態成員必須在類外初始化
int Person::_count = 0;class Student : public Person {};
class Graduate : public Student {}; // 子類的子類void Test() {Student s1, s2;Graduate g1;// 所有對象共用_count,總數=3cout << "對象總數:" << Person::_count << endl; // 輸出3Student::_count = 0; // 子類也能修改,因為共用cout << "對象總數:" << Person::_count << endl; // 輸出0
}
六、繼承的 “老大難”:菱形繼承與虛擬繼承
這是 C++ 繼承的 “痛點”,也是面試高頻考點 —— 菱形繼承是多繼承的特殊情況,會導致數據冗余和二義性,而虛擬繼承是解決這一問題的方案。
6.1 先理清:單繼承、多繼承、菱形繼承
-
單繼承:子類只有一個直接父類(如
Student → Person
); -
多繼承:子類有兩個及以上直接父類(如
Assistant → Student + Teacher
); -
菱形繼承:多繼承的特殊情況 —— 兩個子類繼承同一個父類,又有一個子類繼承這兩個子類(形成 “菱形” 結構)。
結構示意圖:
Person(頂層父類)/ \
Student Teacher(中間子類,都繼承Person)\ /Assistant(底層子類,繼承Student和Teacher)
6.2 菱形繼承的 “坑”:數據冗余 + 二義性
看代碼示例,Assistant
對象會有兩份Person
的成員(_name
),導致兩個問題:
-
二義性:直接訪問
_name
時,不知道是Student
繼承的還是Teacher
繼承的; -
數據冗余:兩份
_name
占用額外內存,且邏輯上應該只有一份(一個助教也是一個人,只需要一個姓名)。
示例:
class Person {
public:string _name = "peter"; // 頂層父類成員
};class Student : public Person { protected: int _stuid; };
class Teacher : public Person { protected: int _jobid; };// 菱形繼承:Assistant繼承Student和Teacher
class Assistant : public Student, public Teacher {
protected:string _major;
};void Test() {Assistant a;// a._name = "jack"; // 報錯:二義性(Student::_name還是Teacher::_name?)// 顯式指定可以解決二義性,但無法解決數據冗余(仍有兩份_name)a.Student::_name = "jack";a.Teacher::_name = "tom";cout << a.Student::_name << " " << a.Teacher::_name << endl; // 輸出jack tom
}
6.3 解決方案:虛擬繼承(virtual)
在中間子類(Student
和Teacher
)繼承Person
時,加上virtual
關鍵字,即可解決菱形繼承的問題。
(1)使用方式
只需修改中間子類的繼承方式:
class Person {
public:string _name = "peter";
};// 中間子類:用virtual繼承Person
class Student : virtual public Person { protected: int _stuid; };
class Teacher : virtual public Person { protected: int _jobid; };// 底層子類正常繼承
class Assistant : public Student, public Teacher { protected: string _major; };void Test() {Assistant a;a._name = "jack"; // 正常:無歧義,_name只有一份cout << a._name << endl; // 輸出jack
}
(2)虛擬繼承的 “原理”(通俗版)
虛擬繼承的核心是:讓中間子類(Student
、Teacher
)不再直接存儲父類(Person
)的成員,而是通過 **“虛基表指針”** 指向 **“虛基表”**,虛基表中存儲了 “父類成員相對于當前類的偏移量”,通過偏移量找到唯一的父類成員。
簡單理解:
-
中間子類(
Student
)多了一個 “虛基表指針”(指向虛基表); -
虛基表中存著 “到
Person
成員的距離”; -
底層子類(
Assistant
)通過兩個中間子類的虛基表指針,找到同一份Person
成員,避免冗余和二義性。
不用深入底層內存細節,記住 “虛擬繼承讓頂層父類成員在底層子類中唯一” 即可。
(3)注意事項
只在菱形繼承的中間子類中使用虛擬繼承,其他場景不要用 —— 虛擬繼承會增加內存開銷(虛基表指針)和計算開銷(偏移量查找),沒必要。
七、繼承的 “終極思考”:繼承 vs 組合,該怎么選?
很多開發者濫用繼承,導致代碼耦合度高、難以維護。實際上,C++ 社區有個共識:優先使用組合,而非繼承。
7.1 繼承:is-a 關系(是一種)
繼承體現的是 “is-a”(是一種)的邏輯 —— 比如BMW
是一種Car
,Student
是一種Person
。
-
優點:直接復用父類代碼,支持多態(后面講);
-
缺點:耦合度高(子類依賴父類實現),破壞父類封裝(子類能訪問父類
protected
成員,父類修改會影響所有子類),屬于 “白箱復用”(子類知道父類內部細節)。
7.2 組合:has-a 關系(有一個)
組合體現的是 “has-a”(有一個)的邏輯 —— 比如Car
有一個Tire
(輪胎),Phone
有一個Battery
(電池)。
-
優點:耦合度低(只需依賴被組合類的接口,不用知道內部細節),封裝性好,屬于 “黑箱復用”(被組合類的修改不影響組合類);
-
缺點:需要手動調用被組合類的接口,代碼量略多。
7.3 選擇原則
- 用繼承的場景:
-
存在明確的 “is-a” 關系(如
BMW → Car
); -
需要實現多態(必須用繼承 +
virtual
)。
- 用組合的場景:
-
存在 “has-a” 關系(如
Car → Tire
); -
沒有明確的 “is-a” 關系,只是想復用代碼;
-
追求低耦合、高維護性的場景(大多數業務場景)。
示例對比:
// 繼承:BMW is a Car
class Car { /* ... */ };
class BMW : public Car { /* ... */ };// 組合:Car has a Tire
class Tire { /* ... */ };
class Car {
protected:Tire _tire; // 組合:Car有一個Tire
};
八、筆試面試高頻題(附答案)
-
什么是菱形繼承?菱形繼承的問題是什么?
答:菱形繼承是多繼承的特殊情況:兩個子類繼承同一個頂層父類,又有一個底層子類繼承這兩個子類(形成菱形結構)。問題是數據冗余(底層子類有兩份頂層父類成員)和二義性(訪問頂層父類成員時無法確定來源)。
-
什么是菱形虛擬繼承?如何解決數據冗余和二義性?
答:在菱形繼承的中間子類(繼承頂層父類的子類)中,用
virtual
關鍵字進行繼承,即為菱形虛擬繼承。解決原理是:中間子類通過 “虛基表指針” 指向 “虛基表”,虛基表存儲頂層父類成員的偏移量,讓底層子類只保留一份頂層父類成員,從而解決數據冗余和二義性。 -
繼承和組合的區別?什么時候用繼承?什么時候用組合?
答:區別在于關系和耦合度:
-
繼承是 “is-a” 關系,耦合度高(子類依賴父類實現,破壞封裝),白箱復用;
-
組合是 “has-a” 關系,耦合度低(依賴接口,不依賴實現),黑箱復用。
使用場景:
-
繼承:is-a 關系、需要多態時;
-
組合:has-a 關系、追求低耦合時(優先選擇)。
九、總結
繼承是 C++ 面向對象的核心,但也是一把 “雙刃劍”:用得好能大幅復用代碼,用得不好會導致耦合高、bug 多。這篇文章從基礎到復雜,幫你理清了繼承的核心規則、避坑點和最佳實踐,關鍵記住三點:
-
優先用
public
繼承,避免同名成員導致的隱藏; -
遠離菱形繼承,萬不得已時用虛擬繼承;
-
優先選擇組合而非繼承,降低代碼耦合度。
建議你動手寫代碼測試本文的示例,比如菱形繼承的問題、虛擬繼承的效果、構造析構的調用順序,只有實踐才能真正掌握~