C++ 中的虛函數表及虛函數執行原理
為了實現虛函數,C++ 使用了虛函數表來達到延遲綁定的目的。虛函數表在動態/延遲綁定行為中用於查詢調用的函數。
儘管要描述清楚虛函數表的機制會多費點口舌,但其實其本身還是比較簡單的。
首先,每個包含虛函數的類(或者繼承自的類包含了虛函數)都有一個自己的虛函數表。這個表是一個在編譯時確定的靜態數組。虛函數表包含了指向每個虛函數的函數指針以供類對象調用。
其次,編譯器還在基類中定義了一個隱藏指針,我們稱為 *__vptr
,*__vptr
是在類實例創建時自動設置的,以指向類的虛函數表。*__vptr
是一個真正的指針,這和 *this
指針不同,*this
指針實際是一個函數參數,使編譯器來達到自引用的目的。
結果就是,每個類對象都會多分配一個指針的大小,並且 *__vptr
是被派生類繼承的。
如果你不清楚這些組件是怎麼配合運作的,看下面的例子:
class Base
{
public:
virtual void function1() {};
virtual void function2() {};
};
class D1: public Base
{
public:
virtual void function1() {};
};
class D2: public Base
{
public:
virtual void function2() {};
};
因為這裡有 3 個類,編譯器會創建 3 個虛函數表。
然後編譯器會在使用了虛函數的最上層基類中定義一個隱藏指針。儘管這個過程編譯器會自動處理,但我們還是通過下面的例子來說明指針添加的位置:
class Base
{
public:
FunctionPointer *__vptr;
virtual void function1() {};
virtual void function2() {};
};
class D1: public Base
{
public:
virtual void function1() {};
};
class D2: public Base
{
public:
virtual void function2() {};
};
*__vptr
在類對象創建的時候會設置成指向類的虛函數表。例如,類型 Base
被實例化的時候,*__vptr
就指向 Base
的虛函數表。類型 D1
或者 D2
被實例化的時候,*__vptr
就指向 D1
或者 D2
的虛函數表。
現在我們來看下虛函數表是怎麼創建的。因為示例中每個類僅有 2 個虛函數,所以每個虛函數表會存放兩個函數指針(分別指向 function1()
和 function2()
)。
Base
對象的虛函數表最簡單。Base
對象只能訪問 Base
類型的成員,不能訪問 D1
或者 D2
的函數。所以 Base
的虛函數表中的兩個指針分別指向 Base::function1()
和 Base::function2()
。
D1
的虛函數表稍複雜點,D1
對象能夠訪問 D1
以及 Base
的成員。D1
重寫了 function1()
,但沒有重寫 function2()
,所以 D1
的虛函數表中的兩個指針分別指向 D1::function1()
和 Base::function2()
。
D2
的虛函數表同理 D1
,包含了分別指向 Base::function1()
和 D2::function2()
的指針。
考慮如果創建 D1
對象時會發生什麼:
int main()
{
D1 d1;
}
因為 d1
是 D1
類型對象,d1
有它自己的 *__vptr
指向 D1
類型的虛函數表。
現在創建一個 Base
類型指針 *dPtr
指向 d1
:
int main()
{
D1 d1;
Base *dPtr = &d1;
return 0;
}
重點:
因為
dPtr
是Base
類型指針,它只指向d1
對象的Base
類型部分(即,指向d1
對象中的Base
子對象),而*__vptr
也在Base
類型部分。所以dPtr
可以訪問Base
類型部分中的*__vptr
。同時,這裡注意,dPtr->__vptr
指向的是D1
的虛擬函數表,這是在d1
初始化時就確定的。所以結果,儘管dPtr
是Base
類型指針,但它能夠訪問D1
的虛函數表。
因此,當有調用 dPtr->function1()
時,發生了什麼?
int main()
{
D1 d1;
Base *dPtr = &d1;
dPtr->function1();
return 0;
}
首先,程序識別到 function1()
是一個虛函數。
其次,程序使用 dPtr->__vptr
獲取到了 D1
的虛函數表。
然後,它在 D1
的虛函數表中尋找可以調用的 function1()
版本,這裡是 D1::function1()
。
因此,dPtr->function1()
實際調用了 D1::function1()
。
通過虛函數表,編譯器和程序能夠確定調用什麼版本的虛函數,儘管使用的是指向/引用基類的指針或者引用。
調用虛函數會比調用非虛函數更慢,有以下幾個原因:
- 必須使用
*__vptr
獲取正確的虛函數。 - 必須建立虛函數表的索引來獲取想要調用的函數。
- 調用找到的函數。
結果就是必須進行三次操作才能完成對函數的調用。但是對於現代計算機系統,這些額外操作增加的時間幾乎可以忽略不計。
另外,每個使用虛函數表的類都有 *__vptr
指針,從而每個類對象都會多一個指針的空間。虛函數很強大,但是它確實產生了性能開銷。