物聯網學習教程—多型的實現機制

千鋒教育官方發表於2019-08-16


  要更好地理解 C++ 的多型性,我們需要弄清楚函式覆蓋的呼叫機制,因此,首先我們介紹一下函式的覆蓋。

1.    函式的覆蓋

我們先看一個例子:

1- 1

#include <iostream.h>

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

    在類 fish 中重寫了 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 物件記憶體模型,如下圖所示:

 

animal 的物件所佔記憶體

 

fish 的物件自身增加的部分

 

fish 類的物件所佔記憶體

 

  1- 1 fish 類物件 的記憶體模型

 

我們構造 fish 類的物件時,首先要呼叫 animal 類的建構函式去構造 animal 類的物件,然後才呼叫 fish 類的建構函式完成自身部分的構造,從而拼接出一個完整的 fish 物件。當我們將 fish 類的物件轉換為 animal 型別時,該物件就被認為是原物件整個記憶體模型的上半部分,也就是圖 1-1 中的“ animal 的物件所佔記憶體”。那麼當我們利用型別轉換後的物件指標去呼叫它的方法時,當然也就是呼叫它所在的記憶體中的方法。因此,出現圖 2.13 所示的結果,也就順理成章了。

2.    多型性和虛擬函式

正如很多學員所想,在例 1-1 的程式中,我們知道 pAn 實際指向的是 fish 類的物件,我們希望輸出的結果是魚的呼吸方法,即呼叫 fish 類的 breathe 方法。這個時候,就該輪到虛擬函式登場了。

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

下面修改例 1-1 的程式碼,將 animal 類中的 breathe() 函式宣告為 virtual ,如下:

1- 2

#include <iostream.h>

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;

    }

};

void main()

{

    fish fh;

    animal *pAn=&fh;

    pAn->breathe();

}

大家可以再次執行這個程式,你會發現結果是“ fish bubble ”,也就是根據物件的型別呼叫了正確的函式。

那麼當我們將 breathe() 宣告為 virtual 時,在背後發生了什麼呢?

編譯器在編譯的時候,發現 animal 類中有虛擬函式,此時編譯器會為每個包含虛擬函式的類建立一個虛表(即 vtable ),該表是一個一維陣列,在這個陣列中存放每個虛擬函式的地址。對於例 1-2 的程式, animal fish 類都包含了一個虛擬函式 breathe() ,因此編譯器會為這兩個類都建立一個虛表,如下圖所示:

 

&animal::breathe()

 

animal 類的 vtable

 

animal::breathe()

 

&fish::breathe()

 

fish 類的 vtable

 

fish::breathe()

 

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、   派生類的虛表中虛擬函式地址的排列順序和基類的虛表中虛擬函式地址排列順序相同。

3.    VC 影片第三課 this 指標說明

我在論壇的 VC 教學影片版面發了帖子,是模擬 MFC 類庫的例子寫的,主要是說明在基類的建構函式中儲存的 this 指標是指向子類的,我們在看一下這個例子:

1- 3

#include <iostream.h>

 

class base;

 

base * pbase;

 

class base

{

public:

    base()

    {

        pbase=this;

       

    }

    virtual void fn()

    {

        cout<<"base"<<endl;

    }

};

 

class derived:public base

{

    void fn()

    {

        cout<<"derived"<<endl;

    }

};

 

derived aa;

void main()

{

    pbase->fn();

}

我在 base 類的建構函式中將 this 指標儲存到 pbase 全域性變數中。在定義全域性物件 aa ,即呼叫 derived aa; 時,要呼叫基類的建構函式,先構造基類的部分,然後是子類的部分,由這兩部分拼接出完整的物件 aa 。這個 this 指標指向的當然也就是 aa 物件,那麼我們 main() 函式中利用 pbase 呼叫 fn() ,因為 pbase 實際指向的是 aa 物件,而 aa 物件內部的虛表指標指向的是自身的虛表,最終呼叫的當然是 derived 類中的 fn() 函式。

在這個例子中,由於我的疏忽,在 derived 類中宣告 fn() 函式時,忘了加 public 關鍵字,導致宣告為了 private (預設為 private ),但透過前面我們所講述的虛擬函式呼叫機制,我們也就明白了這個地方並不影響它輸出正確的結果。不知道這算不算 C++ 的一個 Bug ,因為虛擬函式的呼叫是在執行時確定呼叫哪一個函式,所以編譯器在編譯時,並不知道 pbase 指向的是 aa 物件,所以導致這個奇怪現象的發生。如果你直接用 aa 物件去呼叫,由於物件型別是確定的(注意 aa 是物件變數,不是指標變數),編譯器往往會採用早期繫結,在編譯時確定呼叫的函式,於是就會發現 fn() 是私有的,不能直接呼叫。:)

許多學員在寫這個例子時,直接在基類的建構函式中呼叫虛擬函式,前面已經說了,在呼叫基類的建構函式時,編譯器只“看到了”父類,並不知道後面是否後還有繼承者,它只是初始化父類的虛表指標,讓該虛表指標指向父類的虛表,所以你看到結果當然不正確。只有在子類的建構函式呼叫完畢後,整個虛表才構建完畢,此時才能真正應用 C++ 的多型性。 換句話說,我們不要在建構函式中去呼叫虛擬函式,當然如果你只是想呼叫本類的函式,也無所謂。



來自 “ ITPUB部落格 ” ,連結:http://blog.itpub.net/69914734/viewspace-2653906/,如需轉載,請註明出處,否則將追究法律責任。

相關文章