C++ 動態多態


背景

以前的學習,只是簡單地知道:**面向對象的三大特性(封裝、繼承、多態) **,在項目開發中,用到了多態而自己卻不知道。

多態(Polymorphism)按字面的意思就是“多種狀態”。在面向對象語言中,接口的多種不同的實現方式即為多態。(調用同名函數卻會因上下文的不同而有不同的實現。)

引用Charlie Calverts對多態的描述:多態性是允許你將父對象設置成為和一個或更多的他的子對象相等的技術,賦值之后,父對象就可以根據當前賦值給它的子對象的特性以不同的方式運作。簡單的說:允許將子類類型的指針賦值給父類類型的指針。

多態三要素:相同函數名、依據上下文、實現卻不同;

多態分為2種:靜態多態(重載),動態多態。

  • 靜態多態實際上就是函數重載,是編譯器在編譯期間完成的,所以稱之為靜態。
  • 動態多態: 通過繼承重寫基類的虛函數實現的多態,在程序運行時根據基類的引用(指針)指向的對象來確定自己具體該調用哪一個類的虛函數。 運行時在虛函數表中尋找調用函數的地址。

下面我們重點介紹動態多態(下文稱 多態)

動態多態的概念

多態的常規用法:用一個父類的指針去調用子類中被重寫的方法。

為什么不直接在子類中寫一個同名的成員函數,從而隱藏父類的函數就好了?

舉個例子:

將父類比喻為電腦的外設接口,子類比喻為外設,現在我有移動硬盤、U盤以及MP3,它們3個都是可以作為存儲但是也各不相同。如果我在寫驅動的時候,我用父類表示外設接口,然后在子類中重寫父類那個讀取設備的虛函數,那這樣電腦的外設接口只需要一個。但如果我不是這樣做,而是用每個子類表示一個外設接口,那么我的電腦就必須有3個接口分別來讀取移動硬盤、U盤以及MP3。若以后我還有SD卡讀卡器,那我豈不是要將電腦拆了,焊個SD卡讀卡器的接口上去?

用父類的指針指向子類,是為了面向接口編程。大家都遵循這個接口,弄成一樣的,到哪里都可以用,准確說就是:"一個接口,多種實現"。

多態的使用

使用多態必須滿足以下條件

  • 基類中必須包含虛函數,並且派生類中一定要對基類中的虛函數進行重寫。
  • 通過基類對象的指針或者基類對象的引用**調用虛函數。

在基類的函數前加上virtual關鍵字,在派生類中重寫該函數,運行時將會根據對象的實際類型來調用相應的函數。

在成員函數(必須為虛函數)的形參列表后面寫上=0,則成員函數為純虛函數。包含純虛函數的類叫做抽象類(也叫接口類),抽象類不能實例化出對象。純虛函數在派生類中重新定義以后,派生類才能實例化出對象。純虛函數是一定要被繼承的,否則它存在沒有任何意義。

如果對象類型是子類,就調用子類的函數;如果對象類型是父類,就調用父類的函數,(即指向父類調父類,指向子類調子類)此為多態的表現。

/*
#    Copyright By Schips, All Rights Reserved
#    https://gitee.com/schips/
#
#    File Name:  Polymorphism.cpp
#    Created  :  2020年02月21日 11:17:42
*/

#include <iostream>
using namespace std;
class base
{
public:
    virtual void go();
};

void base :: go ()
{
    cout << "base.go" << endl;
}

class sub : public base
{
public:
    virtual void go();
};

void sub :: go ()
{
    cout << "sub.go" << endl;
}

void fun (base& p)
{
     p.go ();
}

int main(int argc, char *argv[])
{
    base b;
    sub s;
    // 通過 基類引用的方式
    fun(b);
    fun(s);

    // 通過 基類指針的方式
    base *pb = &b;
    pb->go();
    pb = &s;
    pb->go();

    return 0;
}

多態的原理

多態是基於 虛函數表實現的。

虛函數表

對C++ 了解的人都應該知道虛函數(Virtual Function)是通過一張虛函數表(Virtual Table)來實現的。簡稱為V-Table。在這個表中,主要是一個類的虛函數的地址表,這張表解決了繼承、覆蓋的問題,保證其容真實反應實際的函數。這樣,在有虛函數的類的實例中這個表被分配在了這個實例的內存中,所以,當我們用父類的指針來操作一個子類的時候,這張虛函數表就顯得由為重要了,它就像一個地圖一樣,指明了實際所應該調用的函數。

這里我們着重看一下這張虛函數表。在C++的標准規格說明書中說到,編譯器必需要保證虛函數表的指針存在於對象實例中最前面的位置(這是為了保證正確取到虛函數的偏移量)。 這意味着我們通過對象實例的地址得到這張虛函數表,然后就可以遍歷其中函數指針,並調用相應的函數。
假設有這樣的一個類:

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;

pFun = (Fun)*((int*)*(int*)(&b)); 

pFun(); 

通過這個示例,我們可以看到,我們可以通過強行把&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()

以上實例如圖所示:

img

注意:在上面這個圖中,在虛函數表的最后多加了一個結點,這是虛函數表的結束結點,就像字符串的結束符“\0”一樣,其標志了虛函數表的結束。這個結束標志的值在不同的編譯器下是不同的。在WinXP+VS2003下,這個值是NULL。而在Ubuntu 7.10 + Linux 2.6.22 + GCC 4.1.3下,這個值是如果1,表示還有下一個虛函數表,如果值是0,表示是最后一個虛函數表。

虛函數的 “無覆蓋”和“有覆蓋”

沒有覆蓋父類的虛函數是毫無意義的。之所以要講述沒有覆蓋的情況,主要目的是為了給一個對比。在比較之下,我們可以更加清楚地知道其內部的具體實現。

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

下面,再讓我們來看看繼承時的虛函數表是什么樣的。假設有如下所示的一個繼承關系:

請注意,在這個繼承關系中,子類沒有重載(重寫)任何父類的函數。那么,在派生類的實例中,其虛函數表如下所示:
對於實例:Derive d; 的虛函數表如下:

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

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

覆蓋父類的虛函數是很顯然的事情,不然,虛函數就變得毫無意義。下面,我們來看一下,如果子類中有虛函數重載了父類的虛函數,會是一個什么樣子?假設,有下面這樣的一個繼承關系。

img

為了讓大家看到被繼承過后的效果,在這個類的設計中,我只覆蓋了父類的一個函數:f()。那么,對於派生類的實例,其虛函數表會是下面的一個樣子:

img

我們從表中可以看到下面幾點,
1)覆蓋的f()函數被放到了虛表中原來父類虛函數的位置。
2)沒有被覆蓋的函數依舊存在。
這樣,我們就可以看到對於下面這樣的程序,

Base *b = new Derive(); 
b->f(); 

由b所指的內存中的虛函數表的f()的位置已經被Derive::f()函數地址所取代,於是在實際調用發生時,是Derive::f()被調用了。這就實現了多態。

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

下面,再讓我們來看看多重繼承中的情況,假設有下面這樣一個類的繼承關系。注意:子類並沒有覆蓋父類的函數。

img

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

img

我們可以看到:
1) 每個父類都有自己的虛表。
2) 子類的成員函數被放到了第一個父類的表中。(所謂的第一個父類是按照聲明順序來判斷的)
這樣做就是為了解決不同的父類類型的指針指向同一個子類實例,而能夠調用到實際的函數。

多重繼承(有虛函數覆蓋)

下面我們再來看看,如果發生虛函數覆蓋的情況。
下圖中,我們在子類中覆蓋了父類的f()函數。

img

下面是對於子類實例中的虛函數表的圖:

img

我們可以看見,三個父類虛函數表中的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() 

virtual是讓子類與父類之間的同名函數有聯系,這就是多態性,實現動態綁定。

任何類若是有虛函數就會比比正常的類大一點,所有有virtual的類的對象里面最頭上會自動加上一個隱藏的,不讓開發者知道的指針,它指向一張表,這張表叫做vtable,vtable里是所有virtual函數的地址。

派生類虛表:
1.先將基類的虛表中的內容拷貝一份
2.如果派生類對基類中的虛函數進行重寫,使用派生類的虛函數替換相同偏移量位置的基類虛函數
3.如果派生類中新增加自己的虛函數,按照其在派生類中的聲明次序,放在上述虛函數之后

為什么要把基類的析構函數定義為虛函數?
解答:

在用基類操作派生類時,為了防止執行基類的析構函數,不執行派生類的析構函數。因為這樣的刪除只能夠刪除基類對象, 而不能刪除子類對象, 形成了刪除一半形象, 會造成內存泄漏.如下代碼:

#include<iostream>  
using namespace std;  

class Base  
{  
public:  
    Base() {};  
    //virtual ~Base()   // 會導致先析構子類
    ~Base()   // 不析構子類 ,存在內存泄漏
    {  
        cout << "delete Base" << endl;  
    };  

};  

class Derived : public Base  
{  
public:  
    Derived() {};  
    ~Derived()  
    {  
        cout << "delete Derived" << endl;  
    };  
};  

int main()  
{  
    //操作1  
    Base* p1 = new Derived;  
    delete p1;  

    //因為這里子類的析構函數重寫了父類的析構函數,雖然子類和父類的析構函數名不一樣,  
    //但是編譯器對析構函數做了特殊的處理,在內部子類和父類的析構函數名是一樣的。  
    //所以如果不把父類的析構函數定義成虛函數,就不構成多態,由於父類的析構函數隱藏了子類  
    //的析構函數,所以只能調到父類的析構函數。  (導致子類的析構函數沒有執行)
    //但是若把父類的析構函數定義成虛函數,那么調用時就會直接調用子類的析構函數,  
    //由於子類析構先要去析構父類,在析構子類,這樣就把子類和繼承的父類都析構了  

}  

多態 注意事項

使用多態時,讓基類的析構函數成為 虛函數。

虛函數的定義要遵循以下重要規則:

1.如果虛函數在基類與派生類中出現,僅僅是名字相同,而形式參數不同,或者是返回類型不同,那么即使加上了virtual關鍵字,也是不會進行滯后聯編的。

2.只有類的成員函數才能說明為虛函數,因為虛函數僅適合用與有繼承關系的類對象,所以普通函數不能說明為虛函數。

3.靜態成員函數不能是虛函數,因為靜態成員函數的特點是不受限制於某個對象。

4.內聯(inline)函數不能是虛函數,因為內聯函數不能在運行中動態確定位置。即使虛函數在類的內部定義定義,但是在編譯的時候系統仍然將它看做是非內聯的。

5.構造函數不能是虛函數,因為構造的時候,對象還是一片位定型的空間,只有構造完成后,對象才是具體類的實例。

6.析構函數可以是虛函數,而且通常聲名為虛函數。

下面的幾個函數都不能定義為虛函數:
1)友元函數,它不是類的成員函數
2)全局函數
3)靜態成員函數,它沒有this指針
3)構造函數,拷貝構造函數,以及賦值運算符重載(可以但是一般不建議作為虛函數)

多態 的 缺點

  • 降低了程序運行效率(多態需要去找虛表的地址)
  • 空間浪費


免責聲明!

本站轉載的文章為個人學習借鑒使用,本站對版權不負任何法律責任。如果侵犯了您的隱私權益,請聯系本站郵箱yoyou2525@163.com刪除。



 
粵ICP備18138465號   © 2018-2025 CODEPRJ.COM