C++基礎-多態
本文為 C++ 學習筆記,參考《Sams Teach Yourself C++ in One Hour a Day》第 8 版、《C++ Primer》第 5 版、《代碼大全》第 2 版。
多態(Polymorphism)是面向對象語言的一種特征,可以使用相似的方式(基類中的接口)處理不同類型的對象。在編碼時,我們將不同類型(具有繼承層次關系的基類和派生類)的對象視為基類對象進行統一處理,不必關注各派生類的細節,在運行時,將會通過相應機制執行各對象所屬的類中的方法,基類對象執行基類方法,派生類對象執行派生類方法。多態是一種非常強大的機制,我們考慮這種情況,基類早已寫好并定義了良好的接口,基類的使用者編寫代碼時,將能通過基類的接口來調用派生類中的方法,也就是說,后寫的代碼能被先寫的代碼調用,這使程序具有很強的復用性和擴展性。
1. 使用虛函數實現多態
例程 1 源碼:
#include <iostream>
using namespace std;
class Fish
{
public:
/* virtual */ void Swim() { cout << "Fish swims!" << endl; }
};
class Tuna : public Fish
{
public:
void Swim() { cout << "Tuna swims!" << endl; }
};
class Carp : public Fish
{
public:
void Swim() { cout << "Carp swims!" << endl; }
};
void FishSwim(Fish &fish)
{
fish.Swim();
}
int main()
{
// 引用形式
Fish myFish;
Tuna myTuna;
Carp myCarp;
FishSwim(myFish);
FishSwim(myTuna);
FishSwim(myCarp);
// 引用形式
Fish &rFish1 = myFish;
Fish &rFish2 = myTuna;
Fish &rFish3 = myCarp;
rFish1.Swim();
rFish2.Swim();
rFish3.Swim();
// 指針形式
Fish *pFish1 = new Fish();
Fish *pFish2 = new Tuna();
Fish *pFish3 = new Carp();
pFish1->Swim();
pFish2->Swim();
pFish3->Swim();
return 0;
}
直接編譯運行代碼,得到如下結果:
Fish swims!
Fish swims!
Fish swims!
Fish swims!
Fish swims!
Fish swims!
Fish swims!
Fish swims!
Fish swims!
將第 8 行的 virtual 關鍵字取消注釋,再次編譯運行代碼,得到如下結果:
Fish swims!
Tuna swims!
Carp swims!
Fish swims!
Tuna swims!
Carp swims!
Fish swims!
Tuna swims!
Carp swims!
分析上述例程:
- 派生類對象可以賦值給基類對象(這里對象是廣義稱法,代指對象、指針、引用),例程中使用基類引用或指針指向派生類對象。基類與派生類賦值關系的說明可參考《C++基礎-繼承》一文第 3 節。
- 如果基類中的 Swim() 不是虛函數,那么無論基類引用(或指針)指向何種類型的對象,運行時都調用基類中的方法。這種情況未啟用多態機制。
- 如果基類中的 Swim() 是虛函數,那么運行時會根據基類引用(或指針)指向的具體對象,調用對象所屬的類中的方法。若指向派生類對象則調用派生類方法,若指向基類對象則調用基類方法。這種情況使用了多態機制。
使用基類指針或引用指向基類或派生類對象,運行時調用對象所屬的類中的方法,就是多態。在編寫代碼時,可將派生類對象視為基類對象進行統一處理,據此我們可以先實現一個通用接口,如第 31 行 FishSwim() 函數所示,運行時具體調用哪個方法由傳入的參數決定。我們以后可以根據需要新增派生類,并實現派生類中的 Swim() 方法,而早已寫好的 FishSwim() 函數不需要修改任何代碼就能調用新派生類中的方法,這種機制使得代碼具有極好的可擴展性。
編程實踐:對于將被派生類覆蓋的基類方法,務必將其聲明為虛函數,以使其支持多態。
2. 虛析構函數
虛析構函數與普通虛函數機制并無不同。我們分析一下例程 2 源碼:
#include <iostream>
using namespace std;
class Fish
{
public:
Fish()
{
cout << "Constructed Fish" << endl;
}
virtual ~Fish()
{
cout << "Destroyed Fish" << endl;
}
};
class Tuna : public Fish
{
public:
Tuna()
{
cout << "Constructed Tuna" << endl;
}
~Tuna()
{
cout << "Destroyed Tuna" << endl;
}
};
void DeleteFishMemory(Fish *pFish)
{
// 若第 12 行刪除 virtual 關鍵字,則傳入實參是派生類對象時,也被當作其基類對象進行析構
// 若第 12 行攜帶 virtual 關鍵字,則傳入實參是派生類對象時,將被當作派生類對象進行析構
delete pFish;
}
int main()
{
cout << "Allocating a Tuna on the free store:" << endl;
Tuna *pTuna = new Tuna;
cout << "Deleting the Tuna: " << endl;
DeleteFishMemory(pTuna);
cout << "Instantiating a Tuna on the stack:" << endl;
Tuna tuna;
cout << "Automatic destruction as it goes out of scope: " << endl;
return 0;
}
運行結果如下:(//后不是打印內容,是說明語句)
Allocating a Tuna on the free store: // 在堆上創建派生類對象 pTuna
Constructed Fish // 調用基類構造函數
Constructed Tuna // 調用派生類構造函數
Deleting the Tuna: // 調用自定義函數以銷毀派生類對象
Destroyed Tuna // 調用派生類析構函數
Destroyed Fish // 調用基類析構函數
Instantiating a Tuna on the stack: // 在棧上創建派生類對象 tuna
Constructed Fish // 調用基類構造函數
Constructed Tuna // 調用派生類構造函數
Automatic destruction as it goes out of scope: // 提示 main() 結束后自動銷毀棧上對象 tuna
Destroyed Tuna // 調用派生類析構函數
Destroyed Fish // 調用基類析構函數
如果我們將第 12 行 virtual 關鍵字注釋掉,再次編譯運行,結果如下:
Allocating a Tuna on the free store:
Constructed Fish
Constructed Tuna
Deleting the Tuna:
Destroyed Fish // 堆上的派生類對象被當作基類對象析構,只調用了基類析構函數,堆內存未銷毀干凈
Instantiating a Tuna on the stack:
Constructed Fish
Constructed Tuna
Automatic destruction as it goes out of scope:
Destroyed Tuna
Destroyed Fish
和前一次運行結果對比,很容易看出,析構函數未使能多態時,void DeleteFishMemory(Fish *pFish) 傳入實參為派生類對象時,delete 操作只會把實參當作基類對象處理,只調用基類的析構函數,從而導致堆上的派生類對象無法銷毀干凈。
總結如下:
如果不將析構函數聲明為虛函數,那么如果一個函數的形參是基類指針,實參是指向堆內存的派生類指針時,函數返回時作為實參的派生類指針將被當作基類指針進行析構,這會導致資源釋放不完全和內存泄漏;要避免這一問題,可將基類的析構函數聲明為虛函數,那么函數返回時,作為實參的派生類指針就會被當作派生類指針進行析構。
換句話說,對于使用 new 在堆內存中實例化的派生類對象,如果將其賦給基類指針,并通過基類指針調用 delete,如果基類析構函數不是虛函數,delete 將按基類析構的方式來析構此指針,如果基類析構函數是虛函數,delete 將按派生類析構的方式來析構此指針(調用派生類析構函數和基類析構函數)。
編程實踐:務必要將基類的析構函數聲明為虛函數,以避免派生類實例未被妥善銷毀的情況發生。
3. 多態機制的工作原理-虛函數表
為方便說明,將第一節代碼加以修改,例程 3 源碼如下:
#include <iostream>
using namespace std;
class Fish
{
public:
virtual void Swim() { cout << "Fish swims!" << endl; }
};
class Tuna : public Fish
{
public:
void Swim() { cout << "Tuna swims!" << endl; }
};
class Carp:public Fish
{
public:
void Swim() { cout << "Carp swims!" << endl; }
};
int main()
{
Fish *pFish = NULL;
pFish = new Fish();
pFish->Swim();
pFish = new Tuna();
pFish->Swim();
pFish = new Carp();
pFish->Swim();
return 0;
}
編譯運行的輸出結果為:
Fish swims!
Tuna swims!
Carp swims!
例程中使用統一類型(基類)的指針 pFish 指向不同類型(基類或派生類)的對象,指針的賦值是在運行階段執行的,在編譯階段,編譯器把 pFish 認作 Fish 類型的指針,而并不知道 pFish 指向的是哪種類型的對象,無法確定將執行哪個類中的 Swim() 方法。調用哪個類中的 Swim() 方法顯然是在運行階段決定的,這是使用實現多態的邏輯完成的,而這種邏輯由編譯器在編譯階段提供。
3.1 虛函數表機制
如下 Base 類聲明了 N 個虛函數:
class Base
{
public:
virtual void Func1() { // Func1 implementation }
virtual void Func2() { // Func2 implementation }
// .. so on and so forth
virtual void FuncN() { // FuncN implementation }
};
如下 Derived 類繼承自 Base 類,并覆蓋了除 Base::Func2() 外的其他所有虛函數。
class Derived: public Base
{
public:
virtual void Func1() { // Func2 overrides Base::Func2() }
// no implementation for Func2()
// .. so on and so forth
virtual void FuncN() { // FuncN overrides Base::FuncN() }
};
編譯器見到這種繼承層次結構后,知道 Base 定義了一些虛函數,并在 Derived 中覆蓋了它們。在這種情況下,編譯器將為實現了虛函數的基類和覆蓋了虛函數的派生類分別創建一個虛函數表(Virtual Function Table, VFT)。換句話說,Base 和 Derived 類都將有自己的虛函數表。實例化這些類的對象時,會為每個對象創建一個隱藏的指針(我們稱之為 VFT*),它指向相應的 VFT。可將 VFT 視為一個包含函數指針的靜態數組,其中每個指針都指向相應的虛函數,如下圖所示:

每個虛函數表都由函數指針組成,其中每個指針都指向相應虛函數的實現。在類 Derived 的虛函數表中,除 Func2 函數指針外,其他所有函數指針都指向 Derived 本地的虛函數實現。Derived 沒有覆蓋 Base::Func2(),因此相應的函數指針指向 Base 類的 Func2() 實現。
下述代碼調用未覆蓋的虛函數,編譯器將查找 Derived 類的 VFT,最終調用的是 Base::Func2() 的實現:
Derived objDerived;
objDerived.Func2();
調用被覆蓋的虛函數時,也是類似的機制:
void DoSomething(Base& objBase)
{
objBase.Func1(); // invoke Derived::Func1
}
int main()
{
Derived objDerived;
DoSomething(objDerived);
};
在這種情況下,雖然將 objDerived 傳遞給了 objBase,進而被解讀為一個 Base 實例,但該實例的 VFT 指針仍指向 Derived 類的虛函數表,因此通過該 VTF 執行的是 Derived::Func1()。
C++ 就是通過虛函數表實現多態的。
3.2 類實例中的 VFT 指針
例程 3.2 源碼如下:
#include <iostream>
using namespace std;
class Class1
{
private:
int a, b;
public:
void DoSomething() {}
};
class Class2
{
private:
int a, b;
public:
virtual void DoSomething() {}
};
int main()
{
cout << "sizeof(Class1) = " << sizeof(Class1) << endl;
cout << "sizeof(Class2) = " << sizeof(Class2) << endl;
return 0;
}
在 64 位系統下編譯并運行,結果為:
sizeof(Class1) = 8
sizeof(Class2) = 16
Class2 中將函數聲明為虛函數,因此類的成員多了一個 VFT 指針,64 位系統中,指針變量占用 8 字節空間,因此 Class2 比 Class1 多占用了 8 個字節。
3.3 繼承關系中的 VFT 指針
#include <iostream>
using namespace std;
class Base
{
private:
int x = 1;
int y = 2;
const static int z = 3;
/* 注釋1
public:
virtual void test() {};
*/
};
class Derived : public Base
{
private:
int u = 11;
int v = 22;
const static int w = 33;
/* 注釋2
public:
virtual void test() {};
*/
};
int main()
{
Base base;
Derived derived;
cout << "sizeof(Base) = " << sizeof(Base) << endl;
cout << "sizeof(Derived) = " << sizeof(Derived) << endl;
return 0;
}
上述代碼運行結果為:
sizeof(Base) = 8
sizeof(Derived) = 16
使用 gdb 查看變量值:
(gdb) p base
$1 = {x = 1, y = 2, static z = 3}
(gdb) p derived
$2 = {<Base> = {x = 1, y = 2, static z = 3}, u = 11, v = 22, static w = 33}
取消“注釋1”處的注釋,運行結果為:
sizeof(Base) = 16
sizeof(Derived) = 24
使用 gdb 查看變量值:
(gdb) p base
$1 = {_vptr.Base = 0x400b10 <vtable for Base+16>, x = 1, y = 2, static z = 3}
(gdb) p derived
$2 = {<Base> = {_vptr.Base = 0x400af0 <vtable for Derived+16>, x = 1, y = 2, static z = 3}, u = 11, v = 22, static w = 33}
再取消“注釋2”處的注釋,運行結果為:
sizeof(Base) = 16
sizeof(Derived) = 24
使用 gdb 查看變量值:
(gdb) p base
$1 = {_vptr.Base = 0x400b10 <vtable for Base+16>, x = 1, y = 2, static z = 3}
(gdb) p derived
$2 = {<Base> = {_vptr.Base = 0x400af0 <vtable for Derived+16>, x = 1, y = 2, static z = 3}, u = 11, v = 22, static w = 33}
根據上述實驗結果,給出結論:
- static 數據成員屬于整個類,不占用某一具體對象的內存空間。
- 基類的所有非 static 數據成員(包括私有數據成員)都會在派生類對象中占用內存。
- 只要基類含有虛函數,基類和派生類對象都會含有各自的 VFT 指針,即使派生類沒有虛函數。如果派生類沒有虛函數,那么派生類虛函數表中的每個元素都指向基類的的虛函數。
- 派生類對象只含一份 VFT 指針,基類的 VFT 指針不會在派生類中占用內存。從打印可以看出,VFT 指針為 _vptr.Base,派生類的 VFT 指針存在在派生類的 Base 部分,也可以認為派生類的 VFT 指針覆蓋了基類的 VFT 指針,指向自己的虛函數表。
4. 純虛函數和抽象基類
在 C++ 中,包含純虛函數的類是抽象基類。抽象基類用于定義接口(只聲明形式,不實現內容),在派生類中實現接口,這樣可以實現接口與實現的分離。抽象基類不能被實例化。抽象基類提供了一種非常好的機制,可在基類聲明所有派生類都必須實現的函數接口,將這些派生類中必須實現的接口聲明為純虛函數即可。
純虛函數寫法如下:
class AbstractBase
{
public:
virtual void DoSomething() = 0; // pure virtual method
};
其派生類中必須實現此函數。
例程 4 源碼如下:
#include <iostream>
#include <stdio.h>
using namespace std;
class B
{
public:
virtual void func1() = 0; // 純虛函數不能在基類中實現,一定要在派生類中實現
virtual void func2() = 0; // 純虛函數不能在基類中實現,一定要在派生類中實現
virtual void func3() { cout << "B::func3" << endl; } // 此虛函數被派生類中函數覆蓋
virtual void func4() { cout << "B::func4" << endl; } // 此虛函數在派生類中無覆蓋
void func5() { cout << "B::func5" << endl; } // 此函數被派生類中函數覆蓋
void func6() { cout << "B::func6" << endl; } // 此函數在派生類中無覆蓋
private:
int x = 1;
int y = 2;
static int z;
};
class D : public B
{
public:
virtual void func1() override { cout << "D::func1" << endl; }
virtual void func2() override { cout << "D::func2" << endl; }
virtual void func3() override { cout << "D::func3" << endl; }
void func5() { cout << "D::func5" << endl; } // 不能帶 override
private:
int u = 11;
int v = 22;
static int w;
};
int main()
{
// B b; // 編譯錯誤,抽象基類不能被實例化
D d;
cout << "sizeof(d) = " << sizeof(d) << endl;
d.func1(); // 訪問派生類中的覆蓋函數(覆蓋純虛函數)
d.func2(); // 訪問派生類中的覆蓋函數(覆蓋純虛函數)
d.func3(); // 訪問派生類中的覆蓋函數(覆蓋虛函數)
d.func5(); // 訪問派生類中的覆蓋函數(覆蓋普通函數)
d.B::func3(); // 訪問基類中的虛函數
d.B::func4(); // 訪問基類中的虛函數
d.B::func5(); // 訪問基類中的普通函數
return 0;
}
上述代碼運行結果:
sizeof(d) = 24
D::func1
D::func2
D::func3
D::func5
B::func3
B::func4
B::func5
結論如下:
- 類中只要有一個純虛函數,這個類就是抽象基類,不能被實例化。
- 基類中的純虛函數,基類不能給出實現,必須在派生類中實現,即一定要在派生類中覆蓋基類的純虛函數。
- 基類中的虛函數,基類中要給出實現,派生類可實現也可不實現,即派生類需要覆蓋基類中的虛函數。
- 基類中的普通函數,被派生類中的同名同參的函數覆蓋,這種情況下,此函數既不能被繼承,也不支持多態。所以要避免這種情況,避免派生類中某一函數與基類某普通函數同名。
- 普通函數不支持多態,所以需要繼承的函數應聲明為虛函數,不應使用普通函數。
5. 使用虛繼承解決菱形問題
一個類繼承多個父類,而這多個父類又繼承一個更高層次的父類時,會引發菱形問題。例如,鴨嘴獸具備哺乳動物、鳥類和爬行動物的特征,這意味著 Platypus 類需要繼承 Mammal、 Bird 和 Reptile 三個類。然而,這些類都從同一個 Animal 類派生而來,如下圖所示:

例程如下:
#include <iostream>
using namespace std;
class Animal
{
public:
Animal() { cout << "Animal constructor" << endl; }
int age;
};
class Mammal : public /* virtual */ Animal
{
};
class Bird : public /* virtual */ Animal
{
};
class Reptile : public /* virtual */ Animal
{
};
class Platypus : public Mammal, public Bird, public Reptile
{
public:
Platypus() { cout << "Platypus constructor" << endl; }
};
int main()
{
Platypus duckBilledP;
// uncomment next line to see compile failure
// age is ambiguous as there are three instances of base Animal
// duckBilledP.age = 25;
duckBilledP.Mammal::age = 25;
duckBilledP.Bird::age = 25;
duckBilledP.Reptile::age = 25;
return 0;
}
編譯并運行,輸出結果如下:
Animal constructor
Animal constructor
Animal constructor
Platypus constructor
可見,Platypus 有三個 Animal 實例。如果取消第 35 行的注釋,編譯無法通過,因為無法確定是要設置哪個 Animal 實例中的 age 成員。
如果取消第 11、15、19 行對關鍵字 virtual 的注釋,再次編譯運行,可看到如下輸出結果:
Animal constructor
Platypus constructor
此時,Platypus 只有一個 Animal 實例。
在繼承層次結構中,繼承多個從同一個類派生而來的基類時,如果這些基類沒有采用虛繼承,將導致二義性。這種二義性被稱為菱形問題(Diamond Problem)。使用虛繼承可以解決多繼承時的菱形問題。
C++關鍵字 virtual 被用于實現兩個不同的概念,其含義隨上下文而異,如下:
- 在函數聲明中, virtual 意味著當基類指針指向派生對象時,通過它可調用派生類的相應函數。
- 從 Base 類派生出 Derived1 和 Derived2 類時,如果使用了關鍵字 virtual,則意味著再從 Derived1 和 Derived2 派生出 Derived3 時,每個 Derived3 實例只包含一個 Base 實例。
6. 使用 override 明確表明覆蓋意圖
從 C++11 起,程序員可使用限定符 override 來核實被覆蓋的函數在基類中是否被聲明為虛函數。形式如下:
class Fish
{
public:
virtual void Swim()
{
cout << "Fish swims!" << endl;
}
};
class Tuna : public Fish
{
public:
void Swim() const override // Error: no virtual fn with this sig in Fish
{
cout << "Tuna swims!" << endl;
}
void Swim() override // Right: has virtual fn with this sig in Fish
{
cout << "Tuna swims!" << endl;
}
};
換而言之, override 提供了一種強大的途徑,讓程序員能夠明確地表達對基類的虛函數進行覆蓋的意圖,進而讓編譯器做如下檢查:
? 基類函數是否是虛函數?
? 派生類中被聲明為 override 的函數是否是基類中對應虛函數的覆蓋?確保沒有有手誤寫錯。
編程實踐:在派生類中聲明要覆蓋基類函數的函數時,務必使用關鍵字 override
7. 使用 final 禁止覆蓋
被聲明為 final 的類禁止繼承,不能用作基類。而被聲明為 final 的虛函數,不能在派生類中進行覆蓋。
因此,要在 Tuna 類中禁止進一步定制虛函數 Swim(),可像下面這樣做:
class Tuna : public Fish
{
public:
// override Fish::Swim and make this final
void Swim() override final // 我覆蓋父類中的函數,但我的子類不要覆蓋我
{
cout << "Tuna swims!" << endl;
}
};
Tuna 類可以被繼承,但 Swim() 函數不能派生類中的實現覆蓋。
8. 可將復制構造函數聲明為虛函數嗎
答案是不可以。不可能實現虛復制構造函數,因為在基類方法聲明中使用關鍵字 virtual 時,表示它將被派生類的實現覆蓋,這種多態行為是在運行階段實現的。而構造函數只能創建固定類型的對象,不具備多態性,因此 C++不允許使用虛復制構造函數。
雖然如此,但存在一種不錯的解決方案,就是定義自己的克隆函數來實現上述目的。這部分內容有些復雜,待用到時再作補充。
修改記錄
2019-05-28 V1.0 初稿
2020-03-01 V1.1 修改錯誤,整理文檔

浙公網安備 33010602011771號