1. 程式人生 > >程式設計師程式設計藝術-----第八章-----從頭至尾漫談虛擬函式

程式設計師程式設計藝術-----第八章-----從頭至尾漫談虛擬函式

前奏

    有關虛擬函式的問題層出不窮,有關虛擬函式的文章千篇一律,那為何還要寫這一篇有關虛擬函式的文章呢?看完本文後,相信能懂其意義之所在。同時,原狂想曲系列已經更名為程式設計師程式設計藝術系列,因為不再只專注於“面試”,而在“程式設計”之上了。ok,如果有不正之處,望不吝賜教。謝謝。


第一節、一道簡單的虛擬函式的面試題
題目要求:寫出下面程式的執行結果?

  1. //謝謝董天喆提供的這道百度的面試題   
  2. #include <iostream>  
  3. using namespace std;  
  4. class A{  
  5.   public:virtual void p()   
  6.   {   
  7.     cout << "A"
     << endl;   
  8.   }  
  9. };  
  10. class B : public A  
  11. {  
  12.   public:virtual void p()   
  13.   { cout << "B" << endl;  
  14.   }  
  15. };  
  16. int main()   
  17. {  
  18.   A * a = new A;  
  19.   A * b = new B;  
  20.   a->p();  
  21.   b->p();  
  22.   delete a;  
  23.   delete b;      
  24.   return 0;  
  25. }  

    我想,這道面試題應該是考察虛擬函式相關知識的相對簡單的一道題目了。然後,希望你碰到此類有關虛擬函式的面試題,不論其難度是難是易,都能夠舉一反三,那麼本章的目的也就達到了。ok,請跟著我的思路,咱們步步深入(上面程式的輸出結果為A B)。

第二節、有無虛擬函式的區別
      1、當上述程式中的函式p()不是虛擬函式,那麼程式的執行結果是如何?即如下程式碼所示:

class A
{
public:
 void p() 
 { 
  cout << "A" << endl; 
 }
 
};

class B : public A
{
public:
 void p() 
 { 
  cout << "B" << endl;
 }
};

對的,程式此時將輸出兩個A,A。為什麼?
我們知道,在構造一個類的物件時,如果它有基類,那麼首先將構造基類的物件,然後才構造派生類自己的物件。如上,A* a=new A,呼叫預設建構函式構造基類A物件,然後呼叫函式p(),a->p();輸出A,這點沒有問題。
    然後,A * b = new B;,構造了派生類物件B,B由於是基類A的派生類物件,所以會先構造基類A物件,然後再構造派生類物件,但由於當程式中函式是非虛擬函式呼叫時,B類物件對函式p()的呼叫時在編譯時就已靜態確定了,所以,不論基類指標b最終指向的是基類物件還是派生類物件,只要後面的物件呼叫的函式不是虛擬函式,那麼就直接無視,而呼叫基類A的p()函式。

      2、那如果加上虛擬函式呢?即如最開始的那段程式那樣,程式的輸出結果,將是什麼?
在此之前,我們還得明確以下兩點:
    a、通過基類引用或指標呼叫基類中定義的函式時,我們並不知道執行函式的物件的確切型別,執行函式的物件可能是基類型別的,也可能是派生型別的。
    b、如果呼叫非虛擬函式,則無論實際物件是什麼型別,都執行基類型別所定義的函式(如上述第1點所述)。如果呼叫虛擬函式,則直到執行時才能確定呼叫哪個函式,執行的虛擬函式是引用所繫結的或指標所指向的物件所屬型別定義的版本。

根據上述b的觀點,我們知道,如果加上虛擬函式,如上面這道面試題,

class A
{
public:
 virtual void p() 
 { 
  cout << "A" << endl; 
 }
 
};

class B : public A
{
public:
 virtual void p() 
 { 
  cout << "B" << endl;
 }
};

int main() 
{
 A * a = new A;
 A * b = new B;
 a->p();
 b->p();
 delete a;
 delete b;
    return 0;
}

那麼程式的輸出結果將是A B。

所以,至此,咱們的這道面試題已經解決。但虛擬函式的問題,還沒有解決。


第三節、虛擬函式的原理與本質
    我們已經知道,虛(virtual)函式的一般實現模型是:每一個類(class)有一個虛表(virtual table),內含該class之中有作用的虛(virtual)函式的地址,然後每個物件有一個vptr,指向虛表(virtual table)的所在。

請允許我援引自深度探索c++物件模型一書上的一個例子:

class Point { 
public: 
   virtual ~Point();  

   virtual Point& mult( float ) = 0; 

   float x() const { return _x; }     //非虛擬函式,不作儲存
   virtual float y() const { return 0; }  
   virtual float z() const { return 0; }  
   // ...

protected: 
   Point( float x = 0.0 ); 
   float _x; 
};

      1、在Point的物件pt中,有兩個東西,一個是資料成員_x,一個是_vptr_Point。其中_vptr_Point指向著virtual table point,而virtual table(虛表)point中儲存著以下東西:

  • virtual ~Point()被賦值slot 1,
  • mult() 將被賦值slot 2.
  • y() is 將被賦值slot 3
  • z() 將被賦值slot 4.

class Point2d : public Point { 
public: 
   Point2d( float x = 0.0, float y = 0.0 )  
      : Point( x ), _y( y ) {} 
   ~Point2d();   //1

   //改寫base class virtual functions 
   Point2d& mult( float );  //2
   float y() const { return _y; }  //3

protected: 
   float _y; 
};

      2、在Point2d的物件pt2d中,有三個東西,首先是繼承自基類pt物件的資料成員_x,然後是pt2d物件本身的資料成員_y,最後是_vptr_Point。其中_vptr_Point指向著virtual table point2d。由於Point2d繼承自Point,所以在virtual table point2d中儲存著:改寫了的其中的~Point2d()、Point2d& mult( float )、float y() const,以及未被改寫的Point::z()函式。

class Point3d: public Point2d { 
public: 
   Point3d( float x = 0.0, 
            float y = 0.0, float z = 0.0 ) 
      : Point2d( x, y ), _z( z ) {} 
   ~Point3d();

   // overridden base class virtual functions 
   Point3d& mult( float ); 
   float z() const { return _z; }

   // ... other operations ... 
protected: 
   float _z; 
};

      3、在Point3d的物件pt3d中,則有四個東西,一個是_x,一個是_vptr_Point,一個是_y,一個是_z。其中_vptr_Point指向著virtual table point3d。由於point3d繼承自point2d,所以在virtual table point3d中儲存著:已經改寫了的point3d的~Point3d(),point3d::mult()的函式地址,和z()函式的地址,以及未被改寫的point2d的y()函式地址。

ok,上述1、2、3所有情況的詳情,請參考下圖。

本文,日後可能會酌情考慮增補有關內容。ok,更多,可參考深度探索c++物件模型一書第四章。
最近幾章難度都比較小,是考慮到狂想曲有深有淺的原則,後續章節會逐步恢復到相應難度。

第四節、虛擬函式的佈局與彙編層面的考察

第五節、虛擬函式表的詳解

    本節全部內容來自淄博的共享,非常感謝。注@molixiaogemao:只有發生繼承的時候且父類子類都有virtual的時候才會出現虛擬函式指標,請不要忘了虛函數出現的目的是為了實現多型
 

 一般繼承(無虛擬函式覆蓋)
 下面,再讓我們來看看繼承時的虛擬函式表是什麼樣的。假設有如下所示的一個繼承關係:

請注意,在這個繼承關係中,子類沒有過載任何父類的函式。那麼,在派生類的例項中,

 對於例項:Derive d; 的虛擬函式表如下:


我們從表中可以看到下面幾點,
 1)覆蓋的f()函式被放到了虛表中原來父類虛擬函式的位置。
 2)沒有被覆蓋的函式依舊。
 
 這樣,我們就可以看到對於下面這樣的程式,
 Base *b = new Derive();

b->f();

由b所指的記憶體中的虛擬函式表的f()的位置已經被Derive::f()函式地址所取代,
於是在實際呼叫發生時,是Derive::f()被呼叫了。這就實現了多型。


多重繼承(無虛擬函式覆蓋)

下面,再讓我們來看看多重繼承中的情況,假設有下面這樣一個類的繼承關係(注意:子類並沒有覆蓋父類的函式):


我們可以看到:
1) 每個父類都有自己的虛表。
2) 子類的成員函式被放到了第一個父類的表中。(所謂的第一個父類是按照宣告順序來判斷的)

這樣做就是為了解決不同的父類型別的指標指向同一個子類例項,而能夠呼叫到實際的函式。


多重繼承(有虛擬函式覆蓋)
下面我們再來看看,如果發生虛擬函式覆蓋的情況。
下圖中,我們在子類中覆蓋了父類的f()函式。


我們可以看見,三個父類虛擬函式表中的f()的位置被替換成了子類的函式指標。
這樣,我們就可以任一靜態型別的父類來指向子類,並呼叫子類的f()了。如:

Derive d;
Base1 *b1 = &d;
Base2 *b2 = &d;
Base3 *b3 = &d;
b1->f(); //Derive::f()
b2->f(); //Derive::f()
b3->f(); //Derive::f()
b1->g(); //Base1::g()
b2->g(); //Base2::g()
b3->g(); //Base3::g()

安全性
每次寫C++的文章,總免不了要批判一下C++。
這篇文章也不例外。通過上面的講述,相信我們對虛擬函式表有一個比較細緻的瞭解了。
水可載舟,亦可覆舟。下面,讓我們來看看我們可以用虛擬函式表來乾點什麼壞事吧。

一、通過父型別的指標訪問子類自己的虛擬函式
我們知道,子類沒有過載父類的虛擬函式是一件毫無意義的事情。因為多型也是要基於函式過載的。
雖然在上面的圖中我們可以看到Base1的虛表中有Derive的虛擬函式,但我們根本不可能使用下面的語句來呼叫子類的自有虛擬函式:

Base1 *b1 = new Derive();
b1->g1(); //編譯出錯

任何妄圖使用父類指標想呼叫子類中的未覆蓋父類的成員函式的行為都會被編譯器視為非法,即基類指標不能呼叫子類自己定義的成員函式。所以,這樣的程式根本無法編譯通過。
但在執行時,我們可以通過指標的方式訪問虛擬函式表來達到違反C++語義的行為。
(關於這方面的嘗試,通過閱讀後面附錄的程式碼,相信你可以做到這一點)

二、訪問non-public的虛擬函式
另外,如果父類的虛擬函式是private或是protected的,但這些非public的虛擬函式同樣會存在於虛擬函式表中,
所以,我們同樣可以使用訪問虛擬函式表的方式來訪問這些non-public的虛擬函式,這是很容易做到的。
如:

class Base {
private: 
 virtual void f() { cout << "Base::f" << endl; } 
};

class Derive : public Base{ 
};
typedef void(*Fun)(void);
void main() {
 Derive d;
 Fun pFun = (Fun)*((int*)*(int*)(&d)+0);
 pFun(); 
}

對上面粗體部分的解釋(@a && x):

1. (int*)(&d)取vptr地址,該地址儲存的是指向vtbl的指標
2. (int*)*(int*)(&d)取vtbl地址,該地址儲存的是虛擬函式表陣列
3. (Fun)*((int*)*(int*)(&d) +0),取vtbl陣列的第一個元素,即Base中第一個虛擬函式f的地址
4. (Fun)*((int*)*(int*)(&d) +1),取vtbl陣列的第二個元素(這第4點,如下圖所示)。

下圖也能很清晰的說明一些東西(@5):


ok,再來看一個問題,如果一個子類過載的虛擬函式為privete,那麼通過父類的指標可以訪問到它嗎?

#include <IOSTREAM>   
class B   
{    
public:    
    virtual void fun()      
    {     
        std::cout << "base fun called";     
    };    
};  

class D : public B    
{    
private:   
    virtual void fun()      
    {     
        std::cout << "driver fun called";    
    };    
};  

int main(int argc, char* argv[])   
{       
    B* p = new D();    
    p->fun();    
    return 0;    
}  

執行時會輸出 driver fun called

從這個實驗,可以更深入的瞭解虛擬函式編譯時的一些特徵:
在編譯虛擬函式呼叫的時候,例如p->fun(); 只是按其靜態型別來處理的, 在這裡p的型別就是B,不會考慮其實際指向的型別(動態型別)。

    也就是說,碰到p->fun();編譯器就當作呼叫B的fun來進行相應的檢查和處理。
因為在B裡fun是public的,所以這裡在“訪問控制檢查”這一關就完全可以通過了。
然後就會轉換成(*p->vptr[1])(p)這樣的方式處理, p實際指向的動態型別是D,
    所以p作為引數傳給fun後(類的非靜態成員函式都會編譯加一個指標引數,指向呼叫該函式的物件,我們平常用的this就是該指標的值), 實際執行時p->vptr[1]則獲取到的是D::fun()的地址,也就呼叫了該函式, 這也就是動態執行的機理。


為了進一步的實驗,可以將B裡的fun改為private的,D裡的改為public的,則編譯就會出錯。
C++的注意條款中有一條" 絕不重新定義繼承而來的預設引數值" 
(Effective C++ Item37, never redefine a function's inherited default parameter value) 也是同樣的道理。

可以再做個實驗
class B   
{    
public:   
    virtual void fun(int i = 1)      
    {     
        std::cout << "base fun called, " << i;     
    };    
};  

class D : public B    
{    
private:    
    virtual void fun(int i = 2)      
    {     
        std::cout << "driver fun called, " << i;     
    };    
}; 

則執行會輸出driver fun called, 1

關於這一點,Effective上講的很清楚“virtual 函式系動態繫結, 而預設引數卻是靜態繫結”,
也就是說在編譯的時候已經按照p的靜態型別處理其預設引數了,轉換成了(*p->vptr[1])(p, 1)這樣的方式。

補遺

   一個類如果有虛擬函式,不管是幾個虛擬函式,都會為這個類宣告一個虛擬函式表,這個虛表是一個含有虛擬函式的類的,不是說是類物件的。一個含有虛擬函式的類,不管有多少個數據成員,每個物件例項都有一個虛指標,在記憶體中,存放每個類物件的記憶體區,在記憶體區的頭部都是先存放這個指標變數的(準確的說,應該是:視編譯器具體情況而定),從第n(n視實際情況而定)個位元組才是這個物件自己的東西。

下面再說下通過基類指標,呼叫虛擬函式所發生的一切:
One *p;
p->disp();

1、上來要取得類的虛表的指標,就是要得到,虛表的地址。存放類物件的記憶體區的前四個位元組其實就是用來存放虛表的地址的。
2、得到虛表的地址後,從虛表那知道你呼叫的那個函式的入口地址。根據虛表提供的你要找的函式的地址。並呼叫函式;你要知道,那個虛表是一個存放指標變數的陣列,並不是說,那個虛表中就是存放的虛擬函式的實體。

本章完。