關於虛函數的那些事兒

1、虛函數定義

在某基類中聲明爲virtual 並在一個或多個派生類中被從新定義的成員函數,用法格式爲:html

virtual 函數返回類型 函數名(參數表) {函數體};ios

虛函數是C++語言實現運行時多態的惟一手段,經過指向派生類的基類指針或引用,訪問派生類中同名覆蓋成員函數。程序員

舉個例子:編程

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; }

程序的輸出爲:數組

若是將上面的程序改寫以下:安全

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對象,而後再構造派生類對象。可是因爲程序2中的函數p()是非虛函數,B類對象對函數p()的調用在程序編譯階段就已經肯定了。因此,不論基類指針b最終指向的是基類對象仍是派生類對象,只要後面的對象調用的函數不是虛函數,那麼就直接調用基類A的p()。佈局

對於程序1咱們能夠得出:post

1)經過基類引用或指針調用基類中定義的函數時,並不知道執行的函數對象的確切類型,執行函數的對象多是基類類型,也多是派生類類型。性能

2)調用虛函數,則直到運行時才能肯定調用哪一個函數,運行的虛函數是引用所綁定的或指針所指向的對象所屬類型定義的版本。

2、虛函數的原理與本質

虛(virtual)函數的通常實現模型是:每個類(class)有一個虛表(virtual table),內含該class之中有做用的虛(virtual)函數的地址,而後每一個對象有一個vptr(virtual table pointer,虛函數表指針),vptr 指向一個被稱爲vtbl(virtual table,虛函數表)的函數指針數組,每個包含虛函數的類都關聯到 vtbl。當一個對象調用了虛函數,實際的被調用函數經過下面的步驟肯定:找到對象的 vptr 指向的 vtbl,而後在 vtbl 中尋找合適的函數指針。

虛函數的地址取決於對象的內存地址,而不是取決於數據類型(對於非虛擬函數的調用,編譯器只根據數據類型翻譯函數地址,判斷調用的合法性。由於對象的內存地址空間中只包含成員變量,並不存儲有關成員函數的信息,因此非虛擬函數的地址翻譯過程與其對象的內存地址無關)。若是類定義了虛函數,該類及其派生類就要生成一張虛擬函數表,即vtable。而在類的對象地址空間中存儲一個該虛表的入口,佔4個字節,這個入口地址是在構造對象時由編譯器寫入的。因此,因爲對象的內存空間包含了虛表入口,編譯器可以由這個入口找到恰當的虛函數,這個函數的地址再也不由數據類型決定了。故對於一個父類的對象指針,調用虛擬函數,若是給他賦父類對象的指針,那麼他就調用父類中的函數,若是給他賦子類對象的指針,他就調用子類中的函數(取決於對象的內存地址)。

例以下面的例子:

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中存儲的內容以下:

~Point()
mult()
y()
z()

 

 

 

 

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()函數地址。

上述1)、2)、3)的狀況以下圖:

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

3、虛函數表

注意:只有發生繼承的時候且父類子類都有virtual的時候纔會出現虛函數指針,請不要忘了虛函數出現的目的是爲了實現多態。

一、通常繼承(無虛函數覆蓋)

class Base{ public: virtual void f(); virtual void g(); virtual void h(); }; class Derive :public Base{ public: virtual void f1(); virtual void g1(); virtual void h1(); };

下面,再讓咱們來看看繼承時的虛函數表是什麼樣的。假設有以下所示的一個繼承關係:

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

 對於實例:Derive d; 的虛函數表以下:

 咱們能夠看到下面幾點:
 1)虛函數按照其聲明順序放於表中。
 2)父類的虛函數在子類的虛函數前面。

二、通常繼承(有虛函數覆蓋)

下面,咱們來看一下,若是子類中有虛函數重載了父類的虛函數,會是一個什麼樣子?假設,咱們有下面這樣的一個繼承關係。

class Base{ public: virtual void f(); virtual void g(); virtual void h(); }; class Derive :public Base{ public: virtual void f(); virtual void g1(); virtual void h1(); };

爲了讓你們看到被繼承事後的效果,在這個類的設計中,我只覆蓋了父類的一個函數: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()

五、虛繼承

 這個是比較很差理解的,對於虛繼承,若派生類有本身的虛函數,則它自己須要有一個虛指針,指向本身的虛表。另外,派生類虛繼承父類時,首先要經過加入一個虛指針來指向父類,所以有可能會有兩個虛指針。

關於虛繼承的例子能夠參考 類4、(虛)繼承的內存佔用大小」中的示例二

4、(虛)繼承類的內存佔用大小

首先,平時所聲明的類只是一種類型定義,它自己是沒有大小可言的。 所以,若是用sizeof運算符對一個類型名操做,那獲得的是具備該類型實體的大小。

計算一個類對象的大小遵照的原則:

1)空類、單一繼承的空類、多重繼承的空類所佔空間大小爲1字節。(參考文章爲何C++中空類和空結構體大小爲1?

2)一個類中,虛函數、成員函數(靜態與非靜態)和靜態數據成員都不佔類對象的存儲空間。

3)當類中聲明瞭虛函數(不論是1個仍是多個),那麼在實例化對象時,編譯器會自動在對象裏安插一個指針vPtr指向虛函數表VTable。

4)虛承繼的狀況:因爲涉及到虛函數表和虛基表,會同時增長一個(多重虛繼承下對應多個)vfPtr指針指向虛函數表vfTable和一個vbPtr指針指向虛基表vbTable,這二者所佔的空間大小爲:8(或8乘以多繼承時父類的個數)。

5)在考慮以上內容所佔空間的大小時,還要注意編譯器下的「補齊」padding的影響,即編譯器會插入多餘的字節補齊。

6)類對象的大小=各非靜態數據成員(包括父類的非靜態數據成員但都不包括全部的成員函數)的總和+ vfptr指針(多繼承下可能不止一個)+vbptr指針(多繼承下可能不止一個)+編譯器額外增長的字節。

示例一:含有普通繼承

class A { }; class B { char ch; virtual void func0() { } }; class C { char ch1; char ch2; virtual void func() { } virtual void func1() { } }; class D: public A, public C { int d; virtual void func() { } virtual void func1() { } }; class E: public B, public C { int e; virtual void func0() { } virtual void func1() { } }; int main(void) { cout<<"A="<<sizeof(A)<<endl;    //result=1
    cout<<"B="<<sizeof(B)<<endl;    //result=8 
    cout<<"C="<<sizeof(C)<<endl;    //result=8
    cout<<"D="<<sizeof(D)<<endl;    //result=12
    cout<<"E="<<sizeof(E)<<endl;    //result=20
    return 0; }

執行結果:

前面三個A、B、C類的內存佔用空間大小就不須要解釋了,注意一下內存對齊就能夠理解了。
求sizeof(D)的時候,須要明白,首先VPTR指向的虛函數表中保存的是類D中的兩個虛函數的地址,而後存放基類C中的兩個數據成員ch一、ch2,注意內存對齊,而後存放數據成員d,這樣4+4+4=12。
求sizeof(E)的時候,首先是類B的虛函數地址,而後類B中的數據成員,再而後是類C的虛函數地址,而後類C中的數據成員,最後是類E中的數據成員e,一樣注意內存對齊,這樣4+4+4+4+4=20。

示例二:含有虛繼承

class A { public: virtual void aa() { } virtual void aa2() { } private: char ch[3]; }; class B: virtual public A { public: virtual void bb() { } virtual void bb2() { } }; int main(void) { cout<<"A's size is "<<sizeof(A)<<endl; cout<<"B's size is "<<sizeof(B)<<endl; return 0; }

執行結果:

對於虛繼承,類B由於有本身的虛函數,因此它自己有一個虛指針,指向本身的虛表。另外,類B虛繼承類A時,首先要經過加入一個虛指針來指向父類A,而後還要包含父類A的全部內容。所以是4+4+8=16。

5、安全性討論

在安全性方面,虛函數存在着一些漏洞。

一、經過父類型的指針訪問子類本身的虛函數

咱們知道,子類沒有重載父類的虛函數是一件毫無心義的事情。由於多態也是要基於函數重載的。
雖然在上面的圖中咱們能夠看到Base1的虛表中有Derive的虛函數,但咱們根本不可能使用下面的語句來調用子類的自有虛函數:

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

任何妄圖使用父類指針想調用子類中的未覆蓋父類的成員函數的行爲都會被編譯器視爲非法,即基類指針不能調用子類本身定義的成員函數。因此,這樣的程序根本沒法編譯經過。
但在運行時,咱們能夠經過指針的方式訪問虛函數表來達到違反C++語義的行爲。

二、訪問私有的或者受保護的的虛函數

若是父類的虛函數是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(); getchar(); }

執行結果:

  • (int*)(&d)取vptr地址,該地址存儲的是指向vtbl的指針
  • (int*)*(int*)(&d)取vtbl地址,該地址存儲的是虛函數表數組
  • (Fun)*((int*)*(int*)(&d) +0),取vtbl數組的第一個元素,即Base中第一個虛函數f的地址
  • (Fun)*((int*)*(int*)(&d) +1),取vtbl數組的第二個元素。

三、父類指針能夠訪問子類重載的私有的虛函數

class B { public: virtual void fun() { std::cout << "base fun called"; }; }; class D : public B { private: virtual void fun() { std::cout << "driver fun called"<<endl; }; }; int main(int argc, char* argv[]) { B* p = new D(); p->fun(); return 0; }

執行結果:

這是由於:在編譯虛擬函數調用的時候,例如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的,則編譯就會出錯。

四、毫不從新定義繼承而來的缺省參數值

class B { public: virtual void fun(int i = 1) { std::cout << "base fun called, " << i << endl; }; }; class D : public B { private: virtual void fun(int i = 2) { std::cout << "driver fun called, " << i << endl; }; }; int main(int argc, char* argv[]) { B* p = new D(); p->fun(); return 0; }

執行結果:

這是由於:「virtual 函數系動態綁定, 而缺省參數倒是靜態綁定」,也就是說在編譯的時候已經按照p的靜態類型處理其默認參數了,轉換成了(*p->vptr[1])(p, 1)這樣的方式。 

6、C++如何不用虛函數實現多態 

可使用函數指針來實現多態

#include<iostream>
using namespace std; typedef void (*fVoid)(); class A { public: static void test() { printf("hello A\n"); } fVoid print; A() { print = A::test; } }; class B : public A { public: static void test() { printf("hello B\n"); } B() { print = B::test; } }; int main(void) { A aa; aa.print(); B b; A* a = &b; a->print(); return 0; }

這樣作的好處主要是繞過了vtable。咱們都知道虛函數表有時候會帶來一些性能損失。

補充一下虛函數的缺點:虛函數最主要的缺點是執行效率較低,看一看虛擬函數引起的多態性的實現過程,就能體會到其中的緣由,另外就是因爲要攜帶額外的信息(VPTR),因此致使類多佔的內存空間也會比較大,對象也是同樣的。

7、參考

《C++ Primer Plus》

程序員編程藝術:第八章、從頭到尾漫談虛函數

C++中虛函數工做原理和(虛)繼承類的內存佔用大小計算

相關文章
相關標籤/搜索