C++ 虛函數的內部實現
C++ 虛函數的內部實現
虛函數看起來是個玄之又玄的東西,但其實特別簡單!了解了虛函數的內部實現,關於虛函數的各種問題都不在話下啦!
1. 知識儲備
閱讀這篇文章,你需要事先了解以下幾個概念:
-
什麼是繼承?
-
什麼是虛函數?
在C++中,在基類的成員函數聲明前加上關鍵字
virtual
即可讓該函數成為 虛函數,派生類中對此函數的不同實現都會繼承這一修飾符。 -
為什麼需要虛函數?
- 進程的內存分佈
如果你已經完全了解上述概念,那麼這篇文章很適合你去深入了解虛函數~
2. C++中類的memory Layout
為了更好地理解虛函數的內部實現,我們首先需要知道,C++的類中成員函數和成員變量在內存中的空間分配。
1. 我們從最普通的一個類說起~
class X {
int x;
float xx;
static int count;
public:
X() {}
~X() {}
void printInt() {}
void printFloat() {}
static void printCount() {}
};
如果我們在這個程序中定義了這個類的一個對象,那麼這個類的內存分佈如下圖所示:
類的非靜態成員變量會被保存在棧上,類的靜態成員變量被保存在數據段,而類的成員函數被保存在代碼段。
class X { int x; float xx; static int count; public: X() {} ~X() {} void printInt() {} void printFloat() {} static void printCount() {} }; |
![]() |
2. 含有虛函數的基類的內存分佈
如果一個類中含有虛函數,那麼為了實現動態綁定,編譯器會在原來的代碼中插入(augment)一個新的成員變量–一個成員指針 vptr
, 這個指針指向一張包含所有虛函數的函數指針表 vtable
. 當我們調用虛函數時,實際上是通過vptr
這個指針來調用函數指針表vtable
裏面的某個函數指針來實現函數調用。
一般而言,這張vtable
會在數據段,是靜態的,每一個類僅有一張表。但是這不是死規定,這是由編譯器的實現決定的。vptr
這個指針和成員變量一致,存在在堆棧段,是每一個對象都會有的。
vtable
中的第一個entry包含了當前類及其基類的相關信息,其餘entry是函數指針。
現在來看一個例子~
class X { int x; float xx; static int count; public: X() {} virtual ~X() {} virtual void printAll() {} void printInt() {} void printFloat() {} static void printCount() {} }; |
![]() |
3. 含有虛函數的子類的內存分佈
此時,基類的成員變量和成員函數相當於派生類的子對象,也就是說派生類會繼承基類的vptr
。這時會先為基類的成員函數和成員對象分配內存空間,然後再為派生類的自己的成員變量和成員函數分配空間。vptr
會指向Y這個類的vtable
如果派生類寫了一個不在基類里的新的虛函數,那麼這個vtable
會多出一行,行內的內容是指向這個新虛函數的函數指針。
class X { int x; public: X() {} virtual ~X() {} virtual void printAll() {} }; class Y : public X { int y; public: Y() {} ~Y() {} void printAll() {} }; |
![]() |
4. 含有虛函數、有多繼承的子類的內存分佈
有多個基類的派生類會有多個vptr
, 用來指向繼承自不同基類的vtable
。也就是說,每一個有虛函數的基類都會有一個虛函數指針表。
我們來看一個Z類繼承自X類和Y類的例子。
class X { public: int x; virtual ~X() {} virtual void printX() {} }; class Y { public: int y; virtual ~Y() {} virtual void printY() {} }; class Z : public X, public Y { public: int z; ~Z() {} void printX() {} void printY() {} void printZ() {} }; |
![]() |
3. 虛函數的內部實現
了解了虛函數在內存中的分配方式後,理解虛函數的實現以及動態綁定就變得非常簡單了。
這裡以多繼承的子類的代碼為例,上代碼~
class X {
public:
int x;
virtual ~X() {}
virtual void printX() { cout<<"printX() in X"<<endl; }
};
class Y {
public:
int y;
virtual ~Y() {}
virtual void printY() { cout<<"printY() in Y"<<endl; }
};
class Z : public X, public Y {
public:
int z;
~Z() {}
void printX() { cout<<"printX() in Z"<<endl; }
void printY() { cout<<"printY() in Z"<<endl; }
void printZ() { cout<<"printZ() in Z"<<endl; }
};
int main(){
Y *y_ptr = new Z();
y_ptr->printY(); // OK
y_ptr->printZ(); // Not OK, Y類的虛函數表中沒有printZ()函數
y_ptr->y = 3; // OK
y_ptr->z = 3;// not OK, Y類的空間中沒有變量z
}
所以在上述代碼中,y_ptr
指向的是在Z類對象中的子對象,即Y類對象在Z類中函數與變量。
注意⚠️ 此時y_ptr
中的_vptr
指向的是Z類對象的vtable
而y_ptr->printY()
這行代碼,其實會被編譯器翻譯成如下偽代碼
((y_ptr->_vptr)->_vtbl[2])();
其中y_ptr->_vptr
指向Y類對象的vptr
指針,vptr
指針再指向虛函數表中對應的函數指針項,即((y_ptr->_vptr)->_vtbl[2])
, 最後通過函數指針來實現函數調用。
由於這個_vptr
指向的是Z類對象的虛函數表,所以調用的printY()
函數實際上是Z類中實現的printY()
,即輸出"printY() in Z"
。 動態綁定就這樣實現了。
4. 用幾個問題加深理解
沿用3中的例子,我們來看接下來的幾個問題。
Q1. 如果將Z類對象賦值給Y類變量,動態綁定還會發生嗎?
即如下代碼中,輸出是"printY() in Z"
還是"printY() in Y"
?
Z zz;
Y yy = zz;
yy.printY();
答案是不會發生,輸出的結果是"printY() in Y"
。
首先我們需要明確一個很重要的概念,對_vptr
這個指針的賦值操作是在構造類對象的過程中發生的。換一句話說,當一個類的實例被創建的時候_vptr
被賦值,指向該類的vtable
。一旦類的實例被創建,一個類對象裏面的_vptr
永遠不會變,永遠都會指向所屬類型的虛函數表。
不論是賦值操作還是賦值構造時, 只會處理成員變量,即把zz中的成員變量賦值給yy, 但是_vptr
還是指向Y類的虛函數表。
Q2. 如果在基類中不聲明某個函數是虛函數,在子類中重寫了這個函數,動態綁定還會發生嗎?
即如下代碼中,輸出是"printX() in Z"
還是"printX() in X"
?
class X {
public:
int x;
virtual ~X() {}
void printX() { cout<<"printX() in X"<<endl; }
};
class Z : public X {
public:
int z;
~Z() {}
void printX() { cout<<"printX() in Z"<<endl; }
void printZ() { cout<<"printZ() in Z"<<endl; }
};
int main(){
X *x_ptr = new Z();
x_ptr->printX(); // OK
}
答案是不會發生,輸出的結果是"printX() in X"
。沒有聲明為虛函數的函數,不會被放入虛函數表中,即vtable
不會保存該函數的函數指針。這時,動態綁定肯定不會發生了。
5. 總結
- 一般而言,虛函數表是屬於一個類的(one vtable per class), 位於靜態數據區,而虛函數表指針
_vptr
是屬於一個類的對象的(one vptr per object). - 一個由多繼承關係的類會有多個虛函數指針。
- 虛函數指針的賦值操作是在構造類對象的過程中發生的,之後的賦值操作不會改變
vptr
的值 - C++標準沒有定義動態綁定的具體實現方式,只是陳述了動態綁定的行為。具體的實現與編譯器相關。
以上為個人學習總結,如有錯誤歡迎指出!
這篇博文參考了如下文章:
//www.vishalchovatiya.com/memory-layout-of-cpp-object/