虛函數表詳解

1、概述

爲了實現C++的多態,C++使用了一種動態綁定的技術。這個技術的核心是虛函數表(下文簡稱虛表)。本文介紹虛函數表是如何實現動態綁定的。數組

2、類的虛表

每一個包含了虛函數的類都包含一個虛表。 
咱們知道,當一個類(A)繼承另外一個類(B)時,類A會繼承類B的函數的調用權。因此若是一個基類包含了虛函數,那麼其繼承類也可調用這些虛函數,換句話說,一個類繼承了包含虛函數的基類,那麼這個類也擁有本身的虛表。函數

咱們來看如下的代碼。類A包含虛函數vfunc1,vfunc2,因爲類A包含虛函數,故類A擁有一個虛表。spa

 
  1. class A {設計

  2. public:指針

  3. virtual void vfunc1();code

  4. virtual void vfunc2();對象

  5. void func1();blog

  6. void func2();繼承

  7. private:圖片

  8. int m_data1, m_data2;

  9. };

類A的虛表如圖1所示。 
這裏寫圖片描述 
圖1:類A的虛表示意圖

虛表是一個指針數組,其元素是虛函數的指針,每一個元素對應一個虛函數的函數指針。須要指出的是,普通的函數即非虛函數,其調用並不須要通過虛表,因此虛表的元素並不包括普通函數的函數指針。 
虛表內的條目,即虛函數指針的賦值發生在編譯器的編譯階段,也就是說在代碼的編譯階段,虛表就能夠構造出來了。

3、虛表指針

虛表是屬於類的,而不是屬於某個具體的對象,一個類只須要一個虛表便可。同一個類的全部對象都使用同一個虛表。 
爲了指定對象的虛表,對象內部包含一個虛表的指針,來指向本身所使用的虛表。爲了讓每一個包含虛表的類的對象都擁有一個虛表指針,編譯器在類中添加了一個指針,*__vptr,用來指向虛表。這樣,當類的對象在建立時便擁有了這個指針,且這個指針的值會自動被設置爲指向類的虛表。

這裏寫圖片描述
圖2:對象與它的虛表

上面指出,一個繼承類的基類若是包含虛函數,那個這個繼承類也有擁有本身的虛表,故這個繼承類的對象也包含一個虛表指針,用來指向它的虛表。

4、動態綁定

說到這裏,你們必定會好奇C++是如何利用虛表和虛表指針來實現動態綁定的。咱們先看下面的代碼。

 
  1. class A {

  2. public:

  3. virtual void vfunc1();

  4. virtual void vfunc2();

  5. void func1();

  6. void func2();

  7. private:

  8. int m_data1, m_data2;

  9. };

  10.  
  11. class B : public A {

  12. public:

  13. virtual void vfunc1();

  14. void func1();

  15. private:

  16. int m_data3;

  17. };

  18.  
  19. class C: public B {

  20. public:

  21. virtual void vfunc2();

  22. void func2();

  23. private:

  24. int m_data1, m_data4;

  25. };

類A是基類,類B繼承類A,類C又繼承類B。類A,類B,類C,其對象模型以下圖3所示。

這裏寫圖片描述
圖3:類A,類B,類C的對象模型

因爲這三個類都有虛函數,故編譯器爲每一個類都建立了一個虛表,即類A的虛表(A vtbl),類B的虛表(B vtbl),類C的虛表(C vtbl)。類A,類B,類C的對象都擁有一個虛表指針,*__vptr,用來指向本身所屬類的虛表。 
類A包括兩個虛函數,故A vtbl包含兩個指針,分別指向A::vfunc1()和A::vfunc2()。 
類B繼承於類A,故類B能夠調用類A的函數,但因爲類B重寫了B::vfunc1()函數,故B vtbl的兩個指針分別指向B::vfunc1()和A::vfunc2()。 
類C繼承於類B,故類C能夠調用類B的函數,但因爲類C重寫了C::vfunc2()函數,故C vtbl的兩個指針分別指向B::vfunc1()(指向繼承的最近的一個類的函數)和C::vfunc2()。 
雖然圖3看起來有點複雜,可是隻要抓住「對象的虛表指針用來指向本身所屬類的虛表,虛表中的指針會指向其繼承的最近的一個類的虛函數」這個特色,即可以快速將這幾個類的對象模型在本身的腦海中描繪出來。

非虛函數的調用不用通過虛表,故不須要虛表中的指針指向這些函數。

假設咱們定義一個類B的對象。因爲bObject是類B的一個對象,故bObject包含一個虛表指針,指向類B的虛表。

 
  1. int main()

  2. {

  3. B bObject;

  4. }

  • 如今,咱們聲明一個類A的指針p來指向對象bObject。雖然p是基類的指針只能指向基類的部分,可是虛表指針亦屬於基類部分,因此p能夠訪問到對象bObject的虛表指針。bObject的虛表指針指向類B的虛表,因此p能夠訪問到B vtbl。如圖3所示。
  1. int main()

  2. {

  3. B bObject;

  4. A *p = & bObject;

  5. }

  • 當咱們使用p來調用vfunc1()函數時,會發生什麼現象?
  1. int main()

  2. {

  3. B bObject;

  4. A *p = & bObject;

  5. p->vfunc1();

  6. }

程序在執行p->vfunc1()時,會發現p是個指針,且調用的函數是虛函數,接下來便會進行如下的步驟。 
首先,根據虛表指針p->__vptr來訪問對象bObject對應的虛表。雖然指針p是基類A*類型,可是*__vptr也是基類的一部分,因此能夠經過p->__vptr能夠訪問到對象對應的虛表。 
而後,在虛表中查找所調用的函數對應的條目。因爲虛表在編譯階段就能夠構造出來了,因此能夠根據所調用的函數定位到虛表中的對應條目。對於 p->vfunc1()的調用,B vtbl的第一項便是vfunc1對應的條目。 
最後,根據虛表中找到的函數指針,調用函數。從圖3能夠看到,B vtbl的第一項指向B::vfunc1(),因此 p->vfunc1()實質會調用B::vfunc1()函數。

若是p指向類A的對象,狀況又是怎麼樣?

 
  1. int main()

  2. {

  3. A aObject;

  4. A *p = &aObject;

  5. p->vfunc1();

  6. }

當aObject在建立時,它的虛表指針__vptr已設置爲指向A vtbl,這樣p->__vptr就指向A vtbl。vfunc1在A vtbl對應在條目指向了A::vfunc1()函數,因此 p->vfunc1()實質會調用A::vfunc1()函數。

能夠把以上三個調用函數的步驟用如下表達式來表示:

(*(p->__vptr)[n])(p)

能夠看到,經過使用這些虛函數表,即便使用的是基類的指針來調用函數,也能夠達到正確調用運行中實際對象的虛函數。 
咱們把通過虛表調用虛函數的過程稱爲動態綁定,其表現出來的現象稱爲運行時多態。動態綁定區別於傳統的函數調用,傳統的函數調用咱們稱之爲靜態綁定,即函數的調用在編譯階段就能夠肯定下來了。

那麼,何時會執行函數的動態綁定?這須要符合如下三個條件。

  • 經過指針來調用函數
  • 指針upcast向上轉型(繼承類向基類的轉換稱爲upcast,關於什麼是upcast,能夠參考本文的參考資料)
  • 調用的是虛函數

若是一個函數調用符合以上三個條件,編譯器就會把該函數調用編譯成動態綁定,其函數的調用過程走的是上述經過虛表的機制。

5、總結

封裝,繼承,多態是面向對象設計的三個特徵,而多態能夠說是面向對象設計的關鍵。C++經過虛函數表,實現了虛函數與對象的動態綁定,從而構建了C++面向對象程序設計的基石。

相關文章
相關標籤/搜索