引言
C++多態的實現方式可以分為靜態多態和動態多態,其中靜態多態主要有函數重裝和模板兩種方式,動態多態就是虛函數。
下面我們將通過解答以下幾個問題的方式來深入理解虛函數的原理:
- 為什么要引入虛函數?(用來解決什么問題)
- 虛函數底層實現原理
- 使用虛函數時需要注意什么?
正文
為什么要引入虛函數?
在回答這個問題之前,我們先看一個示例:
假設我們正在開發一個圖形編輯器,其中包含各種類型的圖形元素,比如圓形、矩形、多邊形等。我們要如何管理所有圖形對象呢?
- 甲同學的方案:
class Circle {
public:void draw() const {// 實現繪制圓形的代碼}
};class Rectangle {
public:void draw() const {// 實現繪制矩形的代碼}
};// 管理圖形對象:
std::vector<Circle*> circle_shapes;
std::vector<Rectangle*> rectangle_shapes;
circle_shapes.push_back(new Circle());
rectangle_shapes.push_back(new Rectangle());// 刷新繪制圖形
for (auto shape : circle_shapes) {shape->draw();
}
for (auto shape : rectangle_shapes) {shape->draw();
}
甲同學實現的方法比較直白簡單,有多少種類型的圖形就定義多少種類,維護和繪制都需要根據圖形類型數量來修改。
當我要新增一種圖形類型Polygon
時,就需要新增以下代碼:
class Polygon {
public:void draw() const {// 實現繪制矩形的代碼}
};// 管理圖形對象:
std::vector<Polygon*> polygon_shapes;
polygon_shapes.push_back(new Polygon());// 刷新繪制圖形
for (auto shape : polygon_shapes) {shape->draw();
}
這種方式的擴展性、可維護性都是最差的。
- 乙同學的方案:
class Shape {
public:virtual void draw() const = 0; // 純虛函數,使得Shape成為抽象基類
};class Circle : public Shape {
public:void draw() const override {// 實現繪制圓形的代碼}
};class Rectangle : public Shape {
public:void draw() const override {// 實現繪制矩形的代碼}
};// 管理圖形對象:
std::vector<Shape*> shapes;
shapes.push_back(new Circle());
shapes.push_back(new Rectangle());// 刷新繪制圖形
// 通過基類指針調用適當的draw方法
for (auto* shape : shapes) {shape->draw(); // 在運行時決定調用哪個類的draw方法
}
乙同學將圖形抽象出一個基類Shape
,然后繼承該類來實現Circle
和Rectangle
;同時將通用接口設計成虛函數,派生類重寫虛函數,在運行時根據對象來調用哪個類的函數。
這種方式既簡化了代碼,又提高了可擴展性和可維護性。
具體來說,虛函數解決的主要問題是如何在不完全知道對象類型的情況下,調用正確的函數。在沒有虛函數的情況下,函數的調用在編譯時就已經確定了(這稱為靜態綁定)。但是,如果我們想要在運行時根據對象的實際類型來決定調用哪個函數(動態綁定),就需要使用虛函數。
虛函數底層實現原理
我們先介紹一下虛函數實現原理中最重要的兩個東西:虛函數表(也稱虛表,vtable)和虛指針(也稱虛表指針,vptr)。
虛函數表
每個包含虛函數的類或其派生類都會擁有一個虛函數表。這個表是一個編譯時生成的靜態數組,存儲在每個類的定義中。
虛函數表主要包含以下元素:
- 虛函數指針:表中的每一個條目都是指向類中每個虛函數的指針。這包括從基類繼承來的虛函數,如果在派生類中被重寫,則指向新的函數地址。
- 類型信息:在支持運行時類型識別(RTTI)的系統中,虛函數表還可能包含指向類型信息的指針,這有助于
typeid
和dynamic_cast
等操作。
虛指針
虛指針是每個對象中的一個隱含成員,如果該對象的類包含虛函數。在對象構造時,編譯器設置這個虛指針指向相應類的虛函數表。
每次通過類的實例調用虛函數時,程序會首先通過虛指針訪問虛函數表,然后通過虛函數表定位到具體的函數地址并調用。這個過程是在運行時完成的,因此允許函數調用根據對象的實際類型動態綁定,而非編譯時決定。
想要了解虛函數的實現原理,就需要先了解類的內存布局,通過內存布局來直觀地學習虛函數的原理。
內存布局
普通類的內存布局
class N {
public:void funA() { std::cout << "funA()" << std::endl; }void funB() { std::cout << "funB()" << std::endl; }int a;int b;
};
class N
的內存布局如下:
1>class N size(8):
1> +---
1> 0 | a
1> 4 | b
1> +---
想要看一個類的內存布局,只需要通過添加命令行:
/d1 reportSingleClassLayoutXXX
(其中XXX就是你想要看的類名)即可。
普通的類只會存儲數據成員。
- 普通的類中為什么沒有維護成員函數呢?
類的成員函數在編譯后存儲在程序的代碼段中,被程序中所有對象共享。
因為一個類的不同實例對象所執行的成員函數是一樣的,沒有必要在實例對象中再復制維護了。所有同類的實例對象使用相同的函數代碼(通過隱含的this
指針來訪問對象的成員變量和成員函數),不僅節省內存,也使得程序更加高效。
這里不再詳細介紹函數調用的原理了,這是最基礎的知識… …
基類的內存布局
class Base {
public:virtual void vFunA() = 0;virtual void vFunB() {}void funA() {}void funB() {}int a;int b;
};
class Base
的內存布局如下:
1>class Base size(12):
1> +---
1> 0 | {vfptr}
1> 4 | a
1> 8 | b
1> +---
1>Base::$vftable@:
1> | &Base_meta
1> | 0
1> 0 | &Base::vFunA
1> 1 | &Base::vFunB
class Base
是一個帶虛函數的類,可以看到它的內存布局和普通類有很大的區別。class Base
中的{vfptr}
是一個指向虛函數表(vftable
)的指針。Base::$vftable@
就是虛函數表,其中&Base_meta
是class Base
的元數據(該類的類型信息,用于運行時類型識別)。虛函數表內主要是維護該類的虛函數地址。
派生類A的內存布局
class A : public Base {
public:virtual void vFunA() override {}virtual void vFunB() override {}void funA() {}void funB() {}int c;
};
class A
的內存布局如下:
1>class A size(16):
1> +---
1> 0 | +--- (base class Base)
1> 0 | | {vfptr}
1> 4 | | a
1> 8 | | b
1> | +---
1>12 | c
1> +---
1>A::$vftable@:
1> | &A_meta
1> | 0
1> 0 | &A::vFunA
1> 1 | &A::vFunB
派生類A的內存布局和基類又不一樣了。
因為class A
繼承class Base
,所以內存布局就包含了基類的數據,然后才是自己的成員c
。
這里需要注意的是虛函數表中,虛函數地址發生了變化,原來虛函數表中的虛函數地址分別是&Base::vFunA
和&Base::vFunB
,現在虛函數地址被更新成class A
的虛函數地址了。
派生類B的內存布局
class B : public Base {
public:virtual void vFunA() override {}void funA() {}void funB() {}int d;
};
class B
的內存布局如下:
1>class B size(16):
1> +---
1> 0 | +--- (base class Base)
1> 0 | | {vfptr}
1> 4 | | a
1> 8 | | b
1> | +---
1>12 | d
1> +---
1>B::$vftable@:
1> | &B_meta
1> | 0
1> 0 | &B::vFunA
1> 1 | &Base::vFunB
派生類B和A的主要區別就是沒有重寫虛函數vFunB
,所以在虛函數表中可以看到虛函數vFunB
的地址沒有被更新,還是指向基類的虛函數地址。
所以,從上面四個類的內存布局可以看出:
- 只要寫了虛函數,就會多生成一個虛函數表,并且還有虛指針指向虛函數表。
- 派生類繼承基類,并重寫虛函數后,虛函數表對應的虛函數地址將被更新。
使用虛函數時需要注意什么?
使用虛函數時需要遵循以下規則:
- 虛函數不能是靜態的
虛函數的目的是為了實現動態多態,和靜態函數在本質上是沖突的。
- 要實現運行時多態性,必須使用基類類型的指針或引用來訪問虛函數
如果調用是通過對象實例(而非指針或引用),則會發生靜態綁定,在編譯時,編譯器確定了要調用的函數版本,這種確定不會延遲到運行時。
- 虛函數的原型在派生類和基類中必須保持一致
虛函數的原型指的是虛函數的名稱、返回類型、參數列表、const屬性。
這句話的意思就是說派生類重寫的虛函數需要和基類的虛函數名稱、返回類型、參數列表、const屬性都保持一致。
- 類可以有虛析構函數,但不能有虛構造函數
- 首先我們先分析前半句:類可以有虛析構函數
其實在繼承關系中,析構函數必須是虛函數。因為當析構函數不是虛函數,那么通過基類指針釋放派生類對象時,只能調用基類的析構函數,導致派生類中的部分資源無法釋放。
- 后半句:但不能有虛構造函數
調用虛函數是通過虛指針定位到虛函數表,然后找到對應的虛函數地址。如果構造函數是虛函數,那么調用構造函數是不是需要先通過虛指針來定位虛函數表了,但虛指針的初始化發生在構造函數階段,所以這里有沖突。
未完待續… …