C ++ 虛擬函式 (轉)
第一節、一道簡單的虛擬函式的面試題
題目要求:寫出下面程式的執行結果?
1、當上述程式中的函式p()不是虛擬函式,那麼程式的執行結果是如何?即如下程式碼所示:
class A
{
public:
void p()
{
cout << “A” << endl;
}
};
class B : public A
{
public:
void p()
{
cout << “B” << endl;
}
};
沒有虛擬函式,就不是多型了,虛擬函式是執行時多型的基礎(函式過載和運算子過載是 編譯時多型的基礎)
我們知道,在構造一個類的物件時,如果它有基類,那麼首先將構造基類的物件,然後才構造派生類自己的物件。如上,A* a=new A,呼叫預設建構函式構造基類A物件,然後呼叫函式p(),a->p();輸出A,這點沒有問題。
然後,A * b = new B;,構造了派生類物件B,B由於是基類A的派生類物件,所以會先構造基類A物件,然後再構造派生類物件,但由於當程式中函式是非虛擬函式呼叫時,B類物件對函式p()的呼叫時在編譯時就已靜態確定了,所以,不論基類指標b最終指向的是基類物件還是派生類物件,只要後面的物件呼叫的函式不是虛擬函式,那麼就直接無視,而呼叫基類A的p()函式。
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)的所在。
每一個類有一個虛表,每一個類的物件有一個指向虛表的指標vptr
請允許我援引自深度探索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++物件模型一書第四章。
最近幾章難度都比較小,是考慮到狂想曲有深有淺的原則,後續章節會逐步恢復到相應難度。
第四節、虛擬函式的佈局與彙編層面的考察
一道試題:
#include <iostream>
using namespace std;
class Base
{
public:
int m_base;
virtual void f() { cout << "Base::f" << endl; }
virtual void g() { cout << "Base::g" << endl; }
};
class Derive : public Base
{
int m_derived;
};
typedef void(*Fun)(void);
void main()
{
Base *d = new Derive;
Fun pFun = (Fun)*((int*)*(int*)(d)+0);
printf("&(Base::f): 0x%x /n", &(Base::f));
printf("&(Base::g):0x%x /n", &(Base::g));
printf("pFun: 0x%x /n", pFun);
pFun();
}
在列印的時候發現pFun的地址和 &(Base::f)的地址竟然不一樣太奇述的佈局如下怪了?經過一番深入研究,終於把這個問題弄明白了。下面就來一步步進行剖析。
根據VC的虛擬函式的佈局機制,上述的佈局如下:
然後我們再細細的分析第一種方式:
Fun pFun = (Fun)*((int*)*(int*)(d)+0);
d是一個類物件的地址。而在32位機上指標的大小是4位元組,因此(int)(&d)取得的是vfptr,即虛表的地址。從而((int)(int)(&d)+0)是虛表的第1項,也就是Base::f()的地址。事實上我們得到了驗證,程式執行結果如下:
這說明虛表的第一項確實是虛擬函式的地址,上面的VC虛擬函式的佈局也確實木有問題。
但是,接下來就引發了一個問題,為什麼&(Base::F)和PFun的值會不一樣呢?既然PFun的值是虛擬函式f的地址,那&(Base::f)又是什麼呢?帶著這個問題,我們進行了反彙編。
printf(“&(Base::f): 0x%x /n”, &(Base::f));
00401068 mov edi,dword ptr [__imp__printf (4020D4h)]
0040106E push offset Base::`vcall'{0}' (4013A0h)
00401073 push offset string "&(Base::f): 0x%x /n" (40214Ch)
00401078 call edi
printf(“&(Base::g): 0x%x /n”, &(Base::g));
0040107A push offset Base::`vcall'{4}' (4013B0h)
0040107F push offset string "&(Base::g): 0x%x /n" (402160h)
00401084 call edi
那麼從上面我們可以清楚的看到:
Base::f 對應於Base::`vcall'{0}' (4013A0h)
Base::g對應於Base::`vcall'{4}' (4013B0h)
那麼Base::vcall'{0}'和Base::
vcall’{4}’到底是什麼呢,繼續進行反彙編分析
Base::`vcall’{0}’:
004013A0 mov eax,dword ptr [ecx]
004013A2 jmp dword ptr [eax]
......
Base::`vcall’{4}’:
004013B0 mov eax,dword ptr [ecx]
004013B2 jmp dword ptr [eax+4]
第一句中, 由於ecx是this指標, 而在VC中一般虛表指標是類的第一個成員, 所以它是把vfptr, 也就是虛表的地址存到了eax中. 第二句
相當於取了虛表的某一項。對於Base::f跳轉到Base::vcall'{0}',取了虛表的第1項;對於Base::g跳轉到Base::
vcall’{4}’,取了虛表第2項。由此都能夠正確的獲得虛擬函式的地址。
由此我們可以看出,vc對此的解決方法是由編譯器加入了一系列的內部函式"vcall". 一個類中的每個虛擬函式都有一個唯一與之對應的vcall函式,通過特定的vcall函式跳轉到虛擬函式表中特定的表項。
更深一步的進行討論,考慮多型的情況,將程式碼改寫如下:
#include <iostream>
using namespace std;
class Base
{
public:
virtual void f() { cout << "Base::f" << endl; }
virtual void g() { cout << "Base::g" << endl; }
};
class Derive : public Base{
public:
virtual void f() { cout << "Derive::f" << endl; }
virtual void g() { cout << "Derive::g" << endl; }
};
typedef void(*Fun)(void);
void main()
{
Base *d = new Derive;
Fun pFun = (Fun)*((int*)*(int*)(d)+0);
printf("&(Base::f): 0x%x /n", &(Base::f));
printf("&(Base::g): 0x%x /n", &(Base::g));
printf("&(Derive::f): 0x%x /n", &(Derive::f));
printf("&(Derive::g): 0x%x /n", &(Derive::g));
printf("pFun: 0x%x /n", pFun);
pFun();
}
列印的時候表現出來了多型的性質:
分析可知原因如下:
這是因為類Derive的虛擬函式表的各項對應的值進行了改寫(rewritting),原來指向Based::f()的地址變成了指向Derive::f(),原來指向Based::g()的地址現在編變成了指向Derive::g()。
反彙編程式碼如下:
printf(“&(Derive::f): 0x%x /n”, &(Derive::f));
00401086 push offset Base::`vcall'{0}' (4013B0h)
0040108B push offset string "&(Derive::f): 0x%x /n" (40217Ch)
00401090 call esi
printf(“&(Derive::g): 0x%x /n”, &(Derive::g));
00401092 push offset Base::`vcall'{4}' (4013C0h)
00401097 push offset string "&(Derive::g): 0x%x /n" (402194h)
0040109C call esi
因此雖然此時Derive::f依然對應Base::`vcall'{0}',而 Derive::g依然對應Base::`vcall'{4}',但是由於每個類有一個虛擬函式表,因此跳轉到的虛表的位置也發生了改變,同時因為進行了改寫,虛表中的每個slot項的值也不一樣。
稍微總結一下:
在VC中有兩種方法呼叫虛擬函式,一種是通過虛表,另外一種是通過vcall thunk的方式
通過虛表的方式:
base *d = new Derive;
d->f();
004115FA mov eax,dword ptr [d]
004115FD mov edx,dword ptr [eax]
004115FF mov esi,esp
00411601 mov ecx,dword ptr [d]
00411604 mov eax,dword ptr [edx]
00411606 call eax
00411608 cmp esi,esp
0041160A call @ILT+470(__RTC_CheckEsp) (4111DBh)
這種方式的應用環境是通過類物件的指標或引用來呼叫虛擬函式
通過vcall thunk的方式:
typedef void (Base::* func1)( void );
base *d = new Derive;
func1 pFun1 = &Base::f;
(d->*pFun1)();
004115A9 mov dword ptr [pFun1],offset Base::`vcall'{0}' (4110C3h)
004115B0 mov esi,esp
004115B2 lea ecx,[d]
004115B5 call dword ptr [pFun1]
004115B8 cmp esi,esp
004115BA call @ILT+460(__RTC_CheckEsp) (4111D1h)
這種方式對應的應用環境是通過類成員函式的指標來呼叫虛擬函式
4.2 透視C++物件模型
一、c++物件模型
在C++中,有兩種類資料成員:靜態和非靜態,以及三種類成員函式:靜態、非靜態和虛擬。比如下面的class Point的宣告:
class Point
pulic:
Point (float xval)
virtual ~point();
float x() const;
static int PointCount();
protected:
virtual ostream& print (ostream &os) const;
float _x;
static int _point_count;
}
C++物件模型對記憶體空間和存取時間做了優化。在此模型中,在cfront2.0編譯器中,非靜態資料成員配置於每一個類物件內;靜態資料成員則存放於所有類物件之外(比如存放在data segment中);虛擬函式則分兩個步驟來進行支援:
每一個類 產生一個虛表,除第1個slot指向類的type_info外,虛表中的每個slot為一個虛擬函式的指標。
每一個類物件新增一個指標資料成員vptr,指向相關的虛表
以上面的Point類為例,物件模型如下:
二、含有虛基類的多重繼承
值得注意的是,虛表已經成為一種公認的方法支援虛擬函式,而對於虛基類的支援不同編譯器有自己的方式:在VC在每個類中增加了一個虛基類表,同時在新增一個指標資料成員指向這個虛基類表;而有些編譯器如cfront喜歡在虛表中放置虛基類的offset。
比如Point2d,Point3d類的宣告:
class Point2d
{
public:
…
void operator += (const Point2d& rhs)
{
_x += rhs.x();
_y += rhs.y();
}
protected:
float _x, _y;
}
class Point3d: public virtual Point2d
{
public:
…
void operator += (const Point3d& rhs)
{
Point2d::operator +=( rhs)
_z += rhs.z();
}
protected:
float _z
}
在cfront中的佈局如下:
採用上述佈局,則在cfront實現模型之下,因為類的資料成員(比如基類,此例中派生類的資料成員_z的offset沒變)operator運算子必須轉換成如下形式:
//虛擬c++碼
(this + _vptr_Point3d[-1])->_x += (&rhs + rhs._vptr_Point3d[-1])->_x ;
(this + _vptr_Point3d[-1])->_y += (&rhs + rhs._vptr_Point3d[-1])->_y ;
_z += rhs._z
三、物件的大小
在實際計算類的大小的過程中,可能和我們想象的不同,主要可能是以下兩個方面造成的:
因為需要支援虛擬函式、虛基類所增加的指標資料成員(比如虛表指標vptr、虛基類指標bptr。在32位機器上,指標的大小均為4個位元組)。
位元組對齊。每個類按照資料成員大小的最大值進行位元組對齊。
按這種計算方法,使用cfront的物件實現模型:Point2d物件的大小為4+4+4=12位元組,Point3d物件的位元組為16位元組。
第五節、虛擬函式表的詳解
本節全部內容來自淄博的共享,非常感謝。
一般繼承(無虛擬函式覆蓋)
下面,再讓我們來看看繼承時的虛擬函式表是什麼樣的。假設有如下所示的一個繼承關係:
請注意,在這個繼承關係中,子類沒有過載任何父類的函式。那麼,在派生類的例項中,
對於例項: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):
- (int*)(&d)取vptr地址,該地址儲存的是指向vtbl的指標
- (int*)(int)(&d)取vtbl地址,該地址儲存的是虛擬函式表陣列
- (Fun)((int)(int)(&d) +0),取vtbl陣列的第一個元素,即Base中第一個虛擬函式f的地址
- (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、得到虛表的地址後,從虛表那知道你呼叫的那個函式的入口地址。根據虛表提供的你要找的函式的地址。並呼叫函式;你要知道,那個虛表是一個存放指標變數的陣列,並不是說,那個虛表中就是存放的虛擬函式的實體。
轉自: