程序師世界是廣大編程愛好者互助、分享、學習的平台,程序師世界有你更精彩!
首頁
編程語言
C語言|JAVA編程
Python編程
網頁編程
ASP編程|PHP編程
JSP編程
數據庫知識
MYSQL數據庫|SqlServer數據庫
Oracle數據庫|DB2數據庫
 程式師世界 >> 編程語言 >> C語言 >> C++ >> C++入門知識 >> 你好,C++(37)上車的人請買票!6.3.3 用虛函數實現多態,6.3.3多態

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

編輯:C++入門知識

你好,C++(37)上車的人請買票!6.3.3 用虛函數實現多態,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對象的買票動作。最後,程序的輸出結果是:

人買票。

人買票。

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

 

重載還是重寫,這是一個問題!

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

實際上,它們都是C++中對函數行為進行重新定義的一種方式,同時,它們重新定義的函數名都跟原來的相同,所以它們才都姓“重”,只是因為它們發生的時間和位置不同,這才產生了“重載”和“重寫”的區別。

重載(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()函數就可以了。在使用這個新創建的類的時候,無須修改程序代碼中的調用形式。

  1. 上一頁:
  2. 下一頁:
Copyright © 程式師世界 All Rights Reserved