揭秘虛函數多態的實現細節


1、什么是虛函數

簡單地說:那些被virtual關鍵字修飾的成員函數就是虛函數。其主要作用就是實現多態性。

多態性是面向對象的核心:它的主要的思想就是可以采用多種形式的能力,通過一個用戶名字或者用戶接口完成不同的實現。通常多態性被簡單的描述為“一個接口,多個實現”。在C++里面具體的表現為通過基類指針訪問派生類的函數和方法。看下面這段簡單的代碼:

 1 class A
 2 {
 3  public:
 4      void print(){cout << "this is A" << endl;}      
 5 };
 6 
 7 class B
 8 {
 9  public:
10      void print(){cout << "this is B" << endl;}
11 };
12 
13 int main()
14 {
15       A a;
16       B b;
17       a.print();
18       b.print();  
19 }

輸出的結果分別是This is A和This is B。但這是否真正做到了多態呢?沒有!多態的關鍵點是用指向基類的指針來調用派生類對象。

1 int main()
2 {
3      A a;
4      B b;
5      A * p1 = &a;
6      B * p2 = &b;
7      p1->print();
8      p2->print();
9 }

輸出的結果是兩個This is A。為什么呢?p2明明指向的是class B的對象但卻調用class A的print()函數,這不是我們所期望的結果,那么怎么解決這個問題呢?此時就需要虛函數:

 1 class A
 2 {
 3  public:
 4      virtual void print(){cout << "This is A" << endl;}
 5 };
 6 
 7 class B : public A
 8 {
 9  public:
10      void print(){cout << "This is B" << endl;}
11 };

此刻,class A的成員函數print()已經成了虛函數,那么class B的print()成了虛函數了么?是的!我們只需要把基類的成員函數設為virtual,其派生類的相應的函數也會自動變為虛函數。所以,class B的print()也成了虛函數。那么對於在派生類的相應函數前是否要用virtual關鍵字修飾,這個是個人的習慣問題了。

重新運行之前的代碼,輸出的結果就是This is A和This is B。

簡單總結就是:基類的指針在操作派生類對象時,會根據不同的具體對象類型,調用相對應的函數(虛函數).

2、聯編

在詳細解釋虛函數多態是怎么實現之前,我們先了解下聯編的概念——就是將模塊或者函數合並在一起生成可執行代碼的處理過程,同時對每個模塊或者函數調用分配內存地址,並且對外部訪問也分配正確的內存地址。按照聯編所進行的階段不同,可分為靜態和動態兩種。

靜態聯編:在編譯階段就將函數實現和函數調用關聯起來稱之為靜態聯編,靜態聯編在編譯階段就必須了解所有的函數或模塊執行所需要的檢測信息,它對函數的選擇是基於指向對象的指針(或者引用)的類型,

動態聯編:在程序執行的時候才進行這種關聯稱之為動態聯編,動態聯編對成員函數的選擇不是基於指針或者引用,而是基於對象類型,不同的對象類型將做出不同的編譯結果。C語言中,所有的聯編都是靜態聯編。C++中一般情況下聯編也是靜態聯編,但是一旦涉及到多態性和虛函數就必須使用動態聯編。

3、揭秘動態聯編

編譯器到底做了什么實現虛函數的動態聯編呢?事實上編譯器對每個包含虛函數的類創建了一個表(vtable),我們稱之為虛表。在vtable中,編譯器放置特定類的虛函數地址,在每個帶有虛函數的類中,編譯器秘密地置一指針,稱為vpointer(常縮寫為vptr),指向這個對象的vtable。通過基類指針做虛函數調用是(即多態調用),編譯器靜態地插入取得這個vptr,並在vtable表中查找函數地址的代碼,這樣就能調用正確的函數使動態聯編發生。為每個類設置vtable、初始化vptr、為虛函數調用插入代碼,所有這些都是自動發生的,多以我們不必擔心這些。利用虛函數,這個對象合適的函數就能被調用,哪怕在編譯器還不知道這個對象的特定類型的情況下。(《Thinking in C++》)

在任何類中不存在顯示的類型信息,可對象中必須存放類信息,否則類型不可能在運行時建立。那這個類信息時什么呢?我們來看下面幾個類:

 1 class A
 2 {
 3  public:
 4      void fun1() const{}
 5      int fun2() const{return a;}
 6  private:
 7      int a;
 8 };
 9 
10 class B
11 {
12  public:
13      virtual void fun1() const{}
14      int fun2() const{return a;}
15  private:
16      int a;
17 };
18 
19 class C
20 {
21  public:
22      virtual void fun1() const{}
23      virtual int fun2() const {return a;}
24  private:
25      int a;
26 };

以上三個類中:

A類沒有虛函數,sizeof(A) = 4,類A的長度就是其成員變量整型a的長度;

B類有一個虛函數,sizeof(B) = 8;

C類有兩個虛函數,sizeof(C) = 8;有一個虛函數和有兩個虛函數的類的長度沒有區別,其實它們的長度就是A的長度加一個void指針的長度,它反映出,如果有一個或多個虛函數,編譯器在這個結構中插入一個指針(vptr)。在B和C之間沒有區別。這是因為vptr指向一個存放地址的表,只需要一個指針,因為所有虛函數地址都包含在這個表中。

這個vptr就可以看作類的類型信息。

那我們來看看編譯器是怎么建立vptr指向的這個虛函數表的。先看下面兩個類:

 1 class Base
 2 {
 3  pubic:
 4      void bfun(){}
 5      virtual void vfun1(){}
 6      virtual int vfun2(){}
 7  private:
 8      int a;
 9 };
10 
11 class Derived : public Base
12 {
13  public:
14      void dfun(){}
15      virtual void vfun1(){}
16      virtual int vfun3(){}
17  private:
18      int b;
19 };

兩個類vptr指向的虛函數表(vtable)分別如下:

Base類                                                    Derived類

vptr——>|  &Base::vfun1  |                       vptr——>|  &Derived::vfun1  |

              |  &Base::vfun2  |                                     |  &Base::vfun2     | 

                                                                             |  &Base::vfun3     |

每當創建一個包含有虛函數的類或從包含有虛函數的類派生一個類時,編譯器就為這個類創建一個vtable(如上所示),在這個表中,編譯器放置了在這個類中或在它的基類中所有已聲明為virtual的函數的地址。如果在這個派生類中沒有對在基類中聲明為virtual的函數進行重新定義,編譯器就使用基類的這個虛函數地址(在Derived的vtable中,vfun2的入口就是這種情況。)然后編譯器在這個類中放置vptr。當使用簡單繼承時,對於每個對象只有一個vptr。vptr必須被初始化為指向相應的vtable,這在構造函數中發生。

一旦vptr被初始化為指向相應的vtable,對象就“知道”它自己是什么類型。但只有當虛函數被調用時這種自我認知才有用。

VPTR常常位於對象的開頭,編譯器能很容易地取到VPTR的值,從而確定VTABLE的位置。VPTR總指向VTABLE的開始地址,所有基類和它的子類的虛函數地址(子類自己定義的虛函數除外)在VTABLE中存儲的位置總是相同的,如上面Base類和Derived類的vtable中 vfun1和vfun2的地址總是按相同的順序存儲。編譯器知道vfun1位於vptr處,vfun2位於vptr+1處,因此在用基類指針調用虛函數時,編譯器首先獲取指針指向對象的類型信息(vptr),然后就去調用虛函數。如一個Base類指針pBase指向了一個Derived對象,那 pBase->vfun2()被編譯器翻譯為 vptr+1 的調用,因為虛函數vfun2的地址在vtable中位於索引為1的位置上。同理,pBase->vfun3()被編譯器翻譯為vptr+2的調用。這就是所謂的晚綁定。


我們來看一下虛函數調用的匯編代碼,以加深理解。

 1 void test(Base * pBase)
 2 {
 3        pBase->vfun2();
 4 };
 5 
 6 int main(int argc, char* argv[])
 7 {
 8        Derived td;
 9        test(&td);
10        return 0;
11 }

Derived td;編譯生成的匯編代碼如下:

mov DWORD PTR _td$[esp+24], OFFSET FLAT:??_7Derived@@6B@ ; Derived::`vftable'

由編譯器的注釋可知,此時PTR_td$[esp+24]中存儲的就是Derived類的vtable地址。

test(&td);編譯生成的匯編代碼如下:

lea eax, DWORD PTR _td$[esp+24]   
mov DWORD PTR __$EHRec$[esp+32], 0
push eax
call ?test@@YAXPAVbase@@@Z   ; test 

調用test函數時完成了如下工作:取對象td的地址,將其壓棧,然后調用test。

pBase—>vfun2();編譯生成的匯編代碼如下:

mov ecx, DWORD PTR _pBase$[esp-4]
mov eax, DWORD PTR [ecx]
jmp DWORD PTR [eax+4]

首先從棧中取出pBase指針指向的對象地址賦給ecx,然后取對象開頭的指針變量中的地址賦給eax,此時eax的值即為VPTR的值,也就是 vtable的地址。最后就是調用虛函數了,由於vfun2位於vtable的第二個位置,相當於 vptr+1,每個函數指針是4個字節長,所以最后的調用被編譯器翻譯為 jmp DWORD PTR [eax+4]。如果是調用pBase->vfun1(),這句就該被編譯為jmp DWORD PTR [eax]。


免責聲明!

本站轉載的文章為個人學習借鑒使用,本站對版權不負任何法律責任。如果侵犯了您的隱私權益,請聯系本站郵箱yoyou2525@163.com刪除。



 
粵ICP備18138465號   © 2018-2025 CODEPRJ.COM