你好,C++(37)上車的人請買票!6.3.3 用虛函數實現多態

6.3.3  用虛函數實現多態

在理解了面向對象的繼承機制以後,咱們知道了在大多數狀況下派生類是基類的「一種」,就像「學生」是「人」類中的一種同樣。既然「學生」是「人」的一種,那麼在使用「人」這個概念的時候,這個「人」能夠指的是「學生」,而「學生」也能夠應用在「人」的場合。好比能夠問「教室裏有多少人」,實際上問的是「教室裏有多少學生」。這種用基類指代派生類的關係反映到C++中,就是基類指針能夠指向派生類的對象,而派生類的對象也能夠當成基類對象使用。這樣的解釋對你們來講是否是很抽象呢?不要緊,能夠回想生活中常常遇到的一個場景:「上車的人請買票」。在這句話中,涉及一個類——人,以及它的一個動做——買票。但上車的人多是老師、學生,也多是工人、農民或者某個程序員,他們買票的方式也各不相同,有的投幣,有的刷卡,可爲何售票員不說「上車的老師請刷卡買票」或者說「上車的工人請投幣買票」,而僅僅說「上車的人請買票」就足夠了呢?這是由於雖然上車的人多是老師、學生、公司職員等,但他們都是「人」這個基類的派生類,因此這裏就能夠用基類「人」來指代全部派生類對象,經過基類的接口「買票」來調用派生類的對這個接口的具體實現來完成買票的具體動做。如圖6-12所示。程序員

 

圖6-12  「上車的人請買票」面試

學習了前面的封裝和繼承,咱們能夠用C++把這個場景描述以下:安全

// 「上車買票」演示程序

// 定義Human類,這個類有一個接口函數BuyTicket()表示買票的動做
class Human
{
 // Human類的行爲
public:
    // 買票接口函數
    void BuyTicket()
    {
        cout<<"人買票。"<<endl;
    }
};

// 從「人」派生兩個類,分別表示老師和學生
class Teacher : public Human
{
 public:
        // 對基類提供的接口函數從新定義,適應派生類的具體狀況
        void BuyTicket()
        {
             cout<<"老師投幣買票。"<<endl;
       }
};

class Student : public Human
{
public:
     void BuyTicket()
    {
         cout<<"學生刷卡買票。"<<endl;
    }
};

// 在主函數中模擬上車買票的場景
int main()
{
// 車上上來兩我的,一個是老師,另外一個是學生
// 基類指針指向派生類對象
      Human* p1 = new Teacher();
      Human* p2 = new Student();
// 上車的人請買票
p1->BuyTicket(); // 第一我的是老師,投幣買票
p1->BuyTicket(); // 第二我的是學生,刷卡買票
// 銷燬對象
delete p1;
delete p2;
p1 = p2 = nullptr;

    return 0;
}

在這段代碼中,咱們先定義了一個基類Human,它有一個接口函數BuyTicket()表示「人」買票的動做。而後定義了它的兩個派生類Teacher和Student,經過繼承,這兩個派生類原本已經直接擁有了BuyTicket()函數表示買票的行爲,可是,「老師」和「學生」買票的行爲是比較特殊的,因此咱們又各自在派生類中對BuyTicket()函數做了從新定義以表達他們特殊的買票動做。在主函數中,咱們模擬了「上車買票」這一場景:首先分別建立了Teacher和Student對象,並用基類Human的兩個指針分別來指代這兩個對象,而後經過Human類型的指針調用接口函數BuyTicket()函數來表達「上車的人請買票」的意思,完成Teacher和Student對象的買票動做。最後,程序的輸出結果是:ide

人買票。函數

人買票。性能

細心的你必定已經注意到一件奇怪的問題:雖然Teacher和Student都各自從新定義了表示買票動做的BuyTicket()函數,雖然基類的指針指向的實際是派生類的對象,但是在用基類的指針調用這個函數時,獲得的動做倒是相同的,都是來自基類的動做。這顯然是不合適的。雖然都是「人買票」,可是不一樣的人應該有不一樣的買票方式,若是這我的是老師就投幣買票,若是是學生就該刷卡買票。根據「人」所指代的具體對象不一樣動做也應該有所不一樣。爲了解決這個問題,C++提供了虛函數(virtual function)的機制。在基類的函數聲明前加上virtual關鍵字,這個函數就成爲了虛函數,而派生類中對這個虛函數的從新定義,不管是否顯式地添加了virtual關鍵字,也仍然是虛函數。在類中擁有虛函數的狀況下,若是經過基類指針調用類中的虛函數,那將調用這個指針實際所指向的具體對象(多是基類對象,也多是派生類對象,根據運行時狀況而定)的虛函數,而再也不是像上面的例子那樣,基類指針指向的是派生類的對象,調用的倒是基類的函數,也就完美地解決了上面的問。像這種在派生類中利用虛函數對基類的成員函數進行從新定義,並在運行時刻根據實際的對象來決定調用哪個函數的機制,被稱爲函數重寫(override) 。學習

 

重載仍是重寫,這是一個問題!spa

在前面的5.3小節中,咱們學習過函數的重載,而在這裏,咱們又學習了函數的重寫。那麼,這對都姓「重」的孿生兄弟有什麼區別呢?如何辨認區分它們呢?指針

實際上,它們都是C++中對函數行爲進行從新定義的一種方式,同時,它們從新定義的函數名都跟原來的相同,因此它們才都姓「重」,只是由於它們發生的時間和位置不一樣,這才產生了「重載」和「重寫」的區別。code

重載(overload)是一個編譯時概念,它發生在代碼的同一層級。它表示在代碼的同一層級(同一名字空間或者同一個類)中,一個函數因參數類型與個數不一樣能夠有多個不一樣的實現。在編譯時刻,編譯器會根據函數調用的實際參數類型和個數來決定調用哪個重載函數版本。

重寫(override)是一個運行時概念,它發生在代碼的不一樣層級(基類和派生類之間)。它表示在派生類中對基類中的虛函數進行從新定義,二者的函數名、參數類型和個數都徹底相同,只是具體實現不一樣。而在運行時刻,若是是經過基類指針調用虛函數,它會根據這個指針實際指向的具體對象類型來選擇調用基類或是派生類的重寫函數。例如:

// 同一層級的兩個同名函數因參數不一樣而造成重載
class Human
{
public:
    virtual void Talk()
    {
           cout<<"Ahaa"<<endl;
    }

    virtual void Talk(string msg)
    {
        cout<<msg<<endl;
    }
};

// 不一樣層級的兩個同名且參數相同的函數造成重寫
class Baby : public Human
{
public:
    virtual void Talk()
    {
        cout<<"Ma-Ma"<<endl;
    }
};

int main()
{
    Human MrChen;
    // 根據參數的不一樣來決定具體調用的重載函數,在編譯時刻決定
MrChen.Talk();   // 調用無參數的Talk()
    MrChen.Talk("Balala"); // 調用以string爲參數的Talk(string)

Human* pBaby = new Baby();
// 根據指針指向的實際對象的不一樣來決定具體調用的重寫函數,在運行時刻決定
pBaby->Talk(); // 調用Baby類的Talk()函數

    delete pBaby;
    pBaby = nullptr;

    return 0;
}

        在這個例子中,Human類當中的兩個Talk()函數是重載函數,由於它們位於同一層級,擁有相同的函數名可是參數不一樣。而Baby類的Talk()函數則是對Human類的Talk()函數的重寫了,由於它們位於不一樣層級(一個在基類,一個在派生類),可是函數名和參數都相同。能夠記住這樣一個簡單的規則:相同層級不一樣參數是重載,不一樣層級相同參數是重寫。

        另外還須要注意的一點是,重載和重寫的結合,會引發函數的隱藏(hide)。仍是上面的例子:

Baby cici;
cici.Talk("Ba-Ba");  // 錯誤:Baby類中的Talk(string)函數被隱藏,沒法調用

        這樣的結果是否是讓人有點意外?原本,按照類的繼承規則,Baby類也應該繼承Human類的Talk(string)函數。然而,這裏Baby類對Talk()函數的重寫隱藏了從Human類繼承的Talk(string)函數,因此纔沒法使用Baby類的對象直接調用基類的Talk(string)函數。一個曲線救國的方法是,能夠經過基類的指針或類型轉換,間接地實現對被隱藏函數的調用:

((Human)cici).Talk("Ba-Ba"); // 經過類型轉換實現對被隱藏函數的調用

        可是,值得告誡的是,不到萬不得已,不要這樣作。

        咱們在這裏對重載和重寫進行比較,其意義並不在於讓咱們去作一個名詞辨析的考試題(雖然這種題目在考試或者面試中也很是常見),而在於讓咱們理解C++中有這樣兩種對函數進行從新定義的方式,從而可讓咱們在合適的地方使用合適的方式,充分發揮用函數解決問題的靈活性。

如今,就能夠用虛函數來解決上面例子中的奇怪問題,讓經過Human基類指針調用的BuyTicket()函數,能夠根據指針所指向的真實對象來選擇不一樣的買票動做:

// 通過虛函數機制改寫後的「上車買票」演示程序
// 定義Human類,提供公有接口
class Human
{
// Human類的行爲
public:
    // 在函數前添加virtual關鍵字,將BuyTicket()函數聲明爲虛函數,
    // 表示其派生類可能對這個虛函數進行從新定義以知足其特殊須要   
    virtual void BuyTicket()     
    {
        cout<<"人買票。"<<endl;
    }
};

// 在派生類中對虛函數進行從新定義
class Teacher : public Human
{
public:
    // 根據實際狀況從新定義基類的虛函數以知足本身的特殊須要
    // 不一樣的買票方式
virtual void BuyTicket()    
    {
        cout<<"老師投幣買票。"<<endl;
    }
};

class Student : public Human
{
public:
    // 不一樣的買票方式
    virtual void BuyTicket()   
    {
        cout<<"學生刷卡買票。"<<endl;
    }
};

//

虛函數機制的改寫,只是在基類的BuyTicket()函數前加上了virtual關鍵字(派生類中的virtual關鍵字是能夠省略的),使其成爲了一個虛函數,其餘代碼沒作任何修改,可是代碼所執行的動做卻發生了變化。Human基類的指針p1和p2對BuyTicket()函數的調用,再也不執行基類的這個函數,而是根據這些指針在運行時刻所指向的真實對象類型來動態選擇,指針指向哪一個類型的對象就執行哪一個類的BuyTicket()函數。例如,在執行「p1->BuyTicket()」語句的時候,p1指向的是一個Teacher類對象,那麼這裏執行的就是Teacher類的BuyTicket()函數,輸出「老師投幣買票」的內容。通過虛函數的改寫,這個程序最後才輸出符合實際的結果:

老師投幣買票。

學生刷卡買票。

這裏咱們注意到,Human基類的BuyTicket()虛函數雖然定義了但從未被調用過。而這也剛好體現了虛函數「虛」的特徵:虛函數是虛(virtual)的,不實在的,它只是提供一個公共的對外接口供派生類對其重寫以提供更具體的服務,而一個基類的虛函數自己卻不多被調用。更進一步地,咱們還能夠在虛函數聲明後加上「= 0」的標記而不定義這個函數,從而把這個虛函數聲明爲純虛函數。純虛函數意味着基類不會實現這個虛函數,它的全部實現都留給其派生類去完成。在這裏,Human基類中的BuyTicket()虛函數就從未被調用過,因此咱們也能夠把它聲明爲一個純虛函數,也就至關於只是提供了一個「買票」動做的接口,而具體的買票方式則留給它的派生類去實現。例如:

// 使用純虛函數BuyTicket()做爲接口的Human類
class Human
{
// Human類的行爲
public:
    // 聲明BuyTicket()函數爲純虛函數
    // 在代碼中,咱們在函數聲明後加上「= 0」來表示它是一個純虛函數
    virtual void BuyTicket() = 0;
};

當類中有純虛函數時,這個類就成爲了一個抽象類(abstract class),它僅用做被繼承的基類,向外界提供一致的公有接口。同普通類相比,抽象類的使用有一些特殊之處。首先,由於抽象類中包含有還沒有完工的純虛函數,因此不能建立抽象類的具體對象。若是試圖建立一個抽象類的對象,將產生一個編譯錯誤。例如:

// 編譯錯誤,不能建立抽象類的對象
Human aHuman;

其次,若是某個類從抽象類派生,那麼它必須實現其中的純虛函數才能成爲一個實體類,不然它將繼續保持抽象類的特徵,沒法建立實體對象。例如:

class Student : public Human
{
public:
    // 實現基類中的純虛函數,讓Student類成爲一個實體類
    virtual void BuyTicket()   
    {
        cout<<"學生刷卡買票。"<<endl;
    }
};

使用virtual關鍵字將普通函數修飾成虛函數以造成多態的很重要的一個應用是,咱們一般用它修飾基類的析構函數而使其成爲一個虛函數,以確保在利用基類指針釋放派生類對象時,派生類的析構函數可以獲得正確執行。例如:

class Human
{
public:
    // 用virtual修飾的析構函數
    virtual ~Human()
    {
          cout<<"銷燬Human對象"<<endl;
    }
};

class Student : public Human
{
public:
    // 重寫析構函數,完成特殊的銷燬工做
    virtual ~Student()    
    {
        cout<<"銷燬Student對象"<<endl;
    }
};

// 將一個Human類型的指針,指向一個Student類型的對象
Human* pHuman = new Student();


//// 利用Human類型的指針,釋放它指向的Student類型的對象
// 由於析構函數是虛函數,因此這個指針所指向的Student對象的析構函數會被調用,
// 不然,會錯誤地調用Human類的析構函數
delete pHuman;
pHuman = nullptr;

最佳實踐:不要在構造函數或析構函數中調用虛函數

咱們知道,在基類的普通函數中,咱們能夠調用虛函數,而在執行的時候,它會根據具體的調用這個函數的對象而動態決定調用執行具體的某個派生類重寫後的虛函數。這是C++多態機制的基本規則。然而,這個規則並非放之四海皆準的。若是這個虛函數出如今基類的構造函數或者析構函數中,在建立或者銷燬派生類對象時,它並不會如咱們所願地執行派生類重寫後的虛函數,取而代之的是,它會直接執行這個基類自身的虛函數。換句話說,在基類構造或析構期間,虛函數是被禁止的。

爲何會有這麼奇怪的行爲?這是由於,在建立一個派生類的對象時,基類的構造函數是先於派生類的構造函數被執行的,若是咱們在基類的構造函數中調用派生類重寫的虛函數,而此時派生類對象還沒有建立完成,其數據成員還沒有被初始化,派生類的虛函數執行或多或少會涉及到它的數據成員,而對未初始化的數據成員進行訪問,無疑是一場惡夢的開始。

在基類的析構函數中調用派生類的虛函數也存在類似的問題。基類的析構函數後於派生類的析構函數被執行,若是咱們在基類的析構函數中調用派生類的虛函數,而此時派生類的數據成員已經被釋放,若是虛函數中涉及對派生類已經釋放的數據成員的訪問,就成了未定義行爲,後果自負。

爲了阻止這些行爲可能帶來的危害,C++禁止了虛函數在構造函數和析構函數中的向下匹配。爲了不這種不一致的匹配規則所帶來的歧義(你覺得它會像普通函數中的虛函數同樣,調用派生類的虛函數,而實際上它調用的倒是基類自身的虛函數),最好的方法就是,不要在基類的構造函數和析構函數中調用虛函數。永絕後患!

當咱們在派生類中重寫基類的某個虛函數對其行爲進行從新定義時,並不須要顯式地使用virtual關鍵字來講明這是一個虛函數重寫,只須要派生類和基類的兩個函數的聲明相同便可。例如上面例子中的Teacher類重寫了Human類的BuyTicket()虛函數,其函數聲明中的virtual關鍵字就是可選的。無須添加virtual關鍵字的虛函數重寫雖然簡便,可是卻很容易讓人暈頭轉向。由於若是派生類的重寫虛函數以前沒有virtual關鍵字,會讓人對代碼的真實意圖產生疑問:這究竟是一個普通的成員函數仍是虛函數重寫?這個函數是從基類繼承而來的仍是派生類新添加的?這些疑問在必定程度上影響了代碼的可讀性以及可維護性。因此,雖然在語法上不是必要的,但爲了代碼的可讀性和可維護性,咱們最好仍是在派生類的虛函數前加上virtual關鍵字。

爲了讓代碼的意義更加明晰,在 C++中,咱們可使用 override關鍵字來修飾一個重寫的虛函數,從而讓程序員能夠在代碼中更加清晰地表達本身對虛函數重寫的實現意圖,增長代碼的可讀性。例如:

class Student : public Human
{
public:
// 雖然沒有virtual關鍵字,
// 可是override關鍵字一目瞭然地代表,這就是一個重寫的虛函數
    void BuyTicket() override  
    {
        cout<<"學生刷卡買票。"<<endl;
    }
    // 錯誤:基類中沒有DoHomework()這個虛函數,不能造成虛函數重寫
    void DoHomework() override
    {
         cout<<"完成家庭做業。"<<endl;
    }
};

從這裏能夠看到,override關鍵字僅能對派生類重寫的虛函數進行修飾,表達程序員的實現意圖,而不能對普通成員函數進行修飾以造成重寫。上面例子中的 DoHomework() 函數並無基類的同名虛函數可供重寫,因此添加在其後的 override關鍵字會引發一個編譯錯誤。若是但願某個函數是虛函數重寫,就在其函數聲明後加上override關鍵字,這樣能夠很大程度地提升代碼的可讀性,同時也可讓代碼嚴格符合程序員的意圖。例如,程序員但願派生類的某個函數是虛函數重寫而爲其加上override修飾,編譯器就會幫助檢查是否可以真正造成虛函數重寫,若是基類沒有同名虛函數或者虛函數的函數形式不一樣沒法造成重寫,編譯器會給出相應的錯誤提示信息,程序員能夠根據這些信息做進一步的處理。

與override相對的,有的時候,咱們還但願虛函數不被默認繼承,阻止某個虛函數被派生類重寫。在這種狀況下,咱們能夠爲虛函數加上 final 關鍵字來達到這個目的。例如: 

// 學生類
class Student : public Human
{
public:
// final關鍵字表示這就是這個虛函數的最終(final)實現,
// 不可以被派生類重寫進行從新定義
    virtual void BuyTicket() final  
    {
        cout<<"學生刷卡買票。"<<endl;
    }
    // 新增長的一個虛函數
    // 沒有final關鍵字修飾的虛函數,派生類能夠對其進行重寫從新定義
    virtual void DoHomework() override
    {
           cout<<"完成家庭做業。"<<endl;
    }
};

// 小學生類
class Pupil : public Student
{
public:
// 錯誤:不能對基類中使用final修飾的虛函數進行重寫
// 這裏表達的意義是,不管是Student仍是派生的Pupil,買票的方式都是同樣的,
// 無需也不能經過虛函數重寫對其行爲進行從新定義
    virtual void BuyTicket() 
    {
        cout<<"學生刷卡買票。"<<endl;
    }

     // 派生類對基類中沒有final關鍵字修飾的虛函數進行重寫
     virtual void DoHomework() override
    {
           cout<<"小學生完成家庭做業。"<<endl;
    }
};

既然虛函數的意義就是用來被重寫以實現面向對象的多態機制,那麼爲何咱們還要使用final關鍵字來阻止虛函數重寫的發生呢?任何事物都有其兩面性,C++的虛函數重寫也不例外。實際上,咱們有不少正當的理由來阻止一個虛函數被它的派生類重寫,其中最重要的一個理由就是這樣作能夠提升程序的性能。由於虛函數的調用須要查找類的虛函數表,若是程序中大量地使用了虛函數,那麼將在虛函數的調用上浪費不少沒必要要的時間,從而影響程序性能。阻止沒必要要的虛函數重寫,也就是減少了虛函數表的大小,天然也就減小了虛函數調用時的查表時間提升了程序性能。而這樣作的另一個理由是出於代碼安全性的考慮,某些函數庫出於擴展的須要,提供了一些虛函數做爲接口供專業的程序員對其進行重寫,從而對函數庫的功能進行擴展。可是對於函數庫的普通使用者而言,重寫這些函數是很是危險的,由於知識或經驗的不足很容易出錯。因此有必要使用final關鍵字阻止這類重寫的發生。

虛函數重寫能夠實現面向對象的多態機制,但過多的虛函數重寫又會影響程序的性能,同時使得程序比較混亂。這時,咱們就須要使用final關鍵字來阻止某些虛函數被無心義地重寫,從而取得某種靈活性與性能之間的平衡。那麼,何時該使用final而何時又不應使用呢?這裏有一個簡單的原則:若是某人從新定義了一個派生類並重寫了基類的某個虛函數,那麼會產生語義上的錯誤嗎?若是會,則須要使用final關鍵字來阻止虛函數被重寫。例如,上面例子中的Student有一個來自它的基類Human的虛函數 BuyTicker(),而當定義Student的派生類Pupil時,就不該該再重寫這個虛函數了,由於不管是Student仍是 Pupil,其BuyTicket()函數的行爲應該是同樣的,不須要從新定義。在這種狀況下,就可使用 final 關鍵字來阻止虛函數重寫的發生。若是出於性能的要求,或者是咱們只是簡單地不但願虛函數被重寫,一般,最好的作法就是在一開始的地方就不要讓這個函數成爲虛函數。  

面向對象的多態機制爲派生類修改基類的行爲,並以一致的調用形式知足不一樣的需求提供了一種可能。合理利用多態機制,能夠爲程序開發帶來更大的靈活性。

1. 接口統一,高度複用

應用程序沒必要爲每一個派生類編寫具體的函數調用,只須要在基類中定義好接口,而後針對接口編寫函數調用,而具體實現再留給派生類本身去處理。這樣就能夠「以不變應萬變」,能夠應對需求的不斷變化(需求發生了變化,只須要修改派生類的具體實現,而對函數的調用不須要改變),從而大大提升程序的可複用性(針對接口的複用)。

2. 向後兼容,靈活擴展

派生類的行爲能夠經過基類的指針訪問,能夠很大程度上提升程序的可擴展性,由於一個基類的派生類能夠不少,而且能夠不斷擴充。好比在上面的例子中,若是想要增長一種乘客類型,只須要添加一個Human的派生類,實現本身的BuyTicket()函數就能夠了。在使用這個新建立的類的時候,無須修改程序代碼中的調用形式。

相關文章
相關標籤/搜索