C++之繼承與多態

轉自:http://www.cnblogs.com/kunhu/p/3631285.html

在程序設計領域,一個廣泛認可的定義是“一種將不同的特殊行爲和單個泛化記號相關聯的能力”。和純粹的面向對象程序設計語言不同,C++中的多態有着更廣泛的含義。除了常見的通過類繼承和虛函數機制生效於運行期的動態多態(dynamic polymorphism)外,帶變量的宏,模板,函數重載,運算符重載,拷貝構造等也允許將不同的特殊行爲和單個泛化記號相關聯,由於這種關聯處理於編譯期而非運行期,因此被稱爲靜態多態(static polymorphism)。

靜態多態性

1、 函數重載與缺省參數

(1)函數重載的實現原理

假設,我們現在想要寫一個函數(如Exp01),它即可以計算整型數據又可以計算浮點數,那樣我們就得寫兩個求和函數,對於更復雜的情況,我們可能需要寫更多的函數,但是這個函數名該怎麼起呢?它們本身實現的功能都差不多,只是針對不同的參數:

int sum_int(int nNum1, int nNum2)

{

    return nNum1 + nNum2;

}

 

double sum_float(float nNum1, float nNum2)

{

    return nNum1 + nNum2;

}

 

C++中爲了簡化,就引入了函數重載的概念,大致要求如下:

1、    重載的函數必須有相同的函數名

2、    重載的函數不可以擁有相同的參數

 

2、 運算符重載

運算符重載也是C++多態性的基本體現,在我們日常的編碼過程中,我們經常進行+、—、*、/等操作。在C++中,要想讓我們定義的類對象也支持這些操作,以簡化我們的代碼。這就用到了運算符重載。

 

比如,我們要讓一個日期對象減去另一個日期對象以便得到他們之間的時間差。再如:我們要讓一個字符串通過“+”來連接另一個字符串……

 

要想實現運算符重載,我們一般用到operator關鍵字,具體用法如下:

返回值  operator 運算符(參數列表)

{

         // code

}

例如:

CMyString Operator +(CMyString & csStr)

{

int nTmpLen = strlen(msString.GetData());

if (m_nSpace <= m_nLen+nTmpLen)

{

char *tmpp = new char[m_nLen+nTmpLen+sizeof(char)*2];

strcpy(tmpp, m_szBuffer);

strcat(tmpp, msString.GetData());

delete[] m_szBuffer;

m_szBuffer = tmpp;

}

}

 

這樣,我們的函數就可以寫成:

int sum (int nNum1, int nNum2)

{

    return nNum1 + nNum2;

}

 

double sum (float nNum1, float nNum2)

{

    return nNum1 + nNum2;

}

到現在,我們可以考慮一下,它們既然擁有相同的函數名,那他們怎麼區分各個函數的呢?

那就是通過C++名字改編(C++名字粉碎),,對於重載的多個函數來說,其函數名都是一樣的,爲了加以區分,在編譯連接時,C++會按照自己的規則篡改函數名字,這一過程爲"名字改編".有的書中也稱爲"名字粉碎".不同的C++編譯器會採用不同的規則進行名字改編,例如以上的重載函數在VC6.0下可能會被重命sum_int@@YAHHH@Z和sum_float@@YAMMM@Z這樣方便連接器在鏈接時正常的識別和找到正確的函數。

(2)缺省參數

無論是Win系統下的API,還是Linux下的很多系統庫,它們的好多的函數存在許多參數,而且大部分都是NULL,倘若我們有個函數大部分的時候,某個參數都是固定值,僅有的時候需要改變一下,而我們每次調用它時都要很費勁的輸入參數豈不是很痛苦?C++提供了一個給參數加默認參數的功能,例如:

double sum (float nNum1, float nNum2 = 10);

 

我們調用時,默認情況下,我們只需要給它第一個參數傳遞參數即可,但是使用這個功能時需要注意一些事項,以免出現莫名其妙的錯誤,下面我簡單的列舉一下大家瞭解就好。

A、 默認參數只要寫在函數聲明中即可。

B、 默認參數應儘量靠近函數參數列表的最右邊,以防止二義性。比如

double sum (float nNum2 = 10,float nNum1);

這樣的函數聲明,我們調用時:sum(15);程序就有可能無法匹配正確的函數而出現編譯錯誤。

3.宏多態

帶變量的宏可以實現一種初級形式的靜態多態: 
// macro_poly.cpp

#include <iostream>
#include <string>

// 定義泛化記號:宏ADD
#define ADD(A, B) (A) + (B);

int main()
{
    int i1(1), i2(2);
    std::string s1("Hello, "), s2("world!");
    int i = ADD(i1, i2);                        // 兩個整數相加
    std::string s = ADD(s1, s2);                // 兩個字符串“相加”
    std::cout << "i = " << i << "\n";
    std::cout << "s = " << s << "\n";
}
當程序被編譯時,表達式ADD(i1, i2)和ADD(s1, s2)分別被替換爲兩個整數相加和兩個字符串相加的具體表達式。整數相加體現爲求和,而字符串相加則體現爲連接(注:string.h庫已經重載了。程序的輸出結果符合直覺:
1 + 2 = 3
Hello, + world! = Hello, world! 

4.類中的早期綁定

先看以下的代碼:

按 Ctrl+C 複製代碼
按 Ctrl+C 複製代碼

答案是輸出:animal breathe

從編譯的角度
C++編譯器在編譯的時候,要確定每個對象調用的函數的地址,這稱爲早期綁定(early binding),當我們將fish類的對象fh的地址賦給pAn時,C++編譯器進行了類型轉換,此時C++編譯器認爲變量pAn保存的就是animal對象的地址。當在main()函數中執行pAn->breathe()時,調用的當然就是animal對象的breathe函數。

內存模型的角度

 

對於簡單的繼承關係,其子類內存佈局,是先有基類數據成員,然後再是子類的數據成員,當然後面講的複雜情況,本規律不一定成立。


我們構造fish類的對象時,首先要調用animal類的構造函數去構造animal類的對象,然後才調用fish類的構造函數完成自身部分的構造,從而拼接出一個完整的fish對象。當我們將fish類的對象轉換爲animal類型時,該對象就被認爲是原對象整個內存模型的上半部分,也就是圖中的“animal的對象所佔內存”。那麼當我們利用類型轉換後的對象指針去調用它的方法時,當然也就是調用它所在的內存中的方法。因此,輸出animal breathe,也就順理成章了。

前面輸出的結果是因爲編譯器在編譯的時候,就已經確定了對象調用的函數的地址,要解決這個問題就要使用遲綁定(late binding)技術。當編譯器使用遲綁定時,就會在運行時再去確定對象的類型以及正確的調用函數。而要讓編譯器採用遲綁定,就要在基類中聲明函數時使用virtual關鍵字(注意,這是必須的,很多學員就是因爲沒有使用虛函數而寫出很多錯誤的例子),這樣的函數我們稱爲虛函數。一旦某個函數在基類中聲明爲virtual,那麼在所有的派生類中該函數都是virtual,而不需要再顯式地聲明爲virtual。

動態多態性

下面我們將上面一段代碼進行部分修改

 

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
<span style="font-family: 'comic sans ms', sans-serif; font-size: 15px;">#include<iostream>
using namespace std;
class animal
{
    public:
        void sleep(){
            cout<<"animal sleep"<<endl;
             
        }
    virtual void breathe(){
     cout<<"animal breathe"<<endl;
    }
};
class fish:public animal
{
    public:
        void breathe(){
            cout<<"fish bubble"<<endl;
        }
};
 
int main()
{
    fish fh;
    animal *pAnimal=&fh;
    pAnimal->breathe();
}
</span>

  運行結果:fish bubble

編譯器爲每個類的對象提供一個虛表指針,這個指針指向對象所屬類的虛表。在程序運行時,根據對象的類型去初始化vptr,從而讓vptr正確的指向所屬類的虛表,從而在調用虛函數時,就能夠找到正確的函數。由於pAn實際指向的對象類型是fish,因此vptr指向的fish類的vtable,當調用pAn->breathe()時,根據虛表中的函數地址找到的就是fish類的breathe()函數。正是由於每個對象調用的虛函數都是通過虛表指針來索引的,也就決定了虛表指針的正確初始化是非常重要的。換句話說,在虛表指針沒有正確初始化之前,我們不能夠去調用虛函數。那麼虛表指針在什麼時候,或者說在什麼地方初始化呢?

答案是在構造函數中進行虛表的創建和虛表指針的初始化。還記得構造函數的調用順序嗎,在構造子類對象時,要先調用父類的構造函數,此時編譯器只“看到了”父類,並不知道後面是否後還有繼承者,它初始化父類對象的虛表指針,該虛表指針指向父類的虛表。當執行子類的構造函數時,子類對象的虛表指針被初始化,指向自身的虛表。

     當fish類的fh對象構造完畢後,其內部的虛表指針也就被初始化爲指向fish類的虛表。在類型轉換後,調用pAn->breathe(),由於pAn實際指向的是fish類的對象,該對象內部的虛表指針指向的是fish類的虛表,因此最終調用的是fish類的breathe()函數。

下面詳細的介紹內存的分佈

基類的內存分佈情況

對於無虛函數的類A:

class A
{
void g(){.....}
};
則sizeof(A)=1;
如果改爲如下:
class A
{
public:
    virtual void f()
    {
       ......
    }
    void g(){.....}
}
則sizeof(A)=4! 這是因爲在類A中存在virtual function,爲了實現多態,每個含有virtual function的類中都隱式包含着一個靜態虛指針vfptr指向該類的靜態虛表vtable, vtable中的表項指向類中的每個virtual function的入口地址
例如 我們declare 一個A類型的object :
    A c;
    A d;
則編譯後其內存分佈如下:

 

從 vfptr所指向的vtable可以看出,每個virtual function都佔有一個entry,例如本例中的f函數。而g函數因爲不是virtual類型,故不在vtable的表項之內。說明:vtab屬於類成員靜態pointer,而vfptr屬於對象pointer

繼承類的內存分佈狀況
假設代碼如下:
public B:public A
{
public :
    int f() //override virtual function
    {
        return 3;
    }
};

A c;
A d;
B e;
編譯後,其內存分佈如下:

 

從中我們可以看出,B類型的對象e有一個vfptr指向vtable address:0x00400030 ,而A類型的對象c和d共同指向類的vtable address:0x00400050a

動態綁定過程的實現
    我們說多態是在程序進行動態綁定得以實現的,而不是編譯時就確定對象的調用方法的靜態綁定。
    其過程如下:
    程序運行到動態綁定時,通過基類的指針所指向的對象類型,通過vfptr找到其所指向的vtable,然後調用其相應的方法,即可實現多態。
例如:
A c;
B e;
A *pc=&e; //設置breakpoint,運行到此處
pc=&c;
此時內存中各指針狀況如下:

 

可以看出,此時pc指向類B的虛表地址,從而調用對象e的方法。繼續運行,當運行至pc=&c時候,此時pc的vptr值爲0x00420050,即指向類A的vtable地址,從而調用c的方法。

 

對於虛函數調用來說,每一個對象內部都有一個虛表指針,該虛表指針被初始化爲本類的虛表。所以在程序中,不管你的對象類型如何轉換,但該對象內部的虛表指針是固定的,所以呢,才能實現動態的對象函數調用,這就是C++多態性實現的原理。

   需要注意的幾點
   總結(基類有虛函數):
     1、每一個類都有虛表。
     2、虛表可以繼承,如果子類沒有重寫虛函數,那麼子類虛表中仍然會有該函數的地址,只不過這個地址指向的是基類的虛函數實現。如果基類3個虛函數,那麼基類的虛表中就有三項(虛函數地址),派生類也會有虛表,至少有三項,如果重寫了相應的虛函數,那麼虛表中的地址就會改變,指向自身的虛函數實現。如果派生類有自己的虛函數,那麼虛表中就會添加該項。
     3、派生類的虛表中虛函數地址的排列順序和基類的虛表中虛函數地址排列順序相同。

 

下面想將虛函數和純虛函數做個比較

虛函數

 引入原因:爲了方便使用多態特性,我們常常需要在基類中定義虛函數。

  純虛函數
 引入原因:爲了實現多態性,純虛函數有點像java中的接口,自己不去實現過程,讓繼承他的子類去實現。

    在很多情況下,基類本身生成對象是不合情理的。例如,動物作爲一個基類可以派生出老虎、孔雀等子類,但動物本身生成對象明顯不合常理。 這時我們就將動物類定義成抽象類,也就是包含純虛函數的類
    純虛函數就是基類只定義了函數體,沒有實現過程定義方法如下

  virtual void Eat() = 0; 直接=0 不要 在cpp中定義就可以了 
虛函數和純虛函數的區別
1虛函數中的函數是實現的哪怕是空實現,它的作用是這個函數在子類裏面可以被重載,運行時動態綁定實現動態
純虛函數是個接口,是個函數聲明,在基類中不實現,要等到子類中去實現
2 虛函數在子類裏可以不重載,但是虛函數必須在子類裏去實現。

 

類的多繼承

一個類可以從多個基類中派生,也就是說:一個類可以同時擁有多個類的特性,是的,他有多個基類。這樣的繼承結構叫作“多繼承”,最典型的例子就是沙發-牀了:

SleepSofa類繼承自Bed和Sofa兩個類,因此,SleepSofa類擁有這兩個類的特性,但在實際編碼中會存在如下幾個問題。

 

a)         SleepSofa類該如何定義?

Class SleepSofa : public Bed, public Sofa

{

       ….

}

 構造順序爲:Bed  sofa  sleepsofa (也就是書寫的順序)

                    

b)        Bed和Sofa類中都有Weight屬性頁都有GetWeight和SetWeight方法,在SleepSofa類中使用這些屬性和方法時,如何確定調用的是哪個類的成員?

可以使用完全限定名的方式,比如:

Sleepsofa objsofa;

Objsofa.Bed::SetWeight(); // 給方法加上一個作用域,問題就解決了。

虛繼承

倘若,我們定義一個SleepSofa對象,讓我們分析一下它的構造過程:它會構造Bed類和Sofa類,但Bed類和Sofa類都有一個父類,因此Furniture類被構造了兩次,這是不合理的,因此,我們引入了虛繼承的概念。

 

class Furniture{……};

class Bed : virtual public Furniture{……}; // 這裏我們使用虛繼承

class Sofa : virtual public Furniture{……};// 這裏我們使用虛繼承

 

class sleepSofa : public Bed, public Sofa {……};

                     這樣,Furniture類就之構造一次了……

總結下繼承情況中子類對象的內存結構:

單繼承情況下子類實例的內存結構

假設我們有這樣的一個類:
 
class Base {
     public:
            virtual void f() { cout << "Base::f" << endl; }
            virtual void g() { cout << "Base::g" << endl; }
            virtual void h() { cout << "Base::h" << endl; }
 
};
 
按照上面的說法,我們可以通過Base的實例來得到虛函數表。 下面是實際例程:
 
          typedef void(*Fun)(void);
 
            Base b;
 
            Fun pFun = NULL;
 
            cout << "虛函數表地址:" << (int*)(&b) << endl;
            cout << "虛函數表 — 第一個函數地址:" << (int*)*(int*)(&b) << endl;
 
            // Invoke the first virtual function 
            pFun = (Fun)*((int*)*(int*)(&b));
            pFun();
 
實際運行經果如下:(Windows XP+VS2003,  Linux 2.6.22 + GCC 4.1.3)
 
虛函數表地址:0012FED4
虛函數表 — 第一個函數地址:0044F148
Base::f
 
 
通過這個示例,我們可以看到,我們可以通過強行把&b轉成int *,取得虛函數表的地址,然後,再次取址就可以得到第一個虛函數的地址了,也就是Base::f(),這在上面的程序中得到了驗證(把int* 強制轉成了函數指針)。通過這個示例,我們就可以知道如果要調用Base::g()和Base::h(),其代碼如下:
 
            (Fun)*((int*)*(int*)(&b)+0);  // Base::f()
            (Fun)*((int*)*(int*)(&b)+1);  // Base::g()
            (Fun)*((int*)*(int*)(&b)+2);  // Base::h()
 
這個時候你應該懂了吧。什麼?還是有點暈。也是,這樣的代碼看着太亂了,用下圖解釋一下。如下所示:
 

(1)一般繼承(無虛函數覆蓋)

假設有如下所示的一個繼承關係:

 

在這個繼承關係中,子類沒有重載任何父類的函數。那麼,在派生類的實例中,其虛函數表如下所示:
 對於實例:Derive d; 的虛函數表如下:
 
我們可以看到下面幾點:
1)虛函數按照其聲明順序放於表中。
2)父類的虛函數在子類的虛函數前面。

(2)一般繼承(有虛函數覆蓋)

 

在這個類的設計中,假設只覆蓋了父類的一個函數:f()。那麼,對於派生類的實例,其虛函數表會是下面的一個樣子

我們從表中可以看到下面幾點,
1)覆蓋的f()函數被放到了虛表中原來父類虛函數的位置。
2)沒有被覆蓋的函數依舊。
這樣,我們就可以看到對於下面這樣的程序,
 
            Base *b = new Derive();
 
            b->f();
 
由b所指的內存中的虛函數表的f()的位置已經被Derive::f()函數地址所取代,於是在實際調用發生時是Derive::f()被調用了。這就實現了多態。
 
在單繼承下,對應於例程:

class A

{

public:

    A(){m_A = 0;}

    virtual fun1(){};

    int m_A;

};

 

class B:public A

{

public:

    B(){m_B = 1;}

    virtual fun1(){};

    virtual fun2(){};

    int m_B;

};

 

int main(int argc, char* argv[])

{

    B* pB = new B;

 

       return 0;

}

則在VC6.0下的內存分配圖:

在該圖中,子類只有一個虛函數表,與以上的兩種情況向符合。

多繼承情況下子類實例的內存結構(非虛繼承)

(1)多重繼承(無虛函數覆蓋)

假設有下面這樣一個類的繼承關係。注意:子類並沒有覆蓋父類的函數:

對於子類實例中的虛函數表,是下面這個樣子:

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

(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()

在多繼承(非虛繼承)情況下,對應於以下例程序:

#include <stdio.h>

 

class A

{

public:

 

    A(){m_A = 1;};

    ~A(){};

    virtual int funA(){printf("in funA\r\n"); return 0;};

    int m_A;

};

 

class B

{

public:

    B(){m_B = 2;};

    ~B(){};

    virtual int funB(){printf("in funB\r\n"); return 0;};

    int m_B;

};

 

class C

{

public:

    C(){m_C = 3;};

    ~C(){};

    virtual int funC(){printf("in funC\r\n"); return 0;};

    int m_C;

};

 

class D:public A,public B,public C

{

public:

    D(){m_D = 4;};

    ~D(){};

    virtual int funD(){printf("in funD\r\n"); return 0;};

    int m_D;

};

則在VC6.0下的內存分配圖:

從該圖中可以看出,此時子類中確實有三個來自於父類的虛表。

多繼承情況下子類實例的內存結構(存在虛繼承)

在虛繼承下,Der通過共享虛基類SuperBase來避免二義性,在Base1,Base2中分別保存虛基類指針,Der繼承Base1,Base2,包含Base1, Base2的虛基類指針,並指向同一塊內存區,這樣Der便可以間接存取虛基類的成員,如下圖所示:

 

class SuperBase

{

public:

    int m_nValue;

    void Fun(){cout<<"SuperBase1"<<endl;}

    virtual ~SuperBase(){}

};

 

class Base1:  virtual public SuperBase

{

public:

virtual ~ Base1(){}

};

class Base2:  virtual public SuperBase

{

 

public:

virtual ~ Base2(){}

 

};

 

class Der:public Base1, public Base2

{

 

public:

virtual ~ Der(){}

 

};

 

void main()

{

cout<<sizeof(SuperBase)<<sizeof(Base1)<<sizeof(Base2)<<sizeof(Der)<<endl;

}

 

1) GCC中結果爲8, 12, 12, 16

解析:sizeof(SuperBase) = sizeof(int) + 虛函數表指針

sizeof(Base1) = sizeof(Base2) = sizeof(int) + 虛函數指針 + 虛基類指針

sizeof(Der) = sizeof(int) + Base1中虛基類指針 + Base2虛基類指針 + 虛函數指針 

GCC共享虛函數表指針,也就是說父類如果已經有虛函數表指針,那麼子類中共享父類的虛函數表指針空間,不在佔用額外的空間,這一點與VC不同,VC在虛繼承情況下,不共享父類虛函數表指針,詳見如下。

 

2)VC中結果爲:8, 16, 16, 24

 解析:sizeof(SuperBase) = sizeof(int) + 虛函數表指針

sizeof(Base1) = sizeof(Base2) = sizeof(int) + SuperBase虛函數指針 + 虛基類指針 + 自身虛函數指針

sizeof(Der) = sizeof(int) + Base1中虛基類指針 + Base2中虛基類指針 + Base1虛函數指針 + Base2虛函數指針 + 自身虛函數指針

 如果去掉虛繼承,結果將和GCC結果一樣,A,B,C都是8,D爲16,原因就是VC的編譯器對於非虛繼承,父類和子類是共享虛函數表指針的。

 

 (1)  部分虛繼承的情況下子類實例的內存結構:

#include "stdafx.h"

class A

{

public:

  A(){m_A = 0;};

  virtual funA(){};

  int m_A;

};

 

class B

{

public:

  B(){m_B = 1;};

  virtual funB(){};

  int m_B;

};

 

class C

{

public:

  C(){m_C = 2;};

  virtual funC(){};

  int m_C;

};

 

class D:virtual public A,public B,public C

{

public:

    D(){m_D = 3;};

    virtual funD(){};

    int m_D;

};

 

int main(int argc, char* argv[])

{

    D* pD = new D;

 

       return 0;

}

(2)全部虛繼承的情況下,子類實例的內存結構

class A

{

public:

    A(){m_A = 0;}

    virtual funA(){};

    int m_A;

};

 

class B

{

public:

    B(){m_B = 1;}

    virtual funB(){};

    int m_B;

};

 

class C:virtual public A,virtual public B

{

public:

    C(){m_C = 2;}

    virtual funC(){};

    int m_C;

};

 

int main(int argc, char* argv[])

{

    C* pC = new C;

 

       return 0;

}

 

(3) 菱形結構繼承關係下子類實例的內存結構

class A

{

public:

    A(){m_A = 0;}

    virtual funA(){};

    int m_A;

};

 

class B :virtual public A

{

public:

    B(){m_B = 1;}

    virtual funB(){};

    int m_B;

};

 

class C :virtual public A

{

public:

    C(){m_C = 2;}

    virtual funC(){};

    int m_C;

}; 

   

class D: public B, public C

{

public:

      D(){m_D = 3;}

      virtual funD(){};

      int m_D;

};

 

int main(int argc, char* argv[])

{

        D* pD = new D;

        return 0;

}

對於子類虛表的個數和設置,貌似虛繼承與非虛繼承的差別不是很大。

參考:

http://blog.csdn.net/chen_yi_long/article/details/8662822

http://blog.csdn.net/zyq0335/article/details/7657465

http://haoel.blog.51cto.com/313033/124595/

http://blog.csdn.net/xsh_123321/article/details/5956289



發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章