天天看點

深入研究虛函數和vtable

轉自:http://news.dayoo.com/tech/201005/21/10000617_102078537.htm

  在面向對象的C++語言中,虛函數是一個非常重要的概念。因為它充分展現了面向對象思想中的繼承和多态性這兩大特性,在C++語言裡應用極廣。比如在微軟的MFC類庫中,你會發現很多函數都有virtual關鍵字,也就是說,它們都是虛函數。難怪有人甚至稱虛函數是C++語言的精髓。

        那麼,什麼是虛函數呢,我們先來看看微軟的解釋:

        虛函數是指一個類中你希望重載的成員函數,當你用一個基類指針或引用指向一個繼承類對象的時候,你調用一個虛函數,實際調用的是繼承類的版本。

                                                               ——摘自MSDN

        這個定義說得不是很明白。MSDN中還給出了一個例子,但是它的例子也并不能很好的說明問題。我們自己編寫這樣一個例子:

#include "stdio.h"

#include "conio.h"

class Parent

{

public:

  char data[20];

  virtual void Function2;   // 這裡聲明是虛函數

}parent;

void Parent::Function1

{

}

void Parent::Function2

{

}

class Child:public Parent

{

} child;

{

}

{

}

int main(int argc, char* argv)

{

  Parent *p;  // 定義一個基類指針

  if(_getch=='c')     // 如果輸入一個小寫字母c

       p=&child;         // 指向繼承類對象

  else

       p=&parent;      // 否則指向基類對象

   // 這裡在編譯時會直接給出的

                                   入口位址。

      // 注意這裡,執行的是哪一個?

  return 0;

}

        用任意版本的Visual C++或Borland C++編譯并運作,輸入一個小寫字母c,得到下面的結果:

This is parent,function1

This is child,function2

      為什麼會有第一行的結果呢?因為我們是用一個Parent類的指針調用函數Fuction1,雖然實際上這個指針指向的是Child類的對象,但編譯器無法知道這一事實(直到運作的時候,程式才可以根據使用者的輸入判斷出指針指向的對象),它隻能按照調用Parent類的函數來了解并編譯,是以我們看到了第一行的結果。

        那麼第二行的結果又是怎麼回事呢?我們注意到,函數在基類中被virtual關鍵字修飾,也就是說,它是一個虛函數。虛函數最關鍵的特點是“動态聯編”,它可以在運作時判斷指針指向的對象,并自動調用相應的函數。如果我們在運作上面的程式時任意輸入一個非c的字元,結果如下:

This is parent,function1

This is parent,function2

        請注意看第二行,它的結果出現了變化。程式中僅僅調用了一個函數,卻可以根據使用者的輸入自動決定到底調用基類中的還是繼承類中的,這就是虛函數的作用。我們知道,在MFC中,很多類都是需要你繼承的,它們的成員函數很多都要重載,比如編寫MFC應用程式最常用的CView::OnDraw(CDC*)函數,就必須重載使用。把它定義為虛函數(實際上,在MFC中OnDraw不僅是虛函數,還是純虛函數),可以保證時刻調用的是使用者自己編寫的OnDraw。虛函數的重要用途在這裡可見一斑。

      在了解虛函數的基礎之上,我們考慮這樣的問題:一個基類指針必須知道它所指向的對象是基類還是繼承類的示例,才能在調用虛函數時“自動”決定應該調用哪個版本,它是如何知道的?有些講C++的書上提到,這種“動态聯編”的機制是通過一個“vtable”實作的,vtable是什麼?微軟在關于COM的文檔裡這樣描述:

        vtable是指一張函數指針表,如同C++中類的實作一樣,vtable中的指針指向一個對象支援的接口成員函數。       

                                                               ——摘自MSDN

        很遺憾,微軟這次還是沒有把問題說清楚,當然,上面的文檔本來就是關于COM的,與我們關心的問題不同。

        那麼vtable是什麼?我們先來看看下面的實驗:

        那麼這多出來的四個位元組究竟起到了什麼作用?

        用Visual C++打開前面的示例程式,在main函數中一句前面按F9設斷點,按F5開始調試,輸入一個小寫c,程式停到了我們設的斷點上。找到Debug工具條,按Disassembly按鈕,如圖所示:

45:          

004012CA   mov      eax,dword ptr [ebp-4]

// eax就是我們的p指針

004012CD   mov      edx,dword ptr [eax]

// edx取child對象頭部四個位元組

004012CF   mov       esi,esp

004012D1   mov       ecx,dword ptr [ebp-4]

// 可能要檢查棧,不管它

004012D4   call        dword ptr [edx]

// 注意這裡,調用了child對象頭部的一個函數指針

004012D6   cmp       esi,esp

004012D8   call         __chkesp (004013b0)

這裡最關鍵的一句是call dword ptr[edx],edx是child對象頭部,前面我們分析過了,child對象共有24位元組,其中成員變量占用20位元組,還有4個位元組作用未知。現在從這段彙編代碼上看,那4個位元組很可能就是child對象開頭的這個函數指針,因為編譯器并不知道我們的成員變量data是做什麼用的,更不可能把data的任何一部分當成一個函數指針來處理。

那麼這個函數指針會跳轉到那裡去呢?我們按F10單步運作到這個call指令,然後按F11跟進去:

00401032   jmp        

00401037   jmp         Parent::Parent (004010d0)

    →  0040103C   jmp        

00401041   jmp         Child::Child (004011c0)

這并不是最終的結論,我們看看40103C周圍的幾行代碼,連續幾行全都是jmp指令,這是什麼程式結構?有彙編語言程式設計經驗的朋友可能會想起來了,這是一張入口表,分别存放着到幾個重要函數的跳轉指令!我們再回去看看微軟對于vtable的描述:vtable是指一張函數指針表,(如同C++中類的實作一樣,)vtable中的指針指向(一個對象支援的接口)成員函數。打括号的字不要看,這句話的主幹就是:vtable是一張函數指針表,指向成員函數。種種事實證明,上面的四行代碼就是我們要找的這個vtable!

現在我們應該對虛函數的原理有一個認識了。每個虛函數都在vtable中占了一個表項,儲存着一條跳轉到它的入口位址的指令(實際上就是儲存了它的入口位址)。當一個包含虛函數的對象(注意,不是對象的指針)被建立的時候,它在頭部附加一個指針,指向vtable中相應位置。調用虛函數的時候,不管你是用什麼指針調用的,它先根據vtable找到入口位址再執行,進而實作了“動态聯編”。而不像普通函數那樣簡單地跳轉到一個固定位址。

以上結論僅僅是針對Visual C++ 6.0編譯器而言的,對于其他編譯器,具體實作并不完全相同,但都大同小異。著名的“綠色兵團”雜志上撰文介紹,Linux平台上的GNU C++編譯器就把指向vtable的指針放在對象尾部而不是頭部,而且vtable中僅僅存放虛函數的入口位址,而不是跳轉到虛函數的指令。具體的一些細節,篇幅所限,我們這裡不再讨論,希望有興趣的朋友能繼續研究。

繼續閱讀