??
楓の個人主頁
你不能改變過去,但你可以改變未來
算法/C++/數據結構/C
Hello,這里是小楓。C語言與數據結構和算法初階兩個板塊都更新完畢,我們繼續來學習C++的內容呀。C++是接近底層有比較經典的語言,因此學習起來注定枯燥無味,西游記大家都看過吧~,我希望能帶著大家一起跨過九九八十一難,降伏各類難題,學會C++,我會盡我所能,以通俗易懂、幽默風趣的方式帶給大家形象生動的知識,也希望大家遇到困難不退縮,遇到難題不放棄,學習師徒四人的精神!!!故此得名【C++游記】
?話不多說,讓我們一起進入今天的學習吧~~~??
一、多態的概念
多態(polymorphism)字面意思是“多種形態”,在C++中分為兩類:編譯時多態(靜態多態)和運行時多態(動態多態)。
1. 編譯時多態(靜態多態)
核心是“編譯期確定調用哪個函數”,主要通過函數重載和函數模板實現。
原理:通過不同的參數類型/個數,編譯器在編譯階段就確定要調用的函數版本,無需運行時判斷。
// 函數重載示例(編譯時多態)
#include <iostream>
using namespace std;// 加法函數:int類型
int add(int a, int b) {return a + b;
}// 加法函數:double類型(重載)
double add(double a, double b) {return a + b;
}int main() {cout << add(1, 2) << endl; // 編譯時確定調用int版本cout << add(1.5, 2.5) << endl;// 編譯時確定調用double版本return 0;
}
2. 運行時多態(動態多態)
核心是“運行期確定調用哪個函數”,即“同一個行為,傳入不同對象,產生不同結果”。
生活案例:
- 買票行為:普通人全價、學生打折、軍人優先
- 動物叫行為:貓“喵”、狗“汪汪”
// 動物叫示例(運行時多態)
#include <iostream>
using namespace std;class Animal {
public:// 虛函數:關鍵標志virtual void talk() const {cout << "動物叫" << endl;}
};class Cat : public Animal {
public:// 重寫虛函數virtual void talk() const override {cout << "(>^ω^<)喵" << endl;}
};class Dog : public Animal {
public:virtual void talk() const override {cout << "汪汪" << endl;}
};// 統一接口:接收基類引用
void letsHear(const Animal& animal) {animal.talk(); // 運行時確定調用哪個版本
}int main() {Cat cat;Dog dog;letsHear(cat); // 輸出:(>^ω^<)喵letsHear(dog); // 輸出:汪汪return 0;
}
注意:
本文重點講解運行時多態,因為它是C++面向對象的核心,也是面試高頻考點;編譯時多態相對簡單,日常開發中使用頻率也較低。
二、多態的定義及實現
2.1 多態的構成條件
要實現運行時多態,必須同時滿足以下兩個核心條件:
- 條件1:必須通過基類的指針或引用調用虛函數
- 條件2:被調用的函數必須是虛函數,且派生類必須對基類的虛函數完成重寫(覆蓋)
為什么必須用基類指針/引用?
因為只有基類的指針或引用才能“兼容”指向基類對象和派生類對象,普通基類對象無法做到這一點(會發生切片,丟失派生類特性)。
2.1.1 虛函數
虛函數是多態的“開關”,定義方式:在類成員函數前加virtual關鍵字。
// 虛函數定義示例
class Person {
public:// 虛函數:加virtual關鍵字virtual void BuyTicket() {cout << "買票-全價" << endl;}// 注意:非成員函數不能加virtual(編譯報錯)// virtual void func() {} // 錯誤:全局函數不能是虛函數
};class Student : public Person {
public:// 派生類虛函數:建議顯式加virtual(規范)virtual void BuyTicket() {cout << "買票-打折" << endl;}
};
注意:
1.virtual只能修飾類成員函數,不能修飾全局函數、靜態成員函數、構造函數;
2. 派生類的虛函數可以省略virtual(因為繼承后基類虛函數的“虛屬性”會保留),但不建議這樣寫,會降低代碼可讀性。
2.1.2 虛函數的重寫(覆蓋)
重寫(覆蓋)是指:派生類中有一個與基類完全相同的虛函數(返回值類型、函數名、參數列表必須完全一致),此時派生類的虛函數會“覆蓋”基類的虛函數。
// 虛函數重寫示例
#include <iostream>
using namespace std;class Person {
public:// 基類虛函數virtual void BuyTicket() {cout << "Person: 買票-全價" << endl;}
};class Student : public Person {
public:// 派生類重寫虛函數(返回值、函數名、參數列表完全一致)virtual void BuyTicket() override { // override關鍵字:檢測重寫是否正確cout << "Student: 買票-半價" << endl;}
};class Soldier : public Person {
public:// 派生類重寫虛函數virtual void BuyTicket() override {cout << "Soldier: 買票-優先" << endl;}
};// 統一接口:基類指針
void Func(Person* ptr) {ptr->BuyTicket(); // 運行時確定調用哪個版本
}int main() {Person ps;Student st;Soldier sr;Func(&ps); // 輸出:Person: 買票-全價Func(&st); // 輸出:Student: 買票-半價Func(&sr); // 輸出:Soldier: 買票-優先return 0;
}
2.1.3 析構函數的重寫(面試重點)
析構函數的重寫是一個特殊場景:基類析構函數為虛函數時,派生類析構函數無論是否加virtual,都與基類析構函數構成重寫。
原因:編譯器會將所有析構函數的名稱統一處理為destructor,因此即使派生類析構函數名與基類不同,也能構成重寫。
// 析構函數重寫的重要性(避免內存泄漏)
#include <iostream>
using namespace std;class A {
public:// 基類析構函數:加virtualvirtual ~A() {cout << "~A()" << endl;}
};class B : public A {
public:B() {_p = new int[10]; // 動態申請內存}// 派生類析構函數:無需顯式加virtual(但建議加)~B() override {delete[] _p; // 釋放內存cout << "~B(): 釋放了int數組" << endl;}private:int* _p;
};int main() {A* p1 = new A;A* p2 = new B;delete p1; // 輸出:~A()(正確)delete p2; // 輸出:~B(): 釋放了int數組 → ~A()(正確,無內存泄漏)return 0;
}
面試必問:為什么基類析構函數建議設計為虛函數?
如果基類析構函數不是虛函數,當用基類指針指向派生類對象并刪除時,只會調用基類析構函數,不會調用派生類析構函數,導致派生類中動態申請的內存無法釋放,造成內存泄漏。
2.2 易混淆概念對比
C++中重載、重寫(覆蓋)、重定義(隱藏)是三個容易混淆的概念,這里用表格清晰對比:
概念 | 定義 | 作用范圍 | 函數名 | 參數列表 | 返回值 | virtual關鍵字 |
---|---|---|---|---|---|---|
重載 | 同一作用域內的同名函數 | 同一類 | 相同 | 不同 | 可以不同 | 無關 |
重寫(覆蓋) | 派生類重寫基類的虛函數 | 基類與派生類 | 相同 | 相同 | 相同(協變除外) | 基類必須有,派生類可省略 |
重定義(隱藏) | 派生類與基類同名函數(非重寫) | 基類與派生類 | 相同 | 可以相同/不同 | 可以不同 | 基類無virtual或參數不同 |
// 重載、重寫、重定義對比示例
#include <iostream>
using namespace std;class Base {
public:// 重載:同一類中,函數名相同,參數不同void func() {cout << "Base::func()" << endl;}void func(int x) {cout << "Base::func(int x)" << endl;}// 虛函數:可被重寫virtual void virtualFunc() {cout << "Base::virtualFunc()" << endl;}// 非虛函數:會被派生類重定義(隱藏)void nonVirtualFunc() {cout << "Base::nonVirtualFunc()" << endl;}
};class Derived : public Base {
public:// 重寫(覆蓋):重寫基類虛函數virtual void virtualFunc() override {cout << "Derived::virtualFunc()" << endl;}// 重定義(隱藏):與基類nonVirtualFunc同名,參數相同但基類無virtualvoid nonVirtualFunc() {cout << "Derived::nonVirtualFunc()" << endl;}// 重定義(隱藏):與基類func同名但參數不同void func(double x) {cout << "Derived::func(double x)" << endl;}
};int main() {Derived d;Base* b = &d;b->func(); // 調用Base::func()b->func(10); // 調用Base::func(int x)b->virtualFunc(); // 調用Derived::virtualFunc()(重寫,多態)b->nonVirtualFunc(); // 調用Base::nonVirtualFunc()(非虛函數,不構成多態)d.func(3.14); // 調用Derived::func(double x)d.Base::func(); // 顯式調用基類被隱藏的函數return 0;
}
三、純虛函數與抽象類
3.1 純虛函數
純虛函數是一種特殊的虛函數:在聲明時初始化為0,且沒有函數體。它的作用是強制派生類必須重寫該函數。
// 純虛函數定義
class Shape {
public:// 純虛函數:=0表示沒有函數體virtual double area() const = 0; // 普通虛函數:可以有函數體virtual void printInfo() const {cout << "這是一個圖形" << endl;}
};
3.2 抽象類
含有純虛函數的類稱為抽象類(也叫接口類)。抽象類有以下特性:
- 抽象類不能實例化對象(無法創建具體實例)
- 抽象類的派生類必須重寫所有純虛函數,否則該派生類仍為抽象類
- 抽象類可以定義普通成員函數和成員變量
- 可以聲明抽象或引用(這是多態的基礎)
// 抽象類示例
#include <iostream>
using namespace std;// 抽象類:含有純虛函數
class Shape {
public:// 純虛函數:計算面積virtual double area() const = 0;// 純虛函數:計算周長virtual double perimeter() const = 0;// 普通成員函數void print() const {cout << "面積: " << area() << ", 周長: " << perimeter() << endl;}
};// 派生類:圓
class Circle : public Shape {
public:Circle(double r) : _radius(r) {}// 必須重寫所有純虛函數double area() const override {return 3.14 * _radius * _radius;}double perimeter() const override {return 2 * 3.14 * _radius;}private:double _radius; // 半徑
};// 派生類:矩形
class Rectangle : public Shape {
public:Rectangle(double w, double h) : _width(w), _height(h) {}// 必須重寫所有純虛函數double area() const override {return _width * _height;}double perimeter() const override {return 2 * (_width + _height);}private:double _width; // 寬double _height; // 高
};// 多態應用:統一接口操作不同圖形
void showShapeInfo(const Shape& shape) {shape.print();
}int main() {// 錯誤:抽象類不能實例化對象// Shape shape; // 正確:可以聲明抽象類的指針/引用Circle circle(5.0);Rectangle rect(3.0, 4.0);showShapeInfo(circle); // 輸出:面積: 78.5, 周長: 31.4showShapeInfo(rect); // 輸出:面積: 12, 周長: 14return 0;
}
抽象類的應用場景:
當我們需要定義一個基類,但不希望它被實例化,只作為派生類的接口規范時,就可以使用抽象類。例如:
- 圖形類(Shape):派生類可以是圓形、矩形、三角形等
- 動物類(Animal):派生類可以是貓、狗、鳥等
- 設備類(Device):派生類可以是打印機、掃描儀、投影儀等
四、多態的底層原理
C++多態的底層是通過虛函數表(Virtual Table,簡稱vtable)和虛表指針(vpointer,簡稱vptr)實現的。理解這一機制,能幫你更深入地掌握多態的本質。
4.1 虛函數表(vtable)
當一個類中含有虛函數時,編譯器會為該類創建一個虛函數表:
- 虛函數表是一個函數指針數組,存儲該類所有虛函數的地址
- 每個含有虛函數的類只有一個虛函數表(所有對象共享)
- 派生類會繼承基類的虛函數表,如果重寫了基類的虛函數,會用派生類自己的函數地址覆蓋虛表中對應的位置
- 如果派生類有新的虛函數,會被添加到虛函數表的末尾
4.2 虛表指針(vptr)
每個含有虛函數的類的對象,都會有一個虛表指針(vptr):
- 虛表指針是對象的第一個成員(存儲在對象內存的最前面)
- 虛表指針指向該類的虛函數表
- 對象創建時,編譯器會自動初始化vptr,使其指向相應的虛函數表
4.3 多態的實現過程
當通過基類指針/引用調用虛函數時,多態的實現過程如下:
- 通過基類指針/引用訪問對象的虛表指針(vptr)
- 通過vptr找到該對象所屬類的虛函數表(vtable)
- 在虛函數表中找到對應虛函數的地址
- 調用該地址指向的函數(即派生類重寫后的函數)
// 多態底層原理示例
#include <iostream>
using namespace std;class Base {
public:virtual void func1() { cout << "Base::func1()" << endl; }virtual void func2() { cout << "Base::func2()" << endl; }void func3() { cout << "Base::func3()" << endl; } // 非虛函數
private:int _b = 1;
};class Derived : public Base {
public:virtual void func1() override { cout << "Derived::func1()" << endl; }virtual void func3() { cout << "Derived::func3()" << endl; } // 新的虛函數
private:int _d = 2;
};int main() {Base b;Derived d;// 注意:以下輸出結果可能因編譯器不同而略有差異cout << "Base對象大小: " << sizeof(b) << endl; // 輸出:8(4字節_b + 4字節vptr)cout << "Derived對象大小: " << sizeof(d) << endl;// 輸出:12(4字節_b + 4字節_d + 4字節vptr)return 0;
}
上述代碼的虛函數表結構如下:
Base類的虛函數表
- vtable[0] → &Base::func1
- vtable[1] → &Base::func2
Derived類的虛函數表
- vtable[0] → &Derived::func1(覆蓋基類的func1)
- vtable[1] → &Base::func2(繼承基類的func2)
- vtable[2] → &Derived::func3(新增的虛函數)
注意:
1. 虛函數表是編譯器在編譯期生成的,存儲在只讀數據段(.rodata);
2. 虛表指針是在對象構造時初始化的,指向所屬類的虛函數表;
3. 多態會帶來輕微的性能開銷(多一次指針間接訪問),但通常可以忽略不計。
五、常見問題與面試題
Q1:靜態成員函數可以是虛函數嗎?
A:不可以。因為靜態成員函數屬于類,不屬于某個具體對象,沒有this指針,而虛函數的調用需要通過對象的vptr找到vtable,因此靜態成員函數不能是虛函數。
Q2:構造函數可以是虛函數嗎?
A:不可以。因為對象的vptr是在構造函數執行時初始化的,在構造函數還未執行時,vptr尚未指向正確的虛函數表,因此構造函數不能是虛函數。
Q3:析構函數為什么要設為虛函數?
A:如前文所述,當用基類指針指向派生類對象并刪除時,如果基類析構函數是虛函數,會先調用派生類析構函數,再調用基類析構函數,確保資源正確釋放;否則只會調用基類析構函數,導致派生類資源泄漏。
Q4:多態有什么優缺點?
A:優點:
1. 提高代碼的復用性和可維護性;
2. 提高代碼的擴展性,新增派生類不影響原有代碼;
3. 接口統一,使用者無需關心具體實現。
缺點:
1. 增加了系統復雜度;
2. 帶來輕微的性能開銷(虛函數調用需要查表);
3. 可能隱藏錯誤,調試難度增加。
Q5:什么情況下會發生隱藏(重定義)?
A:派生類中的函數與基類中的函數同名,且不構成重寫時,會發生隱藏:
1. 基類函數不是虛函數,派生類函數與基類函數同名(無論參數是否相同);
2. 基類函數是虛函數,但派生類函數與基類函數參數不同。
Q6:如何判斷一段代碼是否構成多態?
A:同時滿足以下條件:
1. 存在繼承關系;
2. 基類中存在虛函數,派生類重寫了該虛函數;
3. 通過基類的指針或引用調用該虛函數。
六、總結
- 多態分為編譯時多態(函數重載、模板)和運行時多態(虛函數);
- 運行時多態的實現條件:基類指針/引用 + 虛函數重寫;
- 虛函數重寫要求:函數名、參數列表、返回值完全相同(協變除外);
override
關鍵字用于檢查重寫是否正確,final
關鍵字用于禁止重寫或繼承;- 含有純虛函數的類是抽象類,不能實例化,派生類必須重寫所有純虛函數;
- 多態的底層是通過虛函數表(vtable)和虛表指針(vptr)實現的;
- 基類析構函數建議設為虛函數,避免派生類對象銷毀時的內存泄漏;
七、結語
今日C++到這里就結束啦,如果覺得文章還不錯的話,可以三連支持一下。感興趣的寶子們歡迎持續訂閱小楓,小楓在這里謝謝寶子們啦~小楓の主頁還有更多生動有趣的文章,歡迎寶子們去點評鴨~C++的學習很陡,時而巨難時而巨簡單,希望寶子們和小楓一起堅持下去~你們的三連就是小楓的動力,感謝支持~
?