1. 程式人生 > >lanbing598235681的專欄

lanbing598235681的專欄

http://bbs.chinaunix.net/archiver/?tid-1711337.html

理解虛擬函式( virtual function )的幾個關鍵點:

1.       理解早繫結(early binding)、晚繫結(late binding)。所謂early binding:On compile time,就能明確一個函式呼叫是對哪個物件的哪個成員函式進行的,即編譯時就曉得了確定的函式地址;所謂late binding:On compile time,對函式(虛擬函式)的呼叫被搞成了:pObj->_vptr->vtable[],從而導致不到runtime,完全不知道實際函式地址。直到程式執行時,執行到這裡,去vtable裡拿到函式地址,才曉得。其實,原理很簡單,只是單看這些名詞的話會覺得好像很magic一樣。
2.       理解虛擬函式賴以生存的底層機制:vptr + vtable。虛擬函式的執行時實現採用了VPTR/VTBL的形式,這項技術的基礎:
①編譯器在後臺為每個包含虛擬函式的類產生一個靜態函式指標陣列(虛擬函式表),在這個類或者它的基類中定義的每一個虛擬函式都有一個相應的函式指標。
②每個包含虛擬函式的類的每一個例項包含一個不可見的資料成員vptr(虛擬函式指標),這個指標被建構函式自動初始化,指向類的vtbl(虛擬函式表)
③當客戶呼叫虛擬函式的時候,編譯器產生程式碼反指向到vptr,索引到vtbl中,然後在指定的位置上找到函式指標,併發出調用。
參考下面轉載文章:
虛擬函式是在類中被宣告為virtual的成員函式,當編譯器看到通過指標或引用呼叫此類函式時,對其執行晚繫結,即通過指標(或引用)指向的類的型別資訊來決定該函式是哪個類的。通常此類指標或引用都宣告為基類的,它可以指向基類或派生類的物件。
多型指同一個方法根據其所屬的不同物件可以有不同的行為(根據自己理解,不知這麼說是否嚴謹)。
舉個例子說明虛擬函式、多型、早繫結和晚繫結:
  李氏兩兄妹(哥哥和妹妹)參加姓氏運動會(不同姓氏組隊參加),哥哥男子專案比賽,妹妹參加女子專案比賽,開幕式有一個參賽隊伍代表發言儀式,兄妹倆都想去露露臉,可只能一人去,最終他們決定到時抓鬮決定,而組委會也不反對,它才不關心是哥哥還是妹妹來發言,只要派一個姓李的來說兩句話就行。運動會如期舉行,妹妹抓鬮獲得代表李家發言的機會,哥哥參加了男子專案比賽,妹妹參加了女子專案比賽。比賽結果就不是我們關心的了。
現在讓我們來做個類比(只討論與運動會相關的話題):
(1)類的設計:
  李氏兄妹屬於李氏家族,李氏是基類(這裡還是抽象的純基類),李氏又派生出兩個子類(李氏男和李氏女),李氏男會所有男子專案的比賽(李氏男的成員函式),李氏女會所有女子專案的比賽(李氏女的成員函式)。姓李的人都會發言(基類虛擬函式),李氏男和李氏女繼承自李氏當然也會發言,只是男女說話聲音不一樣,內容也會又差異,給人感覺不同(李氏男和李氏女分別重新定義發言這個虛擬函式)。李氏兩兄妹就是李氏男和李氏女兩個類的實體。
(2)程式設計:
李氏兄妹填寫參賽報名表。
(3)編譯:
  李氏兄妹的參賽報名表被上交給組委會(編譯器),哥哥和妹妹分別參加男子和女子的比賽,組委會一看就明白了(早繫結),只是發言人選不明確,組委會看到報名表上寫的是“李家代表”(基類指標),組委會不能確定到底是誰,就做了個備註:如果是男的,就是哥哥李某某;如果是女的,就是妹妹李某某(晚繫結)。組委會做好其它準備工作後,就等運動會開始了(編譯完畢)。
(4)程式執行:
運動會開始了(程式開始執行),開幕式上我們聽到了李家妹妹的發言,如果是哥哥運氣好抓鬮勝出,我們將聽到哥哥的發言(多型)。然後就是看到兄妹倆參加比賽了。。。
但願這個比喻說清楚了虛擬函式、多型、早繫結和晚繫結的概念和它們之間的關係。再說一下,早繫結指編譯器在編譯期間即知道物件的具體型別並確定此物件呼叫成員函式的確切地址;而晚繫結是根據指標所指物件的型別資訊得到類的虛擬函式表指標進而確定呼叫成員函式的確切地址。
  
2、揭密晚繫結的祕密
編譯器到底做了什麼實現的虛擬函式的晚繫結呢?我們來探個究竟。
編譯器對每個包含虛擬函式的類建立一個表(稱為V TA B L E)。在V TA B L E中,編譯器放置特定類的虛擬函式地址。在每個帶有虛擬函式的類中,編譯器祕密地置一指標,稱為v p o i n t e r(縮寫為V P T R),指向這個物件的V TA B L E。通過基類指標做虛擬函式呼叫時(也就是做多型呼叫時),編譯器靜態地插入取得這個V P T R,並在V TA B L E表中查詢函式地址的程式碼,這樣就能呼叫正確的函式使晚捆綁發生。為每個類設定V TA B L E、初始化V P T R、為虛擬函式呼叫插入程式碼,所有這些都是自動發生的,所以我們不必擔心這些。利用虛擬函式,這個物件的合適的函式就能被呼叫,哪怕在編譯器還不知道這個物件的特定型別的情況下。(《C++程式設計思想》)
————這段話紅色加粗部分似乎有點問題,我個人的理解看後面的總結。
在任何類中不存在顯示的型別資訊,可物件中必須存放類資訊,否則型別不可能在執行時建立。那這個類資訊是什麼呢?我們來看下面幾個類:
class no_virtual
{
public:
     void fun1() const{}
     int  fun2() const { return a; }
private:
     int a;
}
class one_virtual
{
public:
     virtual void fun1() const{}
     int  fun2() const { return a; }
private:
     int a;
}
class two_virtual
{
public:
     virtual void fun1() const{}
     virtual int  fun2() const { return a; }
private:
     int a;
}
以上三個類中:
no_virtual沒有虛擬函式,sizeof(no_virtual)=4,類no_virtual的長度就是其成員變數整型a的長度;
one_virtual有一個虛擬函式,sizeof(one_virtual)=8;
two_virtual 有兩個虛擬函式,sizeof(two_virtual)=8; 有一個虛擬函式和兩個虛擬函式的類的長度沒有區別,其實它們的長度就是no_virtual的長度加一個void指標的長度,它反映出,如果有一個或多個虛擬函式,編譯器在這個結構中插入一個指標( V P T R)。在one_virtual 和 two_virtual之間沒有區別。這是因為V P T R指向一個存放地址的表,只需要一個指標,因為所有虛擬函式地址都包含在這個表中。
這個VPTR就可以看作類的型別資訊。
那我們來看看編譯器是怎麼建立VPTR指向的這個虛擬函式表的。先看下面兩個類:
class base
{
public:
     void bfun(){}
     virtual void vfun1(){}
     virtual int vfun2(){}
private:
     int a;
}
class derived : public base
{
public:
     void dfun(){}
     virtual void vfun1(){}
     virtual int vfun3(){}
private:
     int b;
}
兩個類VPTR指向的虛擬函式表(VTABLE)分別如下:
base類
                       ——————
VPTR——> |&base::vfun1 |
                       ——————
                  |&base::vfun2 |
                   ——————
      
derived類
                       ———————
VPTR——> |&derived::vfun1 |
                       ———————
                   |&base::vfun2    |
                   ———————
                   |&derived::vfun3 |
                    ———————
     
  每當建立一個包含有虛擬函式的類或從包含有虛擬函式的類派生一個類時,編譯器就為這個類建立一個VTABLE,如上圖所示。在這個表中,編譯器放置了在這個類中或在它的基類中所有已宣告為virtual的函式的地址。如果在這個派生類中沒有對在基類中宣告為virtual的函式進行重新定義,編譯器就使用基類的這個虛擬函式地址。(在derived的VTABLE中,vfun2的入口就是這種情況。)然後編譯器在這個類中放置VPTR。當使用簡單繼承時,對於每個物件只有一個VPTR。VPTR必須被初始化為指向相應的VTABLE,這在建構函式中發生。
一旦VPTR被初始化為指向相應的VTABLE,物件就"知道"它自己是什麼型別。但只有當虛擬函式被呼叫時這種自我認知才有用。
個人總結如下:
1、從包含虛擬函式的類派生一個類時,編譯器就為該類建立一個VTABLE。其每一個表項是該類的虛擬函式地址。
2、在定義該派生類物件時,先呼叫其基類的建構函式,然後再初始化VPTR,最後再呼叫派生類的建構函式(從二進位制的視野來看,所謂基類子類是一個大結構體,其中this指標開頭的四個位元組存放虛擬函式表頭指標。執行子類的建構函式的時候,首先呼叫基類建構函式,this指標作為引數,在基類建構函式中填入基類的vptr,然後回到子類的建構函式,填入子類的vptr,覆蓋基類填入的vptr。如此以來完成vptr的初始化。)
3、在實現動態繫結時,不能直接採用類物件,而一定要採用指標或者引用。因為採用類物件傳值方式,有臨時基類物件的產生,而採用指標,則是通過指標來訪問外部的派生類物件的VPTR來達到訪問派生類虛擬函式的結果。
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的呼叫。這就是所謂的晚繫結。
我們來看一下虛擬函式呼叫的彙編程式碼,以加深理解。
void test(base* pBase)
{
  pBase->vfun2();
}
int main(int argc, char* argv[])
{
  derived td;
  

  test(&td);
  
  return 0;
}
derived td;編譯生成的彙編程式碼如下:
  mov DWORD PTR _td$[esp+24], OFFSET FLAT:
[email protected]
@[email protected] ; 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 [email protected]@[email protected]@@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]。