實實在在說多態(C++篇 相同函數名 依據上下文 實現卻不同)

 
 
1.   什麼是多態... 1
2.   多態帶來的好處... 1
3.   C++中實現多態的方式... 1
4.   細說用函數重載實現的多態... 2
5.   細說用模板函數實現的多態... 3
6.   小結... 4
7.   細說用虛函數實現的多態... 4
7.1.    虛函數是怎麼回事... 4
7.2.    向上轉型... 5
7.3.    爲什麼要用指針或引用來實現動態多態... 6
7.4.    爲什麼動態多態要用public繼承... 6
8.   總結... 7
9.   附錄:...7
 
多態是C++中的一個重要的基礎,可以這樣說,不掌握多態就是C++的門個漢。然而長期以來,C++社羣對於多態的內涵和外延一直爭論不休。大有只見樹木不見森林之勢。多態到底是怎麼回事呢?說實在的,我覺的多態這個名字起的不怎麼好(或是譯的不怎麼好)。要是我給起名的話,我就給它定一個這樣的名字--“調用’同名函數’卻會因上下文不同會有不同的實現的一種機制”。這個名字長是長了點兒,可是比“多態”清楚多了。看這個長的定義,我們可以從中找出多態的三個重要的部分。一是“相同函數名”,二是“依據上下文”,三是“實現卻不同”。嘿,還是個順口溜呢。我們且把它們叫做多態三要素吧。
 
多態帶來兩個明顯的好處:一是不用記大量的函數名了,二是它會依據調用時的上下文來確定實現。確定實現的過程由C++本身完成另外還有一個不明顯但卻很重要的好處是:帶來了面向對象的編程。
3.  C++中實現多態的方式
 
C++中共有三種實現多態的方式。由“容易說明白”到“不容易說明白”排序分別爲。第一種是函數重載;第二種是模板函數;第三種是虛函數。
 
函數重載是這樣一種機制:允許有不同參數的函數有相同的名字。
具體一點講就是:假如有如下三個函數:
void test(int arg){}        //函數1
void test(char arg){}        //函數2
void test(int arg1,int arg2){}    //函數3
如果在C中編譯,將會得到一個名字衝突的錯誤而不能編譯通過。在C++中這樣做是合法的。可是當我們調用test的時候到底是會調用上面三個函數中的哪一個呢?這要依據你在調用時給的出的參數來決定。如下:
    test(5);       //調用函數1
    test('c');//調用函數2
    test(4,5); //調用函數3
C++是如何做到這一點的呢?原來聰明的C++編譯器在編譯的時候悄悄的在我們的函數名上根據函數的參數的不同做了一些不同的記號。具體說如下:
void test(int arg)           //被標記爲 ‘test有一個int型參數’
void test(char arg)          //被標記爲 ‘test有一個char型的參數’
void test(int arg1,int arg2) //被標記爲 ‘test第一個參數是int型,第二個參數爲int型’
這樣一來當我們進行對test的調用時,C++就可以根據調用時的參數來確定到底該用哪一個test函數了。噢,聰明的C++編譯器。其實C++做標記做的比我上面所做的更聰明。我上面哪樣的標記太長了。C++編譯器用的標記要比我的短小的多。看看這個真正的C++的對這三個函數的標記:
?test@@YAXD@Z
?test@@YAXH@Z
?test@@YAXHH@Z
是不是短多了。但卻不好看明白了。好在這是給計算機看的,人看不大明白是可以理解的。
還記得cout吧。我們用<<可以讓它把任意類型的數據輸出。比如可以象下面那樣:
    cout << 1;    //輸出int型
    cout << 8.9; //輸出double型
    cout << 'a';  //輸出char型
    cout << "abc";//輸出char數組型
    cout << endl; //輸出一個函數
cout之所以能夠用一個函數名<<(<<是一個函數名)就能做到這些全是函數重載的功能。要是沒有函數重載,我們也許會這樣使用cout,如下:
    cout int<< 1;               //輸出int型
    cout double<< 8.9;         //輸出double型
    cout char<< 'a';          //輸出char型
    cout charArray<< "abc";    //輸出char數組型
    cout function(…)<< endl;  //輸出函數
爲每一種要輸出的類型起一個函數名,這豈不是很麻煩呀。
不過函數重載有一個美中不足之處就是不能爲返回值不同的函數進行重載。那是因爲人們常常不爲函數調用指出返回值。並不是技術上不能通過返回值來進行重載。
 
所謂模板函數(也有人叫函數模板)是這樣一個概念:函數的內容有了,但函數的參數類型卻是待定的(注意:參數個數不是待定的)。比如說一個(準確的說是一類或一羣)函數帶有兩個參數,它的功能是返回其中的大值。這樣的函數用模板函數來實現是適合不過的了。如下。
template <typename T>
T getMax(T arg1, T arg2)
{
   return arg1 > arg2 ? arg1:arg2;//代碼段1
}
這就是基於模板的多態嗎?不是。因爲現在我們不論是調用getMax(1, 2)還是調用getMax(3.0, 5.0)都是走的上面的函數定義。它沒有根據調用時的上下文不同而執行不同的實現。所以這充其量也就是用了一個模板函數,和多態不沾邊。怎樣才能和多態沾上邊呢?用模板特化呀!象這樣:
template<>
char* getMax(char* arg1,char* arg2)
{
   return (strcmp(arg1, arg2) > 0)?arg1:arg2;//代碼段2
}
這樣一來當我們調用getMax(“abc”, “efg”)的時候,就會執行代碼段2,而不是代碼段1。這樣就是多態了。
更有意思的是如果我們再寫這樣一個函數:
char getMax(char arg1,char arg2)
{
   return arg1>arg2?arg1:arg2;//代碼段3
}
當我們調用getMax(‘a’, ‘b’)的時候,執行的會是代碼段3,而不是代碼段1或代碼段2。C++允許對模板函數進行函數重載,就象這個模板函數是一個普通的函數一樣。於是我們馬上能想到寫下面這樣一個函數來做三個數中取大值的處理:
int getMax(int arg1,int arg2,int arg3)
{
   return getMax(arg1, max(arg2, arg3) ); //代碼段4
}
同樣我們還可以這樣寫:
template <typename T>
T getMax(T arg1, T arg2, T arg3)
{
   return getMax(arg1, getMax(arg2, arg3) );//代碼段5
}
現在看到結合了模板的多態的威力了吧。比只用函數重載厲害多了。
 
上面的兩種多態在C++中有一個總稱:靜態多態。之所以叫它們靜態多態是因爲它們的多態是在編譯期間就確定了。也就是說前面所說的函數1,2,3代碼段1,2,3,4,5這些,在編譯完成後,應該在什麼樣的上下文的調用中執行哪一些就確定了。比如:如果調用getMax(0.1, 0.2, 0.3)就會執行代碼段5。如果調用test(5)就執行函數1。這些是在編譯期間就能確定下來的。
靜態多態還有一個特點,就是:“總和參數較勁兒”。
下面所要講的一種多態就是必需是在程序的執行過程中才能確定要真正執行的函數。所以這種多態在C++中也被叫做動態多態。
 
 
首先來說一說虛函數,所謂虛函數是這樣一個概念:基類中有這麼一些函數,這些函數允許在派生類中其實現可以和基類的不一樣。在C++中用關鍵字virtual來表示一個函數是虛函數。
C++中還有一個術語 “覆蓋”與虛函數關係密切。所謂覆蓋就是說,派生類中的一個函數的聲明,與基類中某一個函數的聲明一模一樣,包括返回值,函數名,參數個數,參數類型,參數次序都不能有差異。(注1說覆蓋和虛函數關係密切的原因有兩個:一個原因是,只有覆蓋基類的虛函數纔是安全的。第二個原因是,要想實現基於虛函數的多態就必須在派生類中覆蓋基類的虛函數。
接下來讓我們說一說爲什麼要有虛函數,分析一下爲什麼派生類非要在某些情況下覆蓋基類的虛函數。就以那個非常著名的圖形繪製的例子來說吧。假設我們在爲一個圖形系統編程。我們可能有如下的一個類結構。
圖7-1
形狀對外公開一個函數來把自己繪製出來。這是合理的,形狀就應該能繪製出來,對吧?由於繼承的原因,多邊形和圓形也有了繪製自己這個函數。
現在我們來討論在這三個類中的繪製自己的函數都應該怎麼實現。在形狀中嘛,什麼也不做就行了。在多邊形中嘛,只要把它所有的頂點首尾相連起來就行了。在圓形中嘛,依據它的圓心和它的半徑畫一個360度的圓弧就行了。
可是現在的問題是:多邊形和圓形的繪製自己的函數是從形狀繼承而來的,並不能做連接頂點和畫圓弧的工作。
怎麼辦呢?覆蓋它,覆蓋形狀中的繪製自己這個函數。於是我們在多邊形和圓形中各做一個繪製自己的函數,覆蓋形狀中的繪製自己的函數。爲了實現覆蓋,我們需要把形狀中的繪製自己這個函數用virtual修飾。而且形狀中的繪製自己這個函數什麼也不幹,我們就把它做成一個純虛函數。純虛函數還有一個作用,就是讓它所在的類成爲抽象類。形狀理應是一個抽象類,不是嗎?於是我們很快寫出這三個類的代碼如下:
class Shape//形狀
{
public:
   virtualvoid DrawSelf()//繪製自己
    {
       cout <<"我是一個什麼也繪不出的圖形" << endl;
    }
};
class Polygo:public Shape//多邊形
{
public:
   void DrawSelf()  //繪製自己
    {
       cout <<"連接各頂點" << endl;
    }
};
class Circ:public Shape//
{
public:
   void DrawSelf()  //繪製自己
    {
       cout <<"以圓心和半徑爲依據畫弧" << endl;
    }
};
下面,我們將以上面的這三個類爲基礎來說明動態多態。在進行更進一步的說明之前,我們先來說一個不得不說的兩個概念:“子類型”和“向上轉型”。
 
子類型很好理解,比如上面的多邊形和圓形就是形狀的子類型。關於子類型還有一個確切的定義爲:如果類型X擴充或實現了類型Y,那麼就說X是Y的子類型。
向上轉型的意思是說把一個子類型轉的對象換爲父類型的對象。就好比把一個多邊形轉爲一個形狀。向上轉型的意思就這麼簡單,但它的意義卻很深遠。向上轉型中有三點需要我們特別注意。第一,向上轉型是安全的。第二,向上轉型可以自動完成。第三,向上轉型的過程中會丟失子類型信息。這三點在整個動態多態中發揮着重要的作用。
假如我們有如下的一個函數:
void OutputShape( Shape arg)//專門負責調用形狀的繪製自己的函數
{
    arg.DrawSelf();
}
那麼現在我們可以這樣使用OutputShape這個函數:
    Polygon shape1;
    Circ shape2;
    OutputShape(shape1);
    OutputShape(shape2);
我們之所以可以這樣使用OutputShape函數,正是由於向上轉型是安全的(不會有任何的編譯警告),是由於向上轉弄是自動的(我們沒有自己把shape1和shape2轉爲Shape類型再傳給OutputShape函數)。可是上面這段程序運行後的輸出結果是這樣的:
我是一個什麼也繪不出的圖形
我是一個什麼也繪不出的圖形
明明是一個多邊形和一個圓呀,應該是輸出這下面這個樣子才合理呀!
連接各頂點
以圓心和半徑爲依據畫弧
造成前面的不合理的輸出的罪魁禍首正是‘向上轉型中的子類型信息丟失’。爲了得到一個合理的輸出,得想個辦法來找回那些丟失的子類型信息。C++中用一種比較巧妙的辦法來找回那些丟失的子類型信息。這個辦法就是採用指針或引用。
 
對於一個對象來說無論有多少個指針指向它,這些個指針所指的都是同一個對象。(即使你用一個void的指針指向一個對象也是這樣的,不是嗎?)同理對於引用也一樣。
這究竟有多少深層次的意義呢?這裏的深層的意義是這樣的:子類型的信息本來就在它本身中存在,所以我們用一個基類的指針來指出它,這個子類型的信息也會被找到,同理引用也是一樣的。C++正是利用了指針的這一特性。來做到動態多態的。2現在讓我們來改寫OutputShape函數爲這樣:
void OutputShape( Shape& arg)//專門負責調用形狀的繪製自己的函數
{
    arg.DrawSelf();
}
現在我們的程序的輸出爲:
連接各頂點
以圓心和半徑爲依據畫弧
這樣的輸出纔是我們真正的想要的。我們實現的這種真正想要的輸出就是動態多態的實質。
 
在我們上面的代碼中,圓和多邊形都是從形狀公有繼承而來的。要是我們把圓的繼承改爲私有或保護會怎麼樣呢?我們來試一試。哇,我們得到一個編譯錯誤。這個錯誤的大致意思是說:“請不要用一個私有的方法”。怎麼回事呢?
是這麼回事。它的意思是說下面這樣說不合理。
所有的形狀都可以畫出來,圓這種形狀是不能畫出來的。
這樣合理嗎?不合理。所以請在多態中使用公有繼承吧。
 
多態的思想其實早在面向對象的編程出現之前就有了。比如C語言中的+運算符。這個運算符可以對兩個int型的變量求和,也可以對兩個char的變量求和,也可以對一個int型一個char型的兩個變量求和。加法運算的這種特性就是典型的多態。所以說多態的本質是同樣的用法在實現上卻是不同的。
 
注1:嚴格地講返回值可以不同,但這種不同是有限制的。詳細情況請看有關協變的內容。
注2:C++會悄悄地在含有虛函數的類裏面加一個指針。用這個指針來指向一個表格。這個表格會包含每一個虛函數的索引。用這個索引來找出相應的虛函數的入口地址。對於我們所舉的形狀的例子來說,C++會悄悄的做三個表,Shape一個,Polygon一個,Circ一個。它們分別記錄一個DrawSelf函數的入口地址。在程序運行的過程中,C++會先通過類中的那個指針來找到這個表格。再從這個表格中查出DrawSelf的入口地址。然後現通過這個入口地址來調用正直的DrawSelf。正是由於這個查找的過程,是在運行時完成的。所以這樣的多態纔會被叫做動態多態(運行時多態)
發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章