C++多態(tài)詳解之從靜態(tài)多態(tài)到動態(tài)多態(tài)
一、什么是“多態(tài)”
從字面上理解,多態(tài)就是“多種形態(tài)”。在程序設(shè)計里,它指的是:
使用統(tǒng)一的接口,卻可以對不同類型的對象做出不同的具體行為。
更具體一點(diǎn):
- 靜態(tài)多態(tài)(編譯期多態(tài)):
編譯器在編譯階段就能決定到底調(diào)用哪一個函數(shù)、用哪個版本的代碼。
代表形式:函數(shù)重載、運(yùn)算符重載、模板(泛型)。 - 動態(tài)多態(tài)(運(yùn)行期多態(tài)):
編譯時先只“知道有這個虛函數(shù)”,真正要調(diào)用哪個實(shí)現(xiàn)要到運(yùn)行時,根據(jù)對象的實(shí)際類型來決定。
代表形式:virtual虛函數(shù) + 繼承 + 基類指針/引用。
二、靜態(tài)多態(tài):編譯期就決定一切
1. 靜態(tài)多態(tài)的特點(diǎn)
“靜態(tài)”的含義是:綁定發(fā)生在編譯期。
- 編譯器在編譯階段,就根據(jù)實(shí)參類型、模板參數(shù)等,把要調(diào)用的函數(shù)、生成的代碼都選好、生成好。
- 運(yùn)行時不會再為了“選函數(shù)”去查表,因此不需要虛表(vtable),也沒額外的間接調(diào)用開銷。
- 代價是:泛型代碼會在編譯期生成多個實(shí)例,代碼體積可能增長;另外有些行為必須在編譯期就能確定。
常見的靜態(tài)多態(tài)形式有三個:函數(shù)重載、運(yùn)算符重載、模板。
2. 函數(shù)重載
同名函數(shù),根據(jù)參數(shù)列表的不同進(jìn)行區(qū)分:
void print(int x) {
std::cout << "int: " << x << std::endl;
}
void print(double x) {
std::cout << "double: " << x << std::endl;
}
void print(const std::string& s) {
std::cout << "string: " << s << std::endl;
}
int main() {
print(10); // 調(diào)用 print(int)
print(3.14); // 調(diào)用 print(double)
print("hello"); // 字面量轉(zhuǎn)成 std::string,調(diào)用 print(const std::string&)
}
在這里,“多態(tài)”的表現(xiàn)是:同一個名字 print,可以處理不同的類型。
編譯器會在編譯期進(jìn)行“重載決議”,選出最合適的一個版本。這就是靜態(tài)多態(tài)。
補(bǔ)充一個和繼承相關(guān)的點(diǎn):
如果派生類中重新定義了與基類同名但參數(shù)不同的函數(shù),會發(fā)生“名字隱藏”。要想保留基類的其他重載,可以用using Base::func;把基類同名重載導(dǎo)入作用域。
3. 運(yùn)算符重載
運(yùn)算符重載本質(zhì)上也是一種函數(shù)重載,區(qū)別只是語法形式更自然。編譯器在編譯期決定調(diào)用哪個重載,所以它也是靜態(tài)多態(tài)。
struct Point {
int x, y;
Point(int x, int y) : x(x), y(y) {}
Point operator+(const Point& other) const {
return Point(x + other.x, y + other.y);
}
};
int main() {
Point a(1, 2), b(3, 4);
Point c = a + b; // 實(shí)際是調(diào)用 a.operator+(b)
std::cout << c.x << ", " << c.y << std::endl; // 4, 6
}
“同一個運(yùn)算符 +” 對于不同類型(例如 int + int、Point + Point)會產(chǎn)生不同的行為,同樣屬于靜態(tài)多態(tài)。
4. 模板與泛型編程
模板是 C++ 中實(shí)現(xiàn)靜態(tài)多態(tài)最強(qiáng)大的工具。函數(shù)模板和類模板都屬于參數(shù)化多態(tài),在編譯期根據(jù)類型參數(shù)生成具體代碼。
template <typename T>
T add(T a, T b) {
return a + b; // 只要求 T 支持 operator+
}
int main() {
std::cout << add(1, 2) << std::endl; // 實(shí)例化出 add<int>
std::cout << add(1.5, 2.5) << std::endl; // 實(shí)例化出 add<double>
std::cout << add(std::string("a"), "b") << std::endl; // 實(shí)例化出 add<std::string>
}
這里的 add 在源代碼里只寫了一份,但編譯器會根據(jù)實(shí)際調(diào)用自動生成多個版本。
本質(zhì)上,它也是一種“接口相同(add),但根據(jù)類型不同產(chǎn)生不同行為”的多態(tài),只是全部發(fā)生在編譯期。
模板和函數(shù)重載還可以配合使用(例如
std::sort接受不同類型的迭代器、不同的比較器),本質(zhì)上依然是靜態(tài)多態(tài)的一種組合形式。
三、動態(tài)多態(tài):運(yùn)行期由對象說了算
靜態(tài)多態(tài)的“主角”是“類型”和“模板參數(shù)”,它解決的是“類型不一樣怎么共享代碼”。
動態(tài)多態(tài)的“主角”是“對象的實(shí)際類型”,解決的是“一群有共同接口的對象,具體用哪個實(shí)現(xiàn)要到運(yùn)行期才知道”。
1. 動態(tài)多態(tài)的三個要素
C++ 中要用到動態(tài)多態(tài),基本需要三個條件:
- 繼承:有一個基類和若干派生類;
- 虛函數(shù):基類中把要多態(tài)調(diào)用的函數(shù)聲明為
virtual; - 通過基類指針或引用來操作派生類對象
經(jīng)典例子:
class Shape {
public:
virtual void draw() { // 虛函數(shù)
std::cout << "Shape::draw" << std::endl;
}
virtual ~Shape() = default; // 虛析構(gòu),后面會講
};
class Circle : public Shape {
public:
void draw() override { // override 明確表明“重寫基類虛函數(shù)”
std::cout << "Circle::draw" << std::endl;
}
};
class Rect : public Shape {
public:
void draw() override {
std::cout << "Rect::draw" << std::endl;
}
};
void render(Shape& s) {
s.draw(); // 這里發(fā)生動態(tài)綁定
}
int main() {
Circle c;
Rect r;
render(c); // 調(diào)用 Circle::draw
render(r); // 調(diào)用 Rect::draw
}
這里 render 只認(rèn)識 Shape& 這個“統(tǒng)一接口”,但傳入不同的實(shí)際對象(Circle 或 Rect)時,會在運(yùn)行期調(diào)用不同版本的 draw。這就是運(yùn)行期多態(tài)。
注意:
如果是值傳遞,比如void render(Shape s),那么會發(fā)生對象切片(object slicing),派生類部分被“切掉”,只剩下基類部分,動態(tài)多態(tài)就失效了。因此,多態(tài)場景下要習(xí)慣性使用指針或引用。
2. 虛函數(shù)表(vtable)與 vptr 的實(shí)現(xiàn)原理
典型實(shí)現(xiàn)(大多數(shù)主流編譯器采用類似思路)是這樣的:
每個含有虛函數(shù)的類,編譯器都會為它生成一張虛函數(shù)表(vtable),里面是一串“函數(shù)指針”;
每個對象里會隱藏一個指針(通常叫
vptr),指向它所屬類的那張?zhí)摵瘮?shù)表;當(dāng)你寫
p->func1()時,如果func1是虛函數(shù),編譯器會把它翻譯成類似:// 偽代碼 p->vptr[func1_index](p);
即:從對象中取出 vptr,根據(jù)函數(shù)在虛表中的位置,找到對應(yīng)的函數(shù)指針,然后調(diào)用。
情景:Base類寫了兩個虛函數(shù)func1和func2,在子類Derived類中重寫了func1沒有重寫func2
class Base {
public:
virtual void func1();
virtual void func2();
};
class Derived : public Base {
public:
void func1() override;
// 沒有重寫 func2()
};
那么典型的虛表布局可以想象為:
Base的虛表大致為:index 函數(shù) 0 Base::func1 1 Base::func2 Derived的虛表大致為:index 函數(shù) 0 Derived::func1 1 Base::func2
也就是說:
派生類重寫了哪個虛函數(shù),對應(yīng)虛表?xiàng)l目就改成指向派生類實(shí)現(xiàn);沒重寫的虛函數(shù),虛表里仍然指向基類實(shí)現(xiàn)。
構(gòu)造與析構(gòu)期間的 vptr
- 構(gòu)造基類對象時,先設(shè)置 vptr 指向 基類 的虛表;
- 構(gòu)造派生類對象時,在基類構(gòu)造結(jié)束后,再把 vptr 改成指向 派生類 虛表;
- 析構(gòu)時順序相反。
這帶來的一個重要結(jié)論是:
在構(gòu)造函數(shù)或析構(gòu)函數(shù)內(nèi)部調(diào)用虛函數(shù)時,不會表現(xiàn)出“派生類版本”,而是調(diào)用當(dāng)前構(gòu)造/析構(gòu)階段對應(yīng)類的版本。這是為了避免訪問尚未構(gòu)造/已經(jīng)銷毀的派生類成員。
3. 抽象類與純虛函數(shù)
有時我們只關(guān)心接口,不希望有人直接創(chuàng)建這個類的實(shí)例,就可以使用純虛函數(shù)定義一個抽象類:
class Shape {
public:
virtual void draw() = 0; // 純虛函數(shù)
virtual ~Shape() = default;
};
特點(diǎn):
- 含有(或繼承自基類的)至少一個純虛函數(shù)的類,就是抽象類;
- 抽象類不能直接實(shí)例化:
Shape s; // 編譯錯誤; - 派生類必須把這些純虛函數(shù)全部重寫,否則它自己也是抽象類。
抽象類非常適合用來作為“接口基類”,例如游戲引擎中常見的 GameObject 基類,定義一組必須實(shí)現(xiàn)的接口如 update(), render() 等。
4. 虛析構(gòu)函數(shù)與資源釋放
動態(tài)多態(tài)中,一個非常重要但容易忽略的點(diǎn)是:基類析構(gòu)函數(shù)要聲明為 virtual。
典型情景:
class Base {
public:
virtual ~Base() { // 必須是虛析構(gòu)
std::cout << "Base dtor\n";
}
};
class Derived : public Base {
public:
~Derived() {
std::cout << "Derived dtor\n";
}
};
int main() {
Base* p = new Derived();
delete p;
}
如果 ~Base() 不是虛函數(shù),那么 delete p; 只會調(diào)用 Base 的析構(gòu)函數(shù),而不會調(diào)用 Derived 的析構(gòu)函數(shù),導(dǎo)致派生類中資源泄漏。這在實(shí)際工程里非常危險。
只要你打算通過 Base* 或 Base& 以多態(tài)方式管理對象生命周期,就應(yīng)該把基類析構(gòu)函數(shù)聲明為 virtual。
5. 動態(tài)多態(tài)的一些細(xì)節(jié)注意
5.1 默認(rèn)參數(shù)與虛函數(shù)
默認(rèn)參數(shù)是靜態(tài)綁定的:它們在編譯期根據(jù)靜態(tài)類型來決定。
class Base {
public:
virtual void func(int x = 1) {
std::cout << "Base: " << x << std::endl;
}
};
class Derived : public Base {
public:
void func(int x = 2) override {
std::cout << "Derived: " << x << std::endl;
}
};
int main() {
Derived d;
Base* p = &d;
p->func(); // 輸出什么?
}
這里:
- 調(diào)用的函數(shù)體是
Derived::func(虛函數(shù),運(yùn)行期綁定); - 但默認(rèn)參數(shù)值是以
p的靜態(tài)類型Base*為準(zhǔn),所以默認(rèn)值是1。
最終輸出:Derived: 1。
所以建議:不要依賴虛函數(shù)的默認(rèn)參數(shù)來區(qū)分行為,或者干脆在基類中避免給虛函數(shù)提供默認(rèn)參數(shù)。
5.2 對象切片(object slicing)
Derived d; Base b = d; // 發(fā)生對象切片
此時 b 只是一個獨(dú)立的 Base 對象,派生類部分被“切掉了”,多態(tài)自然不存在了。
因此,多態(tài)設(shè)計中一般采用 Base* 或 Base& ,而不是按值傳遞/按值存儲。
四、靜態(tài)多態(tài) vs 動態(tài)多態(tài):對比與選擇
簡單對比一下兩者的特點(diǎn):
| 特性 | 靜態(tài)多態(tài)(重載/模板) | 動態(tài)多態(tài)(虛函數(shù)) |
|---|---|---|
| 綁定時機(jī) | 編譯期 | 運(yùn)行期 |
| 性能開銷 | 無虛表開銷,通常更快 | 通過虛表間接調(diào)用,有一點(diǎn)調(diào)用開銷 |
| 代碼體積 | 模板實(shí)例化可能生成很多代碼 | 一般較穩(wěn)定 |
| 靈活性 | 編譯期就要知道所有類型 | 可以運(yùn)行期決定具體類型 |
| 典型使用場景 | STL 算法、通用工具庫、數(shù)值計算等 | 插件系統(tǒng)、UI 系統(tǒng)、游戲?qū)ο笙到y(tǒng)等 |
| 需要的語言特性 | 函數(shù)重載、運(yùn)算符重載、模板 | 繼承、虛函數(shù)、基類指針/引用 |
兩者不是“誰更高級”的關(guān)系,而是各有適用場景:
- 如果你寫的是通用算法、容器、工具庫,適合用模板等靜態(tài)多態(tài)手段;
- 如果你有一組“類型不同但接口統(tǒng)一”的對象要在運(yùn)行期間統(tǒng)一管理,例如圖形界面控件、游戲里的各種實(shí)體、不同格式的文件解碼器,通常用動態(tài)多態(tài)更自然。
五、結(jié)合實(shí)際開發(fā)的幾個例子
1. 使用靜態(tài)多態(tài)寫通用算法
比如寫一個簡單版本的 for_each:
template <typename It, typename Func>
void my_for_each(It first, It last, Func f) {
for (; first != last; ++first) {
f(*first);
}
}
int main() {
std::vector<int> v{1, 2, 3};
my_for_each(v.begin(), v.end(), [](int x) {
std::cout << x << " ";
});
}
Func可以是函數(shù)指針、函數(shù)對象、lambda;It可以是各種迭代器;- 編譯器會根據(jù)實(shí)際類型生成具體代碼,運(yùn)行時基本沒有額外開銷。
這就是典型的靜態(tài)多態(tài)用法,也是 STL 的設(shè)計思想。
2. 使用動態(tài)多態(tài)做“對象系統(tǒng)”(例如游戲里的實(shí)體)
假設(shè)一個游戲里有不同的實(shí)體:玩家、怪物、NPC,都需要 update():
class Entity {
public:
virtual void update(float dt) = 0; // 純虛函數(shù)
virtual ~Entity() = default;
};
class Player : public Entity {
public:
void update(float dt) override {
// 處理玩家輸入、移動等
}
};
class Monster : public Entity {
public:
void update(float dt) override {
// AI 行為
}
};
void updateAll(std::vector<std::unique_ptr<Entity>>& entities, float dt) {
for (auto& e : entities) {
e->update(dt); // 動態(tài)多態(tài),運(yùn)行期調(diào)用對應(yīng)實(shí)體的 update
}
}
在這里:
- 游戲主循環(huán)只需要維持一個
std::vector<std::unique_ptr<Entity>>; - 不關(guān)心具體是
Player還是Monster,全部通過多態(tài)調(diào)用update; - 這樣系統(tǒng)擴(kuò)展新實(shí)體時只要增加派生類和工廠邏輯就行,主循環(huán)不用改。
典型地,這種需要“運(yùn)行時混合多種類型”的場景,非常適合用動態(tài)多態(tài)。
六、小結(jié)
- 多態(tài)的本質(zhì):用統(tǒng)一接口,處理多種類型/對象,讓代碼更通用、更易擴(kuò)展。
- 靜態(tài)多態(tài):
- 發(fā)生在編譯期;
- 典型形式有函數(shù)重載、運(yùn)算符重載、模板;
- 性能好,但靈活性在“運(yùn)行時決定類型”方面不足。
- 動態(tài)多態(tài):
- 發(fā)生在運(yùn)行期;
- 依靠繼承、虛函數(shù)和基類指針/引用;
- 借助虛函數(shù)表實(shí)現(xiàn),根據(jù)對象實(shí)際類型決定行為;
- 注意虛析構(gòu)函數(shù)、構(gòu)造/析構(gòu)中調(diào)用虛函數(shù)、對象切片等細(xì)節(jié)。
- 基于虛表的實(shí)現(xiàn)細(xì)節(jié):
- 每個有虛函數(shù)的類有一張?zhí)摫恚?/li>
- 每個對象有一個 vptr 指向虛表;
- 派生類重寫虛函數(shù)時,相應(yīng)虛表項(xiàng)會替換為派生類實(shí)現(xiàn),沒重寫的仍指向基類版本——這也回答了你之前關(guān)于“只重寫其中一個虛函數(shù)時虛表長什么樣”的問題。
到此這篇關(guān)于C++多態(tài)詳解之從靜態(tài)多態(tài)到動態(tài)多態(tài)的文章就介紹到這了,更多相關(guān)C++從靜態(tài)多態(tài)到動態(tài)多態(tài)內(nèi)容請搜索腳本之家以前的文章或繼續(xù)瀏覽下面的相關(guān)文章希望大家以后多多支持腳本之家!
相關(guān)文章
C++實(shí)現(xiàn)字符格式相互轉(zhuǎn)換的示例代碼
這篇文章主要為大家詳細(xì)介紹了C++中實(shí)現(xiàn)字符格式相互轉(zhuǎn)換的方法,主要有UTF8與string互轉(zhuǎn)、wstring與string互轉(zhuǎn),感興趣的小伙伴可以了解一下2022-11-11
C語言實(shí)現(xiàn)字符串字符反向排列的方法詳解
這篇文章主要為大家分享了幾種通過C語言實(shí)現(xiàn)字符串字符反向排列(不是逆序打?。┑姆椒?,文中的示例代碼講解詳細(xì),感興趣的小伙伴可以了解一下2022-05-05
C++實(shí)踐數(shù)組作數(shù)據(jù)成員的參考
今天小編就為大家分享一篇關(guān)于C++實(shí)踐數(shù)組作數(shù)據(jù)成員的參考,小編覺得內(nèi)容挺不錯的,現(xiàn)在分享給大家,具有很好的參考價值,需要的朋友一起跟隨小編來看看吧2019-02-02

