深入淺出的講解c++多態性

1. 用virtual關鍵字申明的函數叫做虛函數,虛函數肯定是類的成員函數。
2. 存在虛函數的類都有一個一維的虛函數表叫做虛表。類的對象有一個指向虛表開始的虛指針。虛表是和類對應的,虛表指針是和對象對應的。
3. 多態性是一個接口多種實現,是面向對象的核心。分爲類的多態性和函數的多態性。
4. 多態用虛函數來實現,結合動態綁定。
5. 純虛函數是虛函數再加上= 0。
6. 抽象類是指包括至少一個純虛函數的類。

純虛函數:virtual void breathe()=0;即抽象類!必須在子類實現這個函數!即先有名稱,沒內容,在派生類實現內容!

我們先看一個例子:例1- 1
#include
class animal
{
public:
       void sleep()
       {
              cout<<"animal sleep"<<endl;
       }
       void breathe()
       {
              cout<<"animal breathe"<<endl;
       }
};
class fish:public animal
{
public:
       void breathe()
       {
              cout<<"fish bubble"<<endl;
       }
};
void main()
{
       fish fh;
       animal *pAn=&fh;// 隱式類型轉換
       pAn->breathe();
}

注意,在例1-1的程序中沒有定義虛函數。考慮一下例1-1的程序執行的結果是什麼?
答案是輸出:animal breathe
我 們在main()函數中首先定義了一個fish類的對象fh,接着定義了一個指向animal類的指針變量pAn,將fh的地址賦給了指針變量pAn,然 後利用該變量調用pAn->breathe()。許多學員往往將這種情況和C++的多態性搞混淆,認爲fh實際上是fish類的對象,應該是調用 fish類的breathe(),輸出“fish bubble”,然後結果卻不是這樣。下面我們從兩個方面來講述原因。
1、 編譯的角度
C++編譯器在編譯的時候,要確定每個對象調用的函數(要求此函數是非虛函數)的地址,這稱爲早期綁定(early binding),當我們將fish類的對象fh的地址賦給pAn時,C++編譯器進行了類型轉換,此時C++編譯器認爲變量pAn保存的就是animal對象的地址。當在main()函數中執行pAn->breathe()時,調用的當然就是animal對象的breathe函數。
2、 內存模型的角度
我們給出了fish對象內存模型,如下圖所示:


圖1- 1 fish類對象的內存模型
我 們構造fish類的對象時,首先要調用animal類的構造函數去構造animal類的對象,然後才調用fish類的構造函數完成自身部分的構造,從而拼 接出一個完整的fish對象。當我們將fish類的對象轉換爲animal類型時,該對象就被認爲是原對象整個內存模型的上半部分,也就是圖1-1中的 “animal的對象所佔內存”。那麼當我們利用類型轉換後的對象指針去調用它的方法時,當然也就是調用它所在的內存中的方法。因此,輸出animal breathe,也就順理成章了。
正如很多學員所想,在例1-1的程序中,我們知道pAn實際指向的是fish類的對象,我們希望輸出的結果是魚的呼吸方法,即調用fish類的breathe方法。這個時候,就該輪到虛函數登場了。
前面輸出的結果是因爲編譯器在編譯的時候,就已經確定了對象調用的函數的地址,要解決這個問題就要使用遲綁定(late binding)技術。當編譯器使用遲綁定時,就會在運行時再去確定對象的類型以及正確的調用函數。而要讓編譯器採用遲綁定,就要在基類中聲明函數時使用virtual關鍵字(注意,這是必須的,很多學員就是因爲沒有使用虛函數而寫出很多錯誤的例子),這樣的函數我們稱爲虛函數。一旦某個函數在基類中聲明爲virtual,那麼在所有的派生類中該函數都是virtual,而不需要再顯式地聲明爲virtual
下面修改例1-1的代碼,將animal類中的breathe()函數聲明爲virtual,如下:
例1- 2
#include
class animal
{
public:
       void sleep()
       {
              cout<<"animal sleep"<<endl;
       }
       virtualvoid breathe()
       {
              cout<<"animal breathe"<<endl;
       }
};

class fish:public animal
{
public:
       void breathe()
       {
              cout<<"fish bubble"<<endl;
       }
};
void main()
{
       fish fh;
       animal *pAn=&fh; // 隱式類型轉換
       pAn->breathe();
}

大家可以再次運行這個程序,你會發現結果是“fish bubble”,也就是根據對象的類型調用了正確的函數。
那麼當我們將breathe()聲明爲virtual時,在背後發生了什麼呢?
編譯器在編譯的時候,發現animal類中有虛函數,此時編譯器會爲每個包含虛函數的類創建一個虛表(即vtable),該表是一個一維數組,在這個數組中存放每個虛函數的地址。對於例1-2的程序,animal和fish類都包含了一個虛函數breathe(),因此編譯器會爲這兩個類都建立一個虛表,(即使子類裏面沒有virtual函數,但是其父類裏面有,所以子類中也有了)如下圖所示:


                                                    圖1- 2 animal類和fish類的虛表
那麼如何定位虛表呢?編譯器另外還爲每個類的對象提供了一個虛表指針(即vptr),這個指針指向了對象所屬類的虛表。在 程序運行時,根據對象的類型去初始化vptr,從而讓vptr正確的指向所屬類的虛表,從而在調用虛函數時,就能夠找到正確的函數。對於例1-2的程序, 由於pAn實際指向的對象類型是fish,因此vptr指向的fish類的vtable,當調用pAn->breathe()時,根據虛表中的函數 地址找到的就是fish類的breathe()函數。
正是由於每個對象調用的虛函數都是通過虛表指針來索引的,也就決定了虛表指針的正確初始化是非常重要的。換句話說,在虛表指針沒有正確初始化之前,我們不能夠去調用虛函數。那麼虛表指針在什麼時候,或者說在什麼地方初始化呢?
答案是在構造函數中進行虛表的創建和虛表指針的初始化。
還 記得構造函數的調用順序嗎,在構造子類對象時,要先調用父類的構造函數,此時編譯器只“看到了”父類,並不知道後面是否後還有繼承者,它初始化父類對象的 虛表指針,該虛表指針指向父類的虛表。當執行子類的構造函數時,子類對象的虛表指針被初始化,指向自身的虛表。對於例2-2的程序來說,當fish類的 fh對象構造完畢後,其內部的虛表指針也就被初始化爲指向fish類的虛表。在類型轉換後,調用pAn->breathe(),由於pAn實際指向 的是fish類的對象,該對象內部的虛表指針指向的是fish類的虛表,因此最終調用的是fish類的breathe()函數。
要注意:對於虛函數調用來說,每一個對象內部都有一個虛表指針,該虛表指針被初始化爲本類的虛表。所以在程序中,不管你的對象類型如何轉換,但該對象內部的虛表指針是固定的,所以呢,才能實現動態的對象函數調用,這就是C++多態性實現的原理。
總結(基類有虛函數):
1. 每一個類都有虛表。
2. 虛表可以繼承,如果子類沒有重寫虛函數,那麼子類虛表中仍然會有該函數的地址,只不過這個地址指向的是基類的虛函數實現。如果基類有3個虛函數,那麼基類 的虛表中就有三項(虛函數地址),派生類也會有虛表,至少有三項,如果重寫了相應的虛函數,那麼虛表中的地址就會改變,指向自身的虛函數實現。如果派生類 有自己的虛函數,那麼虛表中就會添加該項。
3. 派生類的虛表中虛函數地址的排列順序和基類的虛表中虛函數地址排列順序相同。

這 就是C++中的多態性。當C++編譯器在編譯的時候,發現animal類的breathe()函數是虛函數,這個時候C++就會採用遲綁定(late binding)技術。也就是編譯時並不確定具體調用的函數,而是在運行時,依據對象的類型(在程序中,我們傳遞的fish類對象的地址)來確認調用的是 哪一個函數,這種能力就叫做C++的多態性。我們沒有在breathe()函數前加virtual關鍵字時,C++編譯器在編譯時就確定了哪個函數被調 用,這叫做早期綁定(early binding)。

C++的多態性是通過遲綁定技術來實現的。

C++的多態性用一句話概括就是:在基類的函數前加上virtual關鍵字,在派生類中重寫該函數,運行時將會根據對象的實際類型來調用相應的函數。如果對象類型是派生類,就調用派生類的函數;如果對象類型是基類,就調用基類的函數。

虛函數是在基類中定義的,目的是不確定它的派生類的具體行爲。例:
定義一個基類:class Animal//動物。它的函數爲breathe()//呼吸。
再定義一個類class Fish//魚 。它的函數也爲breathe()
再定義一個類class Sheep //羊。它的函數也爲breathe()
爲了簡化代碼,將Fish,Sheep定義成基類Animal的派生類。
然 而Fish與Sheep的breathe不一樣,一個是在水中通過水來呼吸,一個是直接呼吸空氣。所以基類不能確定該如何定義breathe,所以在基類 中只定義了一個virtual breathe,它是一個空的虛函數。具本的函數在子類中分別定義。程序一般運行時,找到類,如果它有基類,再找它的基類,最後運行的是基類中的函數,這 時,它在基類中找到的是virtual標識的函數,它就會再回到子類中找同名函數。派生類也叫子類。基類也叫父類。這就是虛函數的產生,和類的多態性 (breathe)的體現。

這裏的多態性是指類的多態性。
函數的多態性是指一個函數被定義成多個不同參數的函數,它們一般被存在 頭文件中,當你調用這個函數,針對不同的參數,就會調用不同的同名函數。例:Rect()//矩形。它的參數可以是兩個座標點(point,point) 也可能是四個座標(x1,y1,x2,y2)這叫函數的多態性與函數的重載。

類的多態性,是指用虛函數和延遲綁定來實現的。函數的多態性是函數的重載。

一般情況下(沒有涉及virtual函數),當我們用一個指針/引用調用一個函數的時候,被調用的函數是取決於這個指針/引用的類型。即如果這個指針/引用是基類對象的指針/引用就調用基類的方法;如果指針/引用是派生類對象的指針/引用就調用派生類的方法,當然如果派生類中沒有此方法,就會向上到基類裏面去尋找相應的方法。這些調用在編譯階段就確定了。

當設計到多態性的時候,採用了虛函數和動態綁定,此時的調用就不會在編譯時候確定而是在運行時確定。不在單獨考慮指針/引用的類型而是看指針/引用的對象的類型來判斷函數的調用,根據對象中虛指針指向的虛表中的函數的地址來確定調用哪個函數。

 

虛函數聯繫到多態,多態聯繫到繼承。所以本文中都是在繼承層次上做文章。沒了繼承,什麼都沒得談。 下面是對C++的虛函數這玩意兒的理解。

什麼是虛函數(如果不知道虛函數爲何物,但有急切的想知道,那你就應該從這裏開始)

簡單地說,那些被virtual關鍵字修飾的成員函數,就是虛函數。虛函數的作用,用專業術語來解釋就是實現多態性(Polymorphism),多態性是將接口與實現進行分離;用形象的語言來解釋就是實現以共同的方法,但因個體差異而採用不同的策略。下面來看一段簡單的代碼

class A{
public:
void print(){ cout<<”This is A”<<endl;}
};

class B:public A{
public:
void print(){ cout<<”This is B”<<endl;}
};

int main(){ //爲了在以後便於區分,我這段main()代碼叫做main1
A a;
B b;
a.print();
b.print();
}


通 過class A和class B的print()這個接口,可以看出這兩個class因個體的差異而採用了不同的策略,輸出的結果也是我們預料中的,分別是This is A和This is B。但這是否真正做到了多態性呢?No,多態還有個關鍵之處就是一切用指向基類的指針或引用來操作對象。那現在就把main()處的代碼改一改。

int main(){ //main2
A a;
B b;
A* p1=&a;
A* p2=&b;
p1->print();
p2->print();
}


運行一下看看結果,喲呵,驀然回首,結果卻是兩個This is A。問題來了,p2明明指向的是class B的對象但卻是調用的class A的print()函數,這不是我們所期望的結果,那麼解決這個問題就需要用到虛函數

class A{
public:
virtual void print(){ cout<<”This is A”<<endl;} //現在成了虛函數了
};

class B:public A{
public:
void print(){ cout<<”This is B”<<endl;} //這裏需要在前面加上關鍵字virtual嗎?
};


毫無疑問,class A的成員函數print()已經成了虛函數,那麼class B的print()成了虛函數了嗎?回答是Yes,我們只需在把基類的成員函數設爲virtual,其派生類的相應的函數也會自動變爲虛函數。所以,class B的print()也成了虛函數。那麼對於在派生類的相應函數前是否需要用virtual關鍵字修飾,那就是你自己的問題了。

現在重新運行main2的代碼,這樣輸出的結果就是This is A和This is B了。

現在來消化一下,我作個簡單的總結,指向基類的指針在操作它的多態類對象時,會根據不同的類對象,調用其相應的函數,這個函數就是虛函數 。

純虛函數
將breathe()函數申明爲純虛函數,結果如例2-18所示。
例2-18
class animal
{
public:
    void eat()
    {
        cout<<"animal eat"<<endl;
    }
    void sleep()
    {
        cout<<"animal sleep"<<endl;
    }
    virtual void breathe() = 0;
};


純虛函數是指被標明爲不具體實現的虛成員函數(注意:純虛函數也可以有函數體,但這種提供函數體的用法很少見)。純虛函數可以讓類先具有一個操作名稱,而沒有操作內容,讓派生類在繼承時再去具體地給出定義。凡是含有純虛函數的類叫做抽象類。這種類不能聲明對象,只是作爲基類爲派生類服務。在派生類中必須完全實現基類的純虛函數,否則,派生類也變成了抽象類,不能實例化對象。

純虛函數多用在一些方法行爲的設計上。在設計基類時,不太好確定或將來的行爲多種多樣,而此行爲又是必需的,我們就可以在基類的設計中,以純虛函數來聲明此種行爲,而不具體實現它。

注意:C++的多態性是由虛函數來實現的,而不是純虛函數。在子類中如果有對基類虛函數的覆蓋定義,無論該覆蓋定義是否有virtual關鍵字,都是虛函數。

無虛函數的類,在編譯的時候採用的是靜態綁定,即綁定類函數的實現和調用,這種綁定是基於指向對象的指針(或是引用)的類型。動態綁定對成員函數的選擇不是基於指針或者引用,而是基於對象類型,不同的對象類型將做出不同的編譯結果。

什麼是虛函數和多態
虛函數
是在類中被聲明爲virtual的成員函數,當編譯器看到通過指針或引用調用此類函數時,對其執行晚綁定,即通過指針(或引用)指向的對象的類型信息來決定該函數是哪個類的。通常此類指針或引用都聲明爲基類的,它可以指向基類或派生類的對象。

多態
指同一個方法根據其所屬的不同對象可以有不同的行爲(根據自己理解,不知這麼說是否嚴謹)。

在進入主題之前先介紹一下聯編的概念。聯編就是將模塊或者函數合併在一起生成可 執行代碼的處理過程,同時對每個模塊或者函數調用分配內存地址,並且對外部訪問也分配正確的內存地址。按照聯編所進行的階段不同,可分爲兩種不同的聯編方法:靜態聯編和動態聯編在編譯階段就將函數實現和函數調用關聯起來稱之爲靜態聯編,靜態聯編在編譯階段就必須瞭解所有的函數或模塊執行所需要檢測的信息,它對函數的選擇是基於指向對象的指針(或者引用)的類型。反之在程序執行的時候才進行這種關聯稱之爲動態聯編,動態聯編對成員函數的選擇不是基於指針或者引用,而是基於對象類型,不同的對象類型將做出不同的編譯結果。C語言中,所有的聯編都是靜態聯編。C++中一般情況下聯編也是靜態聯編,但是一旦涉及到多態性和虛函數就必須使用動態聯編。
多態性是面向對象的核心,它的最主要的思想就是可以採用多種形式的能力,通過一個用戶名字或者用戶接口完成不同的實現。通常多態性被簡單的描述爲“一個接口,多個實現”。在C++裏面具體的表現爲通過基類指針訪問派生類的函數和方法。

揭密動態聯編的祕密
編譯器到底做了什麼實現的虛函數的動態編聯呢?我們來探個究竟。

編譯器對每個包含虛函數的類創建一個表(稱爲V TA B L E)
。 在V TA B L E中,編譯器放置特定類的虛函數地址。在每個帶有虛函數的類中,編譯器祕密地置一指針,稱爲v p o i n t e r(縮寫爲V P T R),指向這個對象的V TA B L E。通過基類指針做虛函數調用時(也就是做多態調用時),編譯器靜態地插入取得這個V P T R,並在V TA B L E表中查找函數地址的代碼,這樣就能調用正確的函數使動態聯編髮生。爲每個類設置V TA B L E、初始化V P T R、爲虛函數調用插入代碼,所有這些都是自動發生的,所以我們不必擔心這些。利用虛函數,這個對象的合適的函數就能被調用,哪怕在編譯器還不知道這個對象 的特定類型的情況下。(《C++編程思想》)

在任何類中不存在顯示的類型信息,可對象中必須存放類信息,否則類型不可能在運行時建立。那這個類信息是什麼呢?我們來看下面幾個類:

class no_virtual
{
public:
    void fun1() const{}
    int fun2() const { return a; }
private:
    int a;
}

class one_virtual
{
public:
    virtual void fun1() const{}
    int fun2() const { return a; }
private:
    int a;
}

class two_virtual
{
public:
    virtual void fun1() const{}
    virtual int fun2() const { return a; }
private:
    int a;
}


    以上三個類中:
    no_virtual沒有虛函數,sizeof(no_virtual)=4,類no_virtual的長度就是其成員變量整型a的長度;
    one_virtual有一個虛函數,sizeof(one_virtual)=8;
    two_virtual 有兩個虛函數,sizeof(two_virtual)=8; 有一個虛函數和兩個虛函數的類的長度沒有區別,其實它們的長度就是no_virtual的長度加一個void指針的長度,它反映出,如果有一個或多個虛函 數,編譯器在這個結構中插入一個指針( V P T R)。在one_virtual 和two_virtual之間沒有區別。這是因爲V P T R指向一個存放地址的表,只需要一個指針,因爲所有虛函數地址都包含在這個表中。

    這個VPTR就可以看作類的類型信息。

    那我們來看看編譯器是怎麼建立VPTR指向的這個虛函數表的。先看下面兩個類:
class base
{
public:
    void bfun(){}
    virtual void vfun1(){}
    virtual int vfun2(){}

private:
    int a;
}

class derived : public base
{
public:
    void dfun(){}
    virtual void vfun1(){}
    virtual int vfun3(){}

private:
    int b;
}


兩個類VPTR指向的虛函數表(VTABLE)分別如下:
base類
                      ——————
VPTR——> |&base::vfun1 |
                      ——————
                 |&base::vfun2 |
                  ——————
    
derived類
                      ———————
VPTR——> |&derived::vfun1 |
                      ———————
                  |&base::vfun2    |
                  ———————
                  |&derived::vfun3 |
                   ———————
每當創建一個包含有虛函數的類或從包含有虛函數的類派生一個類時,編譯器就爲這個類創建一個VTABLE,如 上圖所示。在這個表中,編譯器放置了在這個類中或在它的基類中所有已聲明爲virtual的函數的地址。如果在這個派生類中沒有對在基類中聲明爲 virtual的函數進行重新定義,編譯器就使用基類的這個虛函數地址。(在derived的VTABLE中,vfun2的入口就是這種情況。)然後編譯 器在這個類中放置VPTR。當使用簡單繼承時,對於每個對象只有一個VPTR。VPTR必須被初始化爲指向相應的VTABLE,這在構造函數中發生。
一旦VPTR被初始化爲指向相應的VTABLE,對象就"知道"它自己是什麼類型。但只有當虛函數被調用時這種自我認知纔有用。

    VPTR常常位於對象的開頭,編譯器能很容易地取到VPTR的值, 從而確定VTABLE的位置。VPTR總指向VTABLE的開始地址,所有基類和它的子類的虛函數地址(子類自己定義的虛函數除外)在VTABLE中存儲 的位置總是相同的,如上面base類和derived類的VTABLE中 vfun1和vfun2的地址總是按相同的順序存儲。編譯器知道vfun1位於VPTR處,vfun2位於VPTR+1處,因此在用基類指針調用虛函數 時,編譯器首先獲取指針指向對象的類型信息(VPTR),然後就去調用虛函數。如一個base類指針pBase指向了一個derived對象,那 pBase->vfun2()被編譯器翻譯爲 VPTR+1 的調用,因爲虛函數vfun2的地址在VTABLE中位於索引爲1的位置上。同理,pBase->vfun3()被編譯器翻譯爲VPTR+2的調 用。這就是所謂的晚綁定。

我們來看一下虛函數調用的彙編代碼,以加深理解。
void test(base* pBase)
{
pBase->vfun2();
}

int main(int argc, char* argv[])
{
derived td;
test(&td);
return 0;
}


derived td;編譯生成的彙編代碼如下:
mov DWORD PTR _td$[esp+24], OFFSET FLAT:??_7derived@@6B@ ; derived::`vftable'
由編譯器的註釋可知,此時PTR _td$[esp+24]中存儲的就是derived類的VTABLE地址。

test(&td);編譯生成的彙編代碼如下:
lea eax, DWORD PTR _td$[esp+24]  
mov DWORD PTR __$EHRec$[esp+32], 0
push eax
call ?test@@YAXPAVbase@@@Z   ; test
調用test函數時完成了如下工作:取對象td的地址,將其壓棧,然後調用test。
pBase->vfun2();編譯生成的彙編代碼如下:
mov ecx, DWORD PTR _pBase$[esp-4]
mov eax, DWORD PTR [ecx]
jmp DWORD PTR [eax+4]

首 先從棧中取出pBase指針指向的對象地址賦給ecx,然後取對象開頭的指針變量中的地址賦給eax,此時eax的值即爲VPTR的值,也就是 VTABLE的地址。最後就是調用虛函數了,由於vfun2位於VTABLE的第二個位置,相當於 VPTR+1,每個函數指針是4個字節長,所以最後的調用被編譯器翻譯爲 jmp DWORD PTR [eax+4]。如果是調用pBase->vfun1(),這句就該被編譯爲jmp DWORD PTR [eax]。

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