1. 程式人生 > >C++的精髓——虛擬函式原理

C++的精髓——虛擬函式原理

轉自:http://blog.chinaunix.net/uid-26851094-id-3327323.html

虛(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所有情況的詳情,請參考下圖。

(圖:virtual table(虛表)的佈局:單一繼承情況)

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

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

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

本節全部內容來自淄博的共享,非常感謝。

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

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

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

我們可以看到下面幾點: 
1)虛擬函式按照其宣告順序放於表中。 
2)父類的虛擬函式在子類的虛擬函式前面。 
我相信聰明的你一定可以參考前面的那個程式,來編寫一段程式來驗證。

一般繼承(有虛擬函式覆蓋) 
覆蓋父類的虛擬函式是很顯然的事情,不然,虛擬函式就變得毫無意義。 
下面,我們來看一下,如果子類中有虛擬函式過載了父類的虛擬函式,會是一個什麼樣子?假設,我們有下面這樣的一個繼承關係。

為了讓大家看到被繼承過後的效果,在這個類的設計中,我只覆蓋了父類的一個函式:f()  。 
那麼,對於派生類的例項,其虛擬函式表會是下面的一個樣子: 
 
我們從表中可以看到下面幾點, 
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    
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、得到虛表的地址後,從虛表那知道你呼叫的那個函式的入口地址。根據虛表提供的你要找的函式的地址。並呼叫函式;你要知道,那個虛表是一個存放指標變數的陣列,並不是說,那個虛表中就是存放的虛擬函式的實體。